Or how to go against the well-established patterns and still win.
In my team, we started re-writing the platform that was long in the tooth with a lot of technical debt. It is still serving 50+ million unique viewers, but it well deserves to be retired.
Performance is naturally the biggest concern for our new baby as well as SEO.
So we knew we could not rely on traditional SPA patterns. We needed server-side rendering and we decided to go with Nuxt.js.
Nuxt.js
Nuxt.js is an opinionated framework for creating universal Vue.js applications in a way that abstracts client/server distribution. It can also be used as a static site generator. It can be wired, for example, to listen for changes from an API and (re-)generate the content making sure it always serves static content for lightning-fast performance. Pretty awesome if you ask me.
But this is not something super new. What makes Nuxt.js interesting is the ability to allow developers to spin up an SSR project quickly and start delivering features right away.
Having said that, convention over configuration is rarely something I choose to go with unless there is a really, really good reason. Swimming against an opinionated framework is rarely something that turns out well.
That's where we will go in this post. And it will turn out fine. I promise!
We want to "build once, deploy many"
Our cloud architect approaches me and explains the problem. Every time we change code, the codebase needs to be built specifically for every environment separately. This is often slow. What is the common difference in code across different environments? Environment variables! Although what he asked for makes sense, my initial reaction was:
You want what?
But we have all this artillery of compilers, transpilers, pre- and post-processors at our disposal that take days to set up and that build the code for every environment! Plus, there are so many potential problems for leaking sensitive data to the client.
Build once, deploy many makes perfect sense in the world of software development, however. But not necessarily in the Javascript world...
Let's do it!
So I started my research and Google was certainly not my friend. The cynic in me asked, "Perhaps for a reason?" 👿
There was some chatter, but no solution. That meant that I had to come up with a solution on my own instead of copy-pasting code (that's what we as developers do all the time anyway, right?) 😜
Understanding how Nuxt.js does it
Nuxt.js has a file nuxt.config.js
. This file is used when instantiating Nuxt.js programmatically:
const config = require('nuxt.config.js')
const nuxt = new Nuxt(config)
In this file, it is possible to define env
object of ENV variables. It is a common practice to require config dynamically here. This gets passed to webpack's definePlugin
and can be used for client and server-side like so: process.env.propertyName
or context.env.propertyName
. These variables are then baked into Nuxt.js' build (see .nuxt/utils.js
-> getContext
). For more info, see the official Nuxt.js env page.
Notice the mention of webpack? Yup, that means compilation and... that this is not going to work for us.
Hack away!
Understanding how Nuxt.js works means that:
- we can't use
env
insidenuxt.config.js
anymore - any other dynamic variables (like inside
head.meta
) need to be passed tonuxt.config.js
object during run-time
The code in server/index.js
:
// Import and Set Nuxt.js options
const config = require('../nuxt.config.js')
becomes
// Import extended Nuxt.js options
const config = require('./utils/extendedNuxtConfig.js').default
where utils/extendedNuxtConfig.js
is:
import config from 'config'
import get from 'lodash/get'
// Import and Set Nuxt.js options
const defaultConfig = require('../../nuxt.config.js')
// Place extended config here
const extendedConfig = {}
// (Shallow) extend Nuxt.js config here
const nuxtConfig = {
...defaultConfig,
...extendedConfig
}
// Do final object manipulation for things where
// extending objects is not appropriate
if (get(nuxtConfig, 'head.meta')) {
nuxtConfig.head.meta.push({
hid: 'og:url',
property: 'og:url',
content: config.get('app.canonical_domain')
})
}
export default nuxtConfig
The big elephant in the room
Ok, this solves a minor problem of having dynamic ENV data outside the scope of env
in nuxt.config.js
. The original question remains, however.
I initially thought I would have an abstraction like sharedEnv.js
that would be used for:
- client: creating
env.js
file that would be loaded on the client in globalwindow
scope (window.env.envKey
) - server: imported in files where needed (the most straightforward part)
- isomorphic code: something like
context.isClient ? window.env[key] : global.sharedEnv[key]
(and write a small utility behind it)
This seems very hacky and just not that great. This abstraction does take care of one of my biggest concerns which was leaking sensitive data to the client. Just like the env object from nuxt.config.js
, this forces everyone on the team to consciously add a value so nothing gets leaked. Not intentionally at least.
Vuex to the rescue
While fiddling with the window
scope, I realized that the Vuex store is exported to the global window
scope. This decision was most likely made because of Nuxt.js' isomorphic nature. Vuex is a Flux-inspired data store specifically tailored for Vue.js apps.
So why not use Vuex for our shared env variables?
It is certainly more organic than the previous approach and this data is in a way global state so it fits the bills perfectly.
We start with our good friend server/utils/sharedEnv.js
import config from 'config'
/**
* Set up the object that will be available to both the server and the client.
* For the sake of simplicity, please keep this object flat.
* Be extra diligent about not leaking any sensitive info here!!
*
* @type {Object}
*/
const sharedEnv = {
// ...
canonicalDomain: config.get('app.canonical_domain'),
}
export default sharedEnv
This is executed during the server start-up process. We then add it to our Vuex store.
/**
* Gets the shared environment.
* Documentation suggests it is only executed server side, but not wrapping
* this in a conditional results in an error.
* https://nuxtjs.org/guide/vuex-store/#the-nuxtserverinit-action
*
* @return {Object} Shared environment variables.
*/
const getSharedEnv = () =>
process.server
? require('~/server/utils/sharedEnv').default || {}
: {}
// ...
export const state = () => ({
// ...
sharedEnv: {}
})
export const mutations = {
// ...
setSharedEnv (state, content) {
state.sharedEnv = content
}
}
export const actions = {
nuxtServerInit ({ commit }) {
if (process.server) {
commit('setSharedEnv', getSharedEnv())
}
}
}
We take advantage of the fact that nuxtServerInit
runs on... uhm... server init. It is possible to save our shared env data. Notice the complication here: getSharedEnv
method which then checks if this is run on server again. This is necessary to ensure that the client does not run into an error and tries to execute require
.
Consume it!
Now that we have our shared env variables in our store, we can consume it from our components like so:
this.$store.state.sharedEnv.canonicalDomain
Win!
Not so fast. What about plugins?
Certain plugins may need environment variables to be configured when letting know Vue.js we want to use: Vue.use(MyPlugin, { someEnvOption: 'no access to vuex store here' })
We ran into a racing condition here. Vue.js is trying to initialize itself before Nuxt.js registers our sharedEnv
object to the Vuex store.
While the exported function that registers a Vue.js plugin provides us with a context
object (from which we have access to the store), sharedEnv
is still empty.
The answer to this is making the plugin function async
and await
a manual nuxtServerInit
dispatch. This ensures that Vue.js waits until we have our sharedEnv
object available.
Here's the code:
import Vue from 'vue'
import MyPlugin from 'my-plugin'
/**
* Adds configured MyPlugin asynchronously.
*/
export default async (context) => {
// perform a store action manually to have access to `sharedEnv` object
await context.store.dispatch('nuxtServerInit', context)
const env = { ...context.store.state.sharedEnv }
Vue.use(MyPlugin, { option: env.someKey })
}
Now it is a win!
In and out. Ciao!