Yes, this is possible and easy using Meteor 1.3, a few extra packages, and a minor hack.
See bc-real-estate-math.com for an example. (this site only scores 97 because I haven't sized the images and Analytics and FB tracking have short cache lives)
Traditionally, a client-side rendered platform like Meteor was slow on first loads with an empty cache because of the big Javascript payload. Server-side rendering (using React) of the first page almost solves this except that Meteor out-of-the-box does not support async Javascript or inline CSS thus slowing down your first render and killing your Google PageSpeed Insights score (and argue as you might about that metric, it affects my clients' AdWord prices and thus I optimize for it).
This is what you can achieve with this answer's setup:
- Very fast time-to-first-render on empty cache, like 500ms
- No "flash of styled content"
- Score 100/100 on Google PageSpeed Insights
- Use of any webfont without killing your PageSpeed score
- Full SEO control including page title and meta
- Perfect integration with Google Analytics and Facebook Pixels that
accurately records every page view regardless of server- or
client-side rendering
- Google search bot and other crawlers see all of your pages' real HTML immediately without running scripts
- Seamlessly handles #hash URLs to scroll to parts of a page
- Use a small number (like < 30) of icon font
characters without adding requests or hurting speed score
- Scale up to any size of Javascript without impacting landing page experience
- All the regular awesomeness of a full Meteor web-app
What this setup cannot achieve:
- Large monolithic CSS frameworks will start to kill your PageSpeed score and slow down time-to-first-render. Bootstrap is about as big as you can go before you start seeing problems
- You cannot avoid a flash-of-wrong-font and still maintain 100/100 PageSpeed. The first render will be the client's web-safe font, the 2nd render will use whatever font you deferred earlier.
Essentially what you can make happen is:
- Client requests any url within your site
- Server sends back a
complete HTML file with inline CSS, async Javascript, and deferred
fonts
- Client requests images (if any) and server sends them
- The client can now render the page
- Deferred fonts (if any) arrive and page might re-render
- Javascript mother ship payload arrives in the
background
- Meteor boots up and you have a fully functioning web-app with
all the bells and whistles and no first-load penalty
- As long as you
give the user a few lines of text to read and a pretty picture to
look at, they will never notice the transition from static HTML
page to full-blown web-app
How to accomplish this
I used Meteor 1.3 and these additional packages:
- react
- react-dom
- react-router
- react-router-ssr
- react-helmet
- postcss
- autoprefixer
- meteor-node-stubs
React plays nice with server-side rendering, I haven't tried any other rendering engine. react-helmet is used to easily add and modify the <head>
of each page both client- and server-side (eg. required to set the title of each page). I use the autoprefixer to add all the vendor-specific prefixes to my CSS/SASS, certainly not required for this exercise.
Most of the site is then pretty straightforward following the examples in the react-router, reac-router-ssr, and react-helmet documentation. See those packages' docs for details on them.
First off, a very important file that should be in a shared Meteor directory (ie. not in a server or client folder). This code sets up the React server-side rendering, the <head>
tag, Google Analytics, Facebook tracking, and scrolls to #hash anchors.
import { Meteor } from 'meteor/meteor';
import { ReactRouterSSR } from 'meteor/reactrouter:react-router-ssr';
import { Routes } from '../imports/startup/routes.jsx';
import Helmet from 'react-helmet';
ReactRouterSSR.Run(
Routes,
{
props: {
onUpdate() {
hashLinkScroll();
// Notify the page has been changed to Google Analytics
ga('send', 'pageview');
},
htmlHook(html) {
const head = Helmet.rewind();
html = html.replace('<head>', '<head>' + head.title + head.base + head.meta + head.link + head.script);
return html; }
}
},
{
htmlHook(html){
const head = Helmet.rewind();
html = html.replace('<head>', '<head>' + head.title + head.base + head.meta + head.link + head.script);
return html;
},
}
);
if(Meteor.isClient){
// Google Analytics
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-xxxxx-1', 'auto', {'allowLinker': true});
ga('require', 'linker');
ga('linker:autoLink', ['another-domain.com']);
ga('send', 'pageview');
// Facebook tracking
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
document,'script','https://connect.facebook.net/en_US/fbevents.js');
fbq('init', 'xxxx');
fbq('track', "PageView");
fbq('trackCustom', 'LoggedOutPageView');
}
function hashLinkScroll() {
const { hash } = window.location;
if (hash !== '') {
// Push onto callback queue so it runs after the DOM is updated,
// this is required when navigating from a different page so that
// the element is rendered on the page before trying to getElementById.
setTimeout(() => {
$('html, body').animate({
scrollTop: $(hash).offset().top
}, 1000);
}, 100);
}
}
Here's how the routes are setup. Notice the title attributes that are later fed to react-helmet to set the <head>
content.
import React from 'react';
import { Router, Route, IndexRoute, browserHistory } from 'react-router';
import App from '../ui/App.jsx';
import Homepage from '../ui/pages/Homepage.jsx';
import ExamTips from '../ui/pages/ExamTips.jsx';
export const Routes = (
<Route path="/" component={App}>
<IndexRoute
displayTitle="BC Real Estate Math Online Course"
pageTitle="BC Real Estate Math Online Course"
isHomepage
component={Homepage} />
<Route path="exam-preparation-and-tips">
<Route
displayTitle="Top 3 Math Mistakes to Avoid on the UBC Real Estate Exam"
pageTitle="Top 3 Math Mistakes to Avoid on the UBC Real Estate Exam"
path="top-math-mistakes-to-avoid"
component={ExamTips} />
</Route>
);
App.jsx--the outer application component. Notice the <Helmet>
tag that sets some meta tags and the page title based on attributes of the specific page component.
import React, { Component } from 'react';
import { Link } from 'react-router';
import Helmet from "react-helmet";
export default class App extends Component {
render() {
return (
<div className="site-wrapper">
<Helmet
title={this.props.children.props.route.pageTitle}
meta={[
{name: 'viewport', content: 'width=device-width, initial-scale=1'},
]}
/>
<nav className="site-nav">...
An example page component:
import React, { Component } from 'react';
import { Link } from 'react-router';
export default class ExamTips extends Component {
render() {
return (
<div className="exam-tips blog-post">
<section className="intro">
<p>
...
How to add deferred fonts.
These fonts will load after the initial render and hence not delay time-to-first-render. I believe this is the only way to use webfonts without reducing PageSpeed score. It does however lead to a brief flash-of-wrong-font. Put this in a script file included in the client:
WebFontConfig = {
google: { families: [ 'Open+Sans:400,300,300italic,400italic,700:latin' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = 'https://ajax.googleapis.com/ajax/libs/webfont/1/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
If you use an excellent service like fontello.com and hand-pick only the icons you actually need, you can embed them into your inline <head>
CSS and get icons on first render without waiting for a big font file.
The Hack
That's almost enough but the problem is that our scripts, CSS, and fonts are being loaded synchronously and slowing down the render and killing our PageSpeed score. Unfortunately, as far as I can tell, Meteor 1.3 does not officially support any way to inline the CSS or add the async attribute to the script tags. We must hack a few lines in 3 files of the core boilerplate-generator package.
~/.meteor/packages/boilerplate-generator/.1.0.8.4n62e6++os+web.browser+web.cordova/os/boilerplate-generator.js
...
Boilerplate.prototype._generateBoilerplateFromManifestAndSource =
function (manifest, boilerplateSource, options) {
var self = this;
// map to the identity by default
var urlMapper = options.urlMapper || _.identity;
var pathMapper = options.pathMapper || _.identity;
var boilerplateBaseData = {
css: [],
js: [],
head: '',
body: '',
meteorManifest: JSON.stringify(manifest),
jsAsyncAttr: Meteor.isProduction?'async':null, // <------------ !!
};
....
if (item.type === 'css' && item.where === 'client') {
if(Meteor.isProduction){ // <------------ !!
// Get the contents of aggregated and minified CSS files as a string
itemObj.inlineStyles = fs.readFileSync(pathMapper(item.path), "utf8");;
itemObj.inline = true;
}
boilerplateBaseData.css.push(itemObj);
}
...
~/.meteor/packages/boilerplate-generator/.1.0.8.4n62e6++os+web.browser+web.cordova/os/packages/boilerplate-generator/boilerplate_web.browser.html
<html {{htmlAttributes}}>
<head>
{{#each css}}
{{#if inline}}
<style>{{{inlineStyles}}}</style>
{{else}}
<link rel="stylesheet" type="text/css" class="__meteor-css__" href="{{../bundledJsCssUrlRewriteHook url}}">
{{/if}}
{{/each}}
{{{head}}}
{{{dynamicHead}}}
</head>
<body>
{{{body}}}
{{{dynamicBody}}}
{{#if inlineScriptsAllowed}}
<script type='text/javascript'>__meteor_runtime_config__ = JSON.parse(decodeURIComponent({{meteorRuntimeConfig}}));</script>
{{else}}
<script {{../jsAsyncAttr}} type='text/javascript' src='{{rootUrlPathPrefix}}/meteor_runtime_config.js'></script>
{{/if}}
{{#each js}}
<script {{../jsAsyncAttr}} type="text/javascript" src="{{../bundledJsCssUrlRewriteHook url}}"></script>
{{/each}}
{{#each additionalStaticJs}}
{{#if ../inlineScriptsAllowed}}
<script type='text/javascript'>
{{contents}}
</script>
{{else}}
<script {{../jsAsyncAttr}} type='text/javascript' src='{{rootUrlPathPrefix}}{{pathname}}'></script>
{{/if}}
{{/each}}
</body>
</html>
Now count the number of characters in those 2 files you edited and enter the new values in the length field of those files' entries in ~/.meteor/packages/boilerplate-generator/.1.0.8.4n62e6++os+web.browser+web.cordova/os.json
Then delete the project/.meteor/local folder to force Meteor to use the new core package and restart your app (hot reload will not work). You will only see the changes in production mode.
This is obviously a hack and will break when Meteor updates. I'm hoping by posting this and getting some interest, we will work towards a better way.
To Do
Things to improve would be:
- Avoid the hack. Get MDG to officially support async script and inline
CSS in a flexible way
- Allow granular control over which CSS to inline and which to defer
- Allow granular control over which JS to asyn and which to sync and
which to inline.