My client is demanding a feature-rich client-side rendered web-app that at the same time scores 100/100 on Google PageSpeed Insights and renders very fast on first load with an empty cache. She wants to use the same site both as a web-app and as a landing page and have any search engine easily crawl the entire site with good SEO.
Is this possible using Meteor? How can it be done?
A score of 90 or above is considered good. 50 to 90 is a score that needs improvement, and below 50 is considered poor.
It's simple: a score from 1 to 49 is considered slow, 50 to 89 is considered average, and 90 to 100 is considered fast (if the score is 0, chances are Lighthouse encountered a bug).
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:
What this setup cannot achieve:
Essentially what you can make happen is:
How to accomplish this
I used Meteor 1.3 and these additional packages:
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:
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With