Hi everyone, I wanted to share a little pre-render-with-critical-css experiment I’ve been working on.
I have a web & cordova app with some sales landing pages (/, /features, /pricing, /trial, /signin, etc), and I wanted to achieve all of the following:
- Pre-rendered and cached sales landing pages for bots & first-time visitors…
- …including in-line critical css (I use a material design library a lot of which isn’t needed on the landing pages)
- To reduce server load, routes behind the signin page don’t need pre-rendering - I’m happy with a “loading…” page (most logged in users will either be using the Cordova apps or the js and css bundles will be cached in the browser).
I use Vue.js, so I started with akryum:vue-ssr. This was a great start, but overly complex with data hydration, log-in cookies, none of which I really need. So I went back to the meteor vue SSR guide and the Vue SSR guide and came up with a home-grown solution which ticks all my boxes. This solution should be adaptable to other front-end layers.
My code and resulting server logs are below, but first some explanation:
- All landing page css files are in the
/client
folder so are automatically bundled into the main css bundle. - my /server/ssr.js file does the following on startup:
- pre-renders all the landing page routes, and stores each pre-render in an object;
- uses critical to extract all above-the-fold css for each route;
- combines all these css into one string and de-dupes it using clean-css (99% of the critical CSS for each landing page route is the same, so I decided to cache a single CSS for all of them rather than individual css files)
- sets up an
onPageLoad
listener to serve the cached landing pages+css, or the ‘loading…’ page if not a landing page.
- I’m deploying to Digital Ocean using MUP. I had to switch to the nabiltntn/meteord-node-with-chromium:8.11.2_dumb-init docker image as per mup issue #981
Here’s my /server/ssr.js
code:
// https://github.com/meteor-vue/vue-meteor/tree/master/packages/vue-ssr
// meteor npm install --save vue-server-renderer
import { onPageLoad } from "meteor/server-render";
let sharedCss = '';
const htmlCache = {};
// https://developers.google.com/web/updates/2015/03/increasing-engagement-with-app-install-banners-in-chrome-for-android?hl=en
const cdn = Meteor.settings.public.cdn;
const links=`
<link rel="icon" type="image/png" href="${cdn}/favicon-192x192.png" sizes="192x192">
<link rel="icon" type="image/png" href="${cdn}/favicon-128x128.png" sizes="128x128">
<link rel="apple-touch-icon" href="${cdn}/favicon-128x128.png" sizes="128x128" >
<link rel="apple-touch-icon-precomposed" href="${cdn}/favicon-128x128.png" sizes="128x128" >
`;
const defaultRoute=`
<h2 style="
width:100%;
font-family: 'Helvetica', 'Arial', sans-serif;
font-size: 45px;
font-weight: 400;
line-height: 48px;
margin: 24px 0;
text-align:center;
padding-top:40vh;
color:#666">
Loading...
</h2>
`;
onPageLoad((sink) => {
sink.appendToHead(links);
const path = sink.request.url.path;
const ssr = htmlCache[path];
if (ssr) sink.appendToHead("<style id='ssr-css'>"+sharedCss+"</style>");
sink.renderIntoElementById("app", ssr || defaultRoute);
});
// create pre-rendered landing pages with critical CSS.
// do this AFTER the above so that the onPageLoad handler still exists if there is a problem below...
if (Meteor.isProduction || false) { // don't bother during development, unless testing
// fake these client-only methods for server render
Meteor._localStorage = { getItem: () => null }
Meteor.loggingIn = () => false;
const Vue = require('vue');
const { createRenderer } = require('vue-server-renderer');
const { performance } = require('perf_hooks');
// meteor npm install --save critical@next
// https://github.com/addyosmani/critical/issues/359#issuecomment-470019922/package/critical
const critical = require('critical');
// meteor npm install --save clean-css
const CleanCSS = require('clean-css');
const ssrPaths = ['/', '/features', '/pricing', '/contact', '/trial', '/signin' ];
const renderer = createRenderer();
const rendererSync = Meteor.wrapAsync(renderer.renderToString, renderer);
const { app, router } = require('../imports/ui/CreateApp').default();
const syncGenerate = Meteor.wrapAsync(critical.generate, critical);
const dimensions = '1440x900,960x600,600x960,412x732,320x568'.split(',').map(dims => {
const [width, height] = dims.split('x');
return {width, height}
});
const fs = require('fs');
let cssPath = __meteor_bootstrap__.serverDir.replace('/server','/web.browser');
cssPath += "/"+fs.readdirSync(cssPath).find(file => file.slice(-4)==".css");
ssrPaths.forEach(path => {
console.log('ssr', 'pre-rendering', path);
let now = performance.now();
router.push(path);
const html = rendererSync(app);
htmlCache[path] = html.replace(/( data-v-\w{8}|<!--.*?-->)/g, '');
console.log('ssr', (" "+(performance.now() - now).toFixed(3)).slice(-9), 'got html:', htmlCache[path].length, 'bytes');
now = performance.now();
const res = syncGenerate({
base: '/',
rebase: false, // needs critical@2+ https://github.com/addyosmani/critical/issues/359#issuecomment-470019922
html,
css: cssPath,
dimensions,
minify: true,
penthouse: {forceInclude: [/mdl-layout__drawer-button/]},
});
if (res && res.css) sharedCss += res.css;
console.log('ssr', (" "+(performance.now() - now).toFixed(3)).slice(-9), 'got css:', res && res.css.length, 'bytes', path);
});
console.log('ssr', 'combining CSS files...');
// add any CSS which critical misses...
sharedCss += `
.mdl-layout__drawer{transform: translateX(-250px); width: 240px; position: absolute; left: 0; }
.mdl-snackbar{transform: translate(0, 80px)}
.mdl-layout__header:not(.mdl-layout--fixed-tabs) .mdl-layout__tab-bar-container{display:none}
`;
const clean = new CleanCSS({level: 2}).minify(sharedCss);
sharedCss = clean.styles;
console.log('ssr', ' done:', JSON.stringify(clean.stats));
}
Server boot-up logs include:
01T03:37:45 ssr: pre-rendering /
01T03:37:45 ssr: 58.583 got html: 38912 bytes
01T03:37:52 ssr: 6904.129 got css: 11920 bytes
01T03:37:52 ssr: pre-rendering /features
01T03:37:52 ssr: 37.663 got html: 17604 bytes
01T03:37:55 ssr: 2819.362 got css: 14060 bytes
01T03:37:55 ssr: pre-rendering /pricing
01T03:37:55 ssr: 27.897 got html: 19970 bytes
01T03:37:57 ssr: 2244.652 got css: 13502 bytes
01T03:37:57 ssr: pre-rendering /contact
01T03:37:57 ssr: 14.506 got html: 19380 bytes
01T03:37:59 ssr: 2270.436 got css: 14920 bytes
01T03:37:59 ssr: pre-rendering /trial
01T03:37:59 ssr: 14.137 got html: 20773 bytes
01T03:38:03 ssr: 3234.234 got css: 15565 bytes
01T03:38:03 ssr: pre-rendering /signin
01T03:38:03 ssr: 17.956 got html: 18398 bytes
01T03:38:05 ssr: 2045.320 got css: 14468 bytes
01T03:38:05 ssr: combining CSS files...
01T03:38:05 ssr: done: {"efficiency":0.7645763012968556,"minifiedSize":19878,"originalSize":84435,"timeSpent":427}
01T03:38:28 ssr: 0.024 /
01T03:39:30 ssr: 0.016 /
01T03:41:01 ssr: 0.008 /pricing
01T04:18:34 ssr: 0.037 /trial
01T04:19:38 ssr: 0.008 /
01T04:20:06 ssr: 0.006 /pricing
As you can see, the precached routes are now served in < 1ms. The main landing page request is 60.8kb (17.5kb gzippped) including pre-rendered html, critical css and even some svg!
Chrome lighthouse report from http://s.virtualinout.com (staging server of graphical re-design of my exisitng app, still a WIP…):
CSS coverage of the embedded CSS = 60%:
Edited 2019-11-07: update code to be a bit more efficient and eliminate random errors about callbacks running in a fibre.