The Perfect Mix of Static Hosting and SSR — Nuxt.js on Google Cloud
By: Jamie Curnow
Tags: Jamstack, Nuxt, Server Side Rendering, Firebase, Static Site

I’m starting a new project and, as every developer knows, that means time to refactor! The project is a modern, pay-for-use VPN service that puts the user in control and uses WireGuard under the hood. I don’t have a link to share yet, but I’ll update this article when I have something to show after Christmas. I’m pretty excited about it and want the web app to be on-point, so I started completely from scratch and tried something a little different.

My usual frontend stack is a server-side rendered Nuxt app on Google’s Cloud Run. It’s a solid setup that I’ve used a handful of times in production and has all of the lovely benefits of server-rendered modern web apps (eg. SEO, initial page speed, etc). And Google’s Cloud Run service is the perfect place to host — a fully managed server that runs my app in a Docker container that’s really easy to build and scales from zero to front-page-of-hacker-news and beyond.

There are just two problems:

Cold starts are SLOW!

The server generates the same static pages on each request.

I know cold starts are kind of a non-problem, especially for apps that get a lot of traffic, but if and when your app does go cold it can be quite frustrating. And as a developer, I want to show off my app as I build it, and waiting for a cold start is frankly embarrassing. I’d like to think that Google will solve this eventually by using a sprinkling more of AI magic to predict and learn when to keep your app warm but for now, and for me, the issue is real.

So I looked around at the ecosystem to try and see what direction the community was going and decided to dip my feet into static site generation. I was lured in by the promise of Netlify and the ease of static hosting. I was also inspired by the incredibly fast page load of another app that I’m collaborating on where another developer is statically generating the frontend.

Seeing as I use Google cloud for most things already and I’m going to be using Firestore as a database for the new app, it seemed obvious to take a look at Firebase Hosting.

Firebase hosting promises:

…production-grade web content hosting for developers. With a single command, you can quickly deploy web apps and serve both static and dynamic content to a global CDN. Each file that you upload is cached on SSDs at CDN edges around the world and served as gzip or Brotli. We auto-select the best compression method for your content. No matter where your users are, the content is delivered fast.

Sounds great!

I’m very familiar with the Firebase product offering from Google and have actually used firebase hosting before to host some static content (ages ago) and it was FAST. So I decided to give it a try.

I ran a quick

$ firebase init

Walked through the steps, making sure to point the public directory to the nuxt dist directory. I opened up my nuxt.config.js and changed the target to ‘static’:

export default {

target: 'static' // default is 'server'

}

I generated the static site by running the ‘nuxt generate’ command from the scripts in my package.json

// package.json

"scripts": {

"generate": "nuxt-ts generate"

} // terminal:

$ yarn generate

And deployed to firebase hosting:

$ firebase deploy

In less than a minute my nuxt site was now statically hosted on Firebase hosting and I could hit it at a URL that firebase provides. And it was really quick. Noticeably quicker than if the request had to route through to a cloud run container, and a huge amount quicker than a cold start.

So I started developing but very quickly ran into a little issue. An issue that must surely affect the static-site folks all the time. An issue that made me realize why static sites are used mainly for poster websites and not web apps.

Basically, all I wanted to do was show a hamburger icon in the menu on mobile (with a sliding nav drawer), or a full list of navigation items on the desktop. A really obvious and simple task. In SSR mode I can check the request to be 90% sure if a device is mobile or desktop using @nuxtjs/device and have that pre-populate a variable client site so that the user gets the correct view. However, with static site generation, my machine has no idea what device the user will be, and there is no server-side to base this from the request.

The @nuxtjs/device docs have an option for setting the default user-agent:

defaultUserAgent option can be used for npm run generate {

modules: [

[

'@nuxtjs/device',

{defaultUserAgent: 'Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.39 Mobile Safari/537.36'}

]

]

}

I tried this (which defaults to mobile), but that means that I get a flash on the desktop where the hamburger icon shows for a few milliseconds, and then the navigation changes to desktop mode. This just looks terrible and I was imagining more complex situations where whole parts of the dashboard in the app are conditionally rendered based on mobile or desktop.

I spoke to my friend who does static sites and he said that he would just use CSS to conditionally show or hide the components based on screen size using a media query. Fair enough. I tried this out and, sure enough, it works but as my mobile nav and desktop nav are separate components, it means mounting both components on the page and then hiding one or the other. This isn’t a major issue in this case, but what about in the dashboard of the app if I had to mount two conditional components that both performed a network call, but only one was rendered. It didn’t seem quite right so I did some research.

google: when to use static site generation or server side rendering

I found lots of resources and opinions about the matter including one really great article: Server-Side Rendering vs. Static Site Generation. The basic consensus seems to be:

Use Server Side Rendering when you want to show user-specific data, dynamic views, or data that is updated frequently.

you want to show user-specific data, dynamic views, or data that is updated frequently. Use Static Site Generation when you can predict every route, don’t need to show user data, and data is updated infrequently. Or if the app is a SPA.

So it seemed like a choice needed to be made. A lot of my app pages will be very dynamic and there will be user data, so I need to either make it a SPA or I stick to server-side rendering the whole thing. I don’t want it to be a SPA because of the implications on SEO, so was the only option to use SSR?

Firebase Hosting to the rescue

I was unsatisfied with my options — I want some pages of the site to act like a statically generated site that I can host right on the edge in a CDN, and I also want other pages to act like a server-side rendered app. I rooted around for a solution and stumbled on some interesting functionality of Firebase Hosting: rewrites and cach control.

Through firebase hosting you can now use re-writes to send requests to URL glob patters to a cloud run service. This is super easy to do through the firebase.json config file in your project. For example, to direct all requests from the page /helloworld on your Hosting site to trigger the startup and running of a helloworld container instance:

"hosting": {

// ...



// Directs all requests from the page `/helloworld` to trigger and run a `helloworld` container

"rewrites": [ {

"source": "/helloworld",

"run": {

"serviceId": "helloworld", // "service name"

"region": "us-central1" // optional

}

} ]

}

This could be interesting… I built a container image for my app and deployed it to Cloud Run under the service name ‘front-end’. Then I updated my firebase config:

"rewrites": [

{

"source": "**",

"run": {

"serviceId": "front-end",

"region": "europe-west1"

}

}

]

After I re-deployed firebase from the CLI, sure enough, my app was available through the URL provided by firebase, and I could see the requests coming in on Cloud Run. This didn’t really solve anything though as there was still a cold start and a whole round trip to the server. Then I found out about Firebase Hosting Cache-Control.

As well as telling the browser to cache responses, you can also tell Firebase hosting to cache responses at its edge, which as far as I can tell is just as good as having a statically generated site sat on the edge. All you need to do is send some caching headers back in the response from Cloud Run and firebase hosting will cache the response, then when you next do a ‘firebase deploy’ the edge cache will be invalidated and firebase hosting will hit the server once again.

You just need to send back one caching header from the Cloud Run (Nuxt) service on all of the pages that you want to be cached on the edge:

res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=15778476')

public — Marks the cache as public . This means that both the browser and the intermediate servers (meaning the CDN for Firebase Hosting) can cache the content.

— Marks the cache as . This means that both the browser and the intermediate servers (meaning the CDN for Firebase Hosting) can cache the content. Note: By default, Cache-Control is set to private . This means that if you don't explicitly set Cache-Control to public , only the browser of the requesting user is allowed to cache the content.

By default, is set to . This means that if you don't explicitly set to , only the browser of the requesting user is allowed to cache the content. max-age — Tells the browser and the CDN how many seconds that they can cache the content. When the set time expires, the browser and the CDN must revalidate the content with the origin server. In the example header, we're allowing the browser and the CDN to cache the content for 1 day.

— Tells the browser and the CDN how many seconds that they can cache the content. When the set time expires, the browser and the CDN must revalidate the content with the origin server. In the example header, we're allowing the browser and the CDN to cache the content for 1 day. s-maxage — This is the magic. It overrides the max-age directive for the CDN-caching only; tells the CDN how many seconds that it can cache the content. When the set time expires, the CDN must revalidate the content with the origin server. In the example header, we're overriding the setting for max-age for the CDN only and allowing the CDN to cache the content for 6 months.

I don’t want to do this for all pages remember — only the pages of my app that will not often change (for example, the home page, blog pages, about page, etc.). Basically all pages aside from the user-facing dashboard. So I made a really simple Nuxt serverMiddleware to send back long caching headers for the pages that I want to cache. Here’s what that looks like:



import { defineNuxtServerMiddleware } from ' // middleware/cacheControl.tsimport { defineNuxtServerMiddleware } from ' @nuxtjs/composition-api const staticPage = 'max-age=15778476, s-maxage=15778476'

// I may have some other caching scemes here in the future const pathMap: { [key: string]: string } = {

'/': staticPage,

'/about': staticPage

'/pricing': staticPage

} export default defineNuxtServerMiddleware((req, res, next) => {

const {

originalUrl,

headers: { host }

} = req

if (!originalUrl || !host || host.split(':')[0] === 'localhost') return next()

const cache = pathMap[originalUrl]

if (cache) res.setHeader('Cache-Control', `public, ${cache}`)

return next()

})

I will probably end up updating this function in the future to use a glob pattern or an easier way to match multiple pages. It checks to see if we’re on localhost and prevents cache headers if so and then simply adds a cache header to the response of a request that matches a page path.

I pushed my nuxt app to cloud run, did a ‘firebase deploy’, and tested it out and it works beautifully.

On the first request for a page that I want to be ‘static’ after a re-deploy, the network flow looks like this:

Browser request

firebase hosting (has no cache)

cloud run (cold start)

cloud run generate page

cloud run respond with caching headers

firebase hosting caches on the edge

browser caches locally

Then when another user comes along and requests the page:

browser request

firebase hosting responds with edge cache

browser caches locally

Cloud run doesn’t get hit and doesn’t need to spin up at all! The page ger served super fast as if it’s a static site. Then if a page is requested that shouldn’t be static, I don’t add a caching response header to it in the nuxt middleware and it’s always fetched from Cloud Run and rendered server-side.

Automation

I wrote a cloud build YAML file along with a Docker file that will get triggered when I push to my main branch on Github. This takes care of building the nuxt app, making a container, pushing it to Google container repo for cache, deploying it to cloud run, and doing a ‘firebase deploy’ to invalidate edge cache:

// cloudbuild.yaml

steps:

# pull build cache

- name: gcr.io/cloud-builders/docker

id: Pulling build cache

entrypoint: "bash"

args:

- "-c"

- |

docker pull gcr.io/$PROJECT_ID/front-end:latest || exit 0

# Build the container image for the package using the Dockerfile

- name: gcr.io/cloud-builders/docker

id: Build container image using dockerfile

entrypoint: "bash"

args:

[

"-c",

'docker build -f build.Dockerfile -t gcr.io/$PROJECT_ID/front-end:latest --cache-from gcr.io/$PROJECT_ID/front-end:latest .',

]

# Push the container image to Container Registry

- name: "gcr.io/cloud-builders/docker"

args: ["push", "gcr.io/$PROJECT_ID/front-end:latest"]

# Deploy container image to Cloud Run

- name: "gcr.io/cloud-builders/gcloud"

args:

[

"beta",

"run",

"deploy",

"front-end",

"--image",

"gcr.io/$PROJECT_ID/front-end",

"--region",

"europe-west1",

"--platform",

"managed",

"--quiet",

]

# Deploy to Firebase

- name: gcr.io/$PROJECT_ID/firebase

args: ["deploy", "--project", "$PROJECT_ID"]

id: Deploy to Firebase # Push container artefact to container registry.

images: ["gcr.io/$PROJECT_ID/front-end:latest"]

One thing to note here is that to use the “gcr.io/$PROJECT_ID/firebase” step, you need to follow the instructions here on how to set up that container. (Very easy!):

https://cloud.google.com/cloud-build/docs/deploying-builds/deploy-firebase#using_the_firebase_community_builder

And the Docker file:

// build.Dockerfile

FROM node:12 WORKDIR /usr/src/app COPY package.json ./package.json

COPY yarn.lock ./yarn.lock RUN yarn install COPY . ./ RUN yarn build

CMD yarn start

Conclusion

And that’s it; static site feels and server-side rendering all from the same app with Firebase Hosting and Cloud run.

Feel free to ask any questions or suggest any improvements in the comments, or reach out to me on twitter @jamie_curnow. I’ll post a minimal repo at some point soon with a starter app using this setup. I’ll also make an app at a URL that you can hit to see what it feels like.

Oh, and keep an eye on @guardline on twitter if you’re interested in my new project that will be using this stack 😉