Oct 6, 2022

On-Demand ISR for Ghost w/ NextJS

Here's a quick snippet that allows for On-Demand ISR (incremental static regeneration) in NextJS and Ghost CMS (headless). This gets triggered as a webhook anywhere in your api/ folder.

You'd also set a webhook secret in your environment variables to protect it from misuse. There's a couple of lines specific to this site, but the file should mostly remain the same after adjusting it to your use case.

It detects the revalidation content type – posts or pages – and revalidates a path with a given mapping.

// /pages/api/ghost/revalidate.ts
import { NextApiRequest, NextApiResponse } from "next";
import get from "lodash/fp/get";
import { REVALIDATE_KEY } from "../../../config";
import { postMessage } from "../../../lib/discord";
import isError from "lodash/isError";
import reduce from "lodash/reduce";

interface RevalidateMap {
	[type: string]: (slug: string) => string;
}

const PATH_MAP: RevalidateMap = {
	post: (slug) => `/posts/${slug}`,
	page: (slug) => `/${slug}`,
};

interface SlugItem {
	type: "post" | "page";
	slug: string;
}

function collectSlugs(req: NextApiRequest): SlugItem[] {
	return reduce(
		Object.keys(req.body),
		(result: SlugItem[], value) => {
			const currentSlug = get(`${value}.current.slug`, req.body);
			const previousSlug = get(`${value}.previous.slug`, req.body);
			if (previousSlug === undefined && currentSlug) {
				return [
					...result,
					{ type: value as SlugItem["type"], slug: currentSlug },
				];
			} else if (currentSlug === undefined && previousSlug) {
				return [
					...result,
					{ type: value as SlugItem["type"], slug: previousSlug },
				];
			} else {
				return [
					...result,
					{ type: value as SlugItem["type"], slug: currentSlug },
					{ type: value as SlugItem["type"], slug: previousSlug },
				];
			}
		},
		[]
	);
}

export default async (req: NextApiRequest, res: NextApiResponse) => {
	// Check for secret to confirm this is a valid request
	if (req.query.secret !== REVALIDATE_KEY) {
		return res.status(401).json({ message: "Invalid token" });
	}

	try {
		const collected = collectSlugs(req);
		// Revalidate post lists.
		await res.revalidate("/");
		await res.revalidate("/posts");
        // Revalidate individual pages.
		await Promise.all(
			collected.map(async (item) => {
				const path = PATH_MAP[item.type](item.slug);
				await res.revalidate(path);
				await postMessage(`✅ Revalidated w/ ISR: \`${path}\``);
			})
		);
		return res.json({ revalidated: true });
	} catch (err) {
		// If there was an error, Next.js will continue
		// to show the last successfully generated page
		// eslint-disable-next-line no-console
		console.error(err);
		let message = "Unknown Error";
		if (isError(err)) message = err.message;
		postMessage(`⚠️ Failed to on-demand revalidate. \`${message}\``);
		return res.status(500).send("Error revalidating");
	}
};

After adding this endpoint to your NextJS site, you'd add the URL to Ghost webhooks on any related events (page/post updated, published, etc).

Your pages should now update incrementally without a full build process. This works great on Vercel!