Learn moreKontent.ai Horizons is back! Secure your free spot today.
Petal

How to run scripts before every build on Next.js

Next.js is the most advanced Jamstack framework these days, yet it does not provide a smooth solution for running tasks before build time. How can you add pre-build scripts to your pipeline?


Ondrej PolesnyAug 24, 2022

In this article, I’ll explain some of the use-cases for having pre-build (or start-up) scripts, show how to implement them and run them before each Next.js build, and provide code samples so you can easily start using the solution in your projects.

npm run build
npm run build

Why do we need pre-build scripts?

The site you’re on right now is built with Next.js, and it’s rather large in size. Over time, we encountered multiple occasions when pre-build scripts are helping us simplify the implementation or are the only way to handle some tasks. These are the most common use cases:

  • Handling old URL slugs
    Next.js is capable of issuing proper redirects, but you need to provide the old and new URLs before running a build. We generate the URLs from the content before each build.
Kontent.ai
  • Generating RSS feed
    Next.js can handle generating an RSS feed. It’s just another piece of code, but where to put it? Some articles suggest the index page, which works, but feels like a workaround rather than a proper solution.
  • Sitemap excluded content
    For every page that appears on our site, we have a checkbox allowing content editors to exclude such pages from the sitemap. We generate a list of these pages and use it as a data source for the relevant plugin.
Kontent.ai
  • Site-wide content
    We use some types of content on literally all pages. That includes the basic product info, logo, links, and so on. We use the pre-build scripts to generate a code file that’s available globally in the whole project.

Creating and running pre-build scripts

Running some code before we execute the site build is not that complicated. We simply add a new script that has pre prefix in package.json:

"scripts": {
  ...
  "build": "next build",
  "prebuild": "...",
  ...
}

However, this works only for Node.js scripts. When implementing any larger site, you typically use TypeScript, and when we use it for the main site, we want to continue this trend in pre-build scripts too.

To run them, we need to use ts-node which is a TypeScript execution engine for Node.js. You can install it as any other NPM package:

npm i -D ts-node

Note: Depending on your project, you may also need to install typescript, tslib, and @types/node packages. In advanced scenarios, you can also use a transpiler like Babel.

The pre-build script in package.json then looks like this:

"prebuild": "ts-node ./src/scripts/runner.ts"

Now, what’s the runner.ts code file?

On our site, we used to define the pre-build scripts manually in package.json, but as we were adding more of them, the pre-build script line got really long and became hard to maintain. For that reason, we wanted to automate the process and added the runner.

Pre-build scripts runner

You can look at the runner as a parent script that takes care of executing every script in a specific folder. It needs to perform the following tasks:

  • Load environment variables
  • Discover all scripts in a defined folder
  • Execute each script and wait for it to finish

The first task is easy. Next.js allows us to use its loadEnvConfig function, which takes care of loading environment variables for respective environments:

import { loadEnvConfig } from '@next/env'
...
loadEnvConfig(process.cwd())
...

Note: You can also use the classic dotenv package, but Next.js has its own logic and naming of env files, so it’s just easier to do it the Next.js recommended way.

Then, we need to read all files in a specific directory – in our case, named pre-build – and find all code files. We want all files that end with the .ts suffix.

import fs from 'fs'
import path from 'path'
...
// find all scripts in subfolder
	const files = fs
		.readdirSync(path.join(__dirname, 'pre-build'))
		.filter(file => file.endsWith('.ts'))
		.sort()

Note: In our implementation, we also sort the scripts by their name. That allows us to define an order of priority as some scripts require the output of others.

Now, to be able to run these scripts in an automated way, we need to define some rules the scripts have to follow so we can keep adding new scripts without having to adjust the runner code. This is the basic frame for them:

export default async function execute(params: IScriptParams) {
  // script code
}

The IScriptParams interface is very simple, and its only purpose (for now) is to let scripts use environment variables:

export interface IScriptParams {
	env: any
}

When all scripts use the same frame, we can initialize and run them in a generic fashion from the runner:

for (const file of files) {
		const { default: defaultFunc }: { default: (params: IScriptParams) => void } = await import(`./pre-build/${file}`)
		try {
			console.log(`Running pre-build script '${file}'`)
			await defaultFunc({ env: process.env })
		} catch (e) {
			console.error(`SCRIPT RUNNER: failed to execute pre-build script '${file}'`)
			console.error(e)
		}
	}

Note that we first need to destructure the object with an alias as default is a reserved keyword in JavaScript. Then, we use a dynamic import and try to await the default function the script exports.

This is the full code of the runner:

import path from 'path'
import fs from 'fs'
import { IScriptParams } from './interfaces/IScriptParams'
import { loadEnvConfig } from '@next/env'

loadEnvConfig(process.cwd())

const runAsync = async () => {
	// find all scripts in subfolder
	const files = fs
		.readdirSync(path.join(__dirname, 'pre-build'))
		.filter(file => file.endsWith('.ts'))
		.sort()
	for (const file of files) {
		const { default: defaultFunc }: { default: (params: IScriptParams) => void } = await import(`./pre-build/${file}`)
		try {
			console.log(`Running pre-build script '${file}'`)
			await defaultFunc({ env: process.env })
		} catch (e) {
			console.error(`SCRIPT RUNNER: failed to execute pre-build script '${file}'`)
			console.error(e)
		}
	}
}

// Self-invocation async function
;(async () => {
	await runAsync()
})().catch(err => {
	console.error(err)
	throw err
})

Note: You can also use the top-level async-await if you target ES2017 and higher.

This runner will search through the pre-build directory and execute any script it finds.

For example, this is a pre-build script that we use for generating redirects from content:

import { DeliveryClient } from '@kontent-ai/delivery-sdk'
import path from 'path'
import fs from 'fs'
import { getUrlPathByContentType } from '../../utils/urlUtils'
import { IScriptParams } from '../interfaces/IScriptParams'

export default async function execute(params: IScriptParams) {
	const fullFilePath = path.join(process.cwd(), 'static', 'redirects.json')
	if (fs.existsSync(fullFilePath)) {
		await fs.promises.unlink(fullFilePath)
	}

	const client = new DeliveryClient({
		projectId: params.env.KONTENT_PROJECT_ID,
	})

	const res = await client
		.items()
		.elementsParameter(['url_slug', 'url_slug_history'])
		.notEmptyFilter('elements.url_slug_history')
		.toPromise()

        // logic to build old and new URLs into:
        // {
        //    source: oldUrl,
        //    destination: newUrl,
        //    permanent: false
        // } => const data

	await fs.promises.writeFile(fullFilePath, JSON.stringify(data.flat()))
}

This script is executed by the pre-build scripts runner, fetches all historically used URL slugs from content, transforms it into the structure requested by Next.js, and saves everything as a JSON file that is available to Next.js config during build time.

Conclusion

In this article, I showed you how pre-build scripts can be useful in building your Next.js site. And indeed, every now and then, while implementing functionality on our own site, we leverage this simple concept of pre-fetching data. I explained some of the most common use-cases and shared the full code of a script runner that can be executed before the actual next build to process all needed tasks.

If you have any questions about the solution or need a hand with your Next.js site, make sure to join our Discord.

Written by

Ondrej Polesny

As Developer Evangelist, I specialize in the impossible. When something is too complicated, requires too much effort, or is just simply not possible, just give me a few days and we will see :).

More articles from Ondrej