Score 100 on Google PageSpeed Insights using Meteor (ie. a web-app landing page)
Asked Answered
W

1

6

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?

Waldgrave answered 12/7, 2016 at 22:20 Comment(0)
W
26

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.
Waldgrave answered 12/7, 2016 at 22:20 Comment(3)
A relevant GitHub issue for Meteor discussing proposed changes to the boilerplate: github.com/meteor/meteor/pull/3860Waldgrave
I'm also investigating if this package can avoid the hack: atmospherejs.com/meteorhacks/inject-initialWaldgrave
Hey @Waldgrave - interesting post. Can you tell me how do you handle your DDP data like in subscriptions in react-router-ssr ?Hydrastinine

© 2022 - 2024 — McMap. All rights reserved.