Build a fully static site with Nuxt.js

Generate a fully static site with nuxt. Save request data at build time to avoid repeating requests.

Javascript Nuxt
17 March 2020

Update: Fully static generation is coming in Vue v3! It's currently available in the nuxt-edge release. For more info see the related github issue.


The Problem

Nuxt can generate a static build of your website, but the static content won't always be used. Prefetched data is used on the first page you open, however when navigating between pages prefetched data is ignored and the asyncData is re-run client-side (same goes for fetch). This mixed use of prefetched and freshly fetched data can lead to inconsistent content on a single given page.

Note: If you only want to stop repeat requests such as nuxtServerInit running for every pre rendered page checkout axios-cache-adapter for a simple plugin solution.

Solution 1: "Static" module for nuxt (Recommended)

This method was shared in the related github issue and used on the offical nuxtjs.org website. It saves the payload (response) of page asyncData methods to .json during generation.

// modules/static/index.js

// Inspired by https://github.com/DreaMinder/nuxt-payload-extractor
// Credits to @DreMinder
const path = require('path')
const { writeFile, ensureDir } = require('fs-extra')

const extractPayload = function ({ html, route }, windowNamespace) {
const chunks = html.split(`<script>window.${windowNamespace}=`)
const pre = chunks[0]
const payload = chunks[1].split('</script>').shift()
const post = chunks[1].split('</script>').slice(1).join('</script>')
const path = route === '/' ? '' : route

return {
html: pre + '<script defer src="' + path + '/payload.js"></script>' + post,
payload
}
}

const writePayload = async function (payload, dir, windowNamespace) {
// Make sure the directory exists
await ensureDir(dir)

// Write payload.js file
await writeFile(path.resolve(dir, 'payload.js'), `window.${windowNamespace}=${payload}`, 'utf-8')

// if routes are nested, ignore parent routs and extract last one
const nuxtContext = eval('(' + payload + ')') // eslint-disable-line no-eval
const pageData = nuxtContext.data

// Write payload.json (page data)
await writeFile(path.resolve(dir, 'payload.json'), JSON.stringify(pageData), 'utf-8')
}

module.exports = function (moduleOptions) {
const options = {
blacklist: [],
...this.options.static,
...moduleOptions
}

this.nuxt.hook('generate:page', async (page) => {
if (!this.nuxt.options.generate.subFolders) {
throw new Error('generate.subFolders should be true for @nuxt/static')
}
if (options.blacklist.includes(page.route)) {
return page
}

const windowNamespace = this.nuxt.options.globals.context(this.nuxt.options.globalName)
const { html, payload } = extractPayload(page, windowNamespace)

await writePayload(payload, path.join(this.nuxt.options.generate.dir, page.route), windowNamespace)
page.html = html

return page
})

// Add nuxt_static middleware
this.addPlugin({
src: path.resolve(__dirname, 'plugin.js')
})
this.nuxt.options.router.middleware.push('nuxt_static')
}
// modules/static/plugin.js

import { getMatchedComponents } from './utils.js'
import Middleware from './middleware'

Middleware.nuxt_static = async ({ app, route }) => {
// Ignore on server
if (process.server) { return }
// Ignore if not generated
if (!process.static) { return }

const Components = getMatchedComponents(route)
Components.forEach((Component) => {
Component._payloads = Component._payloads || {}
if (Component.options.asyncData) {
Component.options.asyncData = ({ route }) => Component._payloads[route.path.replace(/\/$/, '')]
}
})
const path = route.path.replace(/\/$/, '')
const needFetch = Components.some(Component => Component.options.asyncData && !Component._payloads[path])
if (!needFetch) {
return
}
const payloadPath = (path + '/payload.json').replace(/\/+/, '/')
const pageDatas = await fetch(payloadPath).then((res) => {
if (!res.ok) { return null }
return res.json()
})
if (!pageDatas) {
return console.error(`[@nuxt/static] Could not fetch ${payloadPath}`) // eslint-disable-line no-console
}

Components.forEach((Component, index) => {
if (Component.options.asyncData) {
Component._payloads[path] = pageDatas[index]
}
})
}

Solution 2: Axios hooks

Based on: https://github.com/stursby/nuxt-static/blob/master/src/plugins/axios.js. Alternatives: nuxt-payload-extractor uses a similar method.

This is a nuxt plugin which exports an axios instance. By using axios' request and response hooks, during generation responses are saved to .json files. In your generated build these .json files will be fetched instead of making the regular request.

Pros:
  • Makes any requests made with the axios instance static, no only asyncData requests
  • You can write requests as regularly with axios
  • Content is always updated on generation, no need to call a separate script
Cons:
  • Requires axios (or another request libraries with request and response hooks)

Tip: Theres no point in using vue-axios. It takes the same number of lines to configure with no difference in functionality.

The nuxt plugin for axios:

/*
* Axios instance
*
* Saves response data to local JSON files on generation
* All data files are named after the requests config query parameter
* Generated static builds will return the locally stored JSON data
* Doesn't affect requests made in dev or start mode
*
* Context:
* Nuxt generate allows statically saving data fetched inside asyncdata
* Problem is the static data is only used on the first page viewed
* Subsequent page views fetch fresh data
*
* Usage:
* All requests must provide a unique query parameter
*
* Modification of https://github.com/stursby/nuxt-static/blob/master/src/plugins/axios.js
*
* @author Barry Hood
* @module axios
*/


import axios from "axios";
import config from "../config/index.js";

let axiosConfig = {
baseURL: "https://graphql.datocms.com",
headers: {
Authorization: config.DATO_CMS_API_TOKEN,
},
};

// Config for generated site
if (process.browser && process.static) {
axiosConfig = {
baseURL: "/data",
};
}

const axiosCleanInstance = axios.create(axiosConfig);
const axiosInstance = axios.create(axiosConfig);


// Warn about missing query parameter
axiosInstance.interceptors.request.use(config => {
if (!config.query) {
// eslint-disable-next-line no-console
console.log(
"WARNING: request is missing query parameter - will fail during generation",
);
}

return config;
});


// Handle requests of generated site
if (process.browser && process.static) {
axiosInstance.interceptors.request.use(config => {
config.method = "GET";
config.url = config.query + ".json";
return config;
});
}


// Track query names to ensure all are unique
const queryNames = new Set();

// Handle requests during generation
if (process.server && process.static) {
const { join, dirname } = require("path");
const { mkdir, writeFile } = require("fs").promises;

axiosInstance.interceptors.response.use(
async function(response) {
const { query } = response.config;

// Require for query paramater
if (!query) {
throw new Error(
"ERROR: All requests must have a query parameter defined",
);
}

// Require query name is unique
if (queryNames.has(query)) {
throw new Error(
`ERROR: All requests query parameters must be unique. Duplicate found: "${query}"`,
);
}

queryNames.add(query);
const path = join("./dist/data", query + ".json");

try {
await mkdir(dirname(path), { recursive: true });
} catch (e) {
if (e.code !== "EEXIST") throw e;
}

await writeFile(path, JSON.stringify(response.data));
return response;
},
function(error) {
// Do something with response error
return Promise.reject(error);
},
);
}

export { axiosCleanInstance };
export default axiosInstance;

Example use:

import axios from "~/plugins/axios";

const res = await axios({
// Each request must have a unique query name
query: "HOME",
url: "/",
method: "GET",
});


// If you want to use an unmodified instance of axios
import { axiosCleanInstance } from "~/plugins/axios";

const res = await axiosCleanInstance.get('/api');

Solution 3: Vuex store with caching

Based on: This gist and this comment. Alternatives: To avoid repeat requests you can use axios-cache-adaptor.

The nuxtServerInit method runs once for each page, this means every request runs once for each page. But by by using a cache we only make each request once.

Pros:

  • Affects nuxtServerInit but doesn't touch other parts of your app. Easily mixes with other data fetching methods.
  • Data's globally accessible via your store.

Cons:

  • Only works for requests in nuxtServerInit, not in components.
  • Requests run every time you start or generate the app.

Example nuxt.js.config with a cache:

import axios from "axios";

// Temp cache to store pages and promise resolving all requests
// before committing final data to store
const cache = {
};

/**
* Get data from cache by name
* If data doesn't exist call passed function and cache returned data
*
* @param {string} name - name of item in cache
* @param {function} getFunc - async function which returns items data. Called if data's not already cached
* @response {*}
*/

async function cacheGet(name, getFunc) {
if (cache[name]) return cache[name];

// Return proimse, don't await result
// No blocking means function returns before next nuxtServerInit call.
// Each call then awaits the same promise.
const res = getFunc();
cache[name] = res;
return res;
}

async function fetchNuxtServerInit() {
const res = await axios({
url: "/",
method: "POST",
query: "nuxtServerInit",
data: {
query: `{
allApartments {
...
}
allOffers {
...
}
}
`
,
},
});

return res.data.data;
}

export const actions = {
async nuxtServerInit({ commit }) {
const data = await cacheGet("NuxtServerInitQuery", fetchNuxtServerInit);

commit("setApartments", data.allApartments);
commit("setOffers", data.allOffers);
},
};

Solution 3: Nuxt "static" module

Based on: https://joshuastuebner.com/blog/backend/nuxt_static.html#how

The module:

const fs = require('fs').promises;
const axios = require('axios');

// Define where static JSON files are saved
const staticDirectory = 'static/data';

async function emptyDir(directory) {
try {
const files = fs.readdirSync(directory);
const unlinkPromises = files.map(filename => unlink(`${directory}/${filename}`));
return Promise.all(unlinkPromises);
} catch(err) {
console.log(err);
}
}

async function fetchJson(url) {
const res = await axios.get(url);
return res.data;
}

const writeData = (path, data) => {
return new Promise(async (resolve, reject) => {
try {
await fs.writeFile(path, JSON.stringify(data));
resolve(`${path} Write Successful`);
} catch (e) {
console.error(`${path} Write Failed. ${e}`)
reject(`${path} Write Failed. ${e}`)
}
})
}

module.exports = function scraper() {
// Add hook for build
this.nuxt.hook('build:before', async builder => {
try {
await fs.mkdir(staticDirectory);
} catch (e) {
console.error(`Failed to create directory: ${staticDirectory}`)
console.error(e);
}

// Clean data directory
emptyDir(staticDirectory)

// Empty array to fill with promises
const scraper = []

// One of these for every request, a loop for dynamic nested pages
scraper.push(writeData(`${staticDirectory}/index.json`, await fetchJson('https://randomapi.com/api/6de6abfedb24f889e0b5f675edc50deb?fmt=raw&sole')))

// Finish when all requests are done
return Promise.all(scraper).then(() => {
console.log('JSON Build Complete!')
}).catch(err => {
console.error(err)
})
});
};