Build a fully static site with Nuxt.js

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.

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:
Cons:

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:

Cons:

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);
      });
  });
};