Pre-rendered landing pages with Critical CSS

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…):
Screenshot%20from%202019-11-01%2017-37-36

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.

7 Likes

This bit feels like a bit of a hack:

let cssPath = __meteor_bootstrap__.serverDir.replace('/server','/web.browser');
cssPath += "/"+fs.readdirSync(cssPath).find(file => file.slice(-4)==".css");

Is there a better way of getting the path/filename of the minified css file?

This some serious pioneering right here. Your users will appreciate!:pray: