Type‑Safe Incremental Static Regeneration with Edge Middleware: A Practical Guide
Introduction
Incremental Static Regeneration (ISR) lets you serve pre‑rendered pages and update them in the background without a full rebuild. When you add Edge Middleware into the mix, you gain the ability to run request‑time logic (auth, geo‑routing, A/B tests) at the CDN edge, before the page is served.
The power is obvious, but the devil is in the details:
- ISR revalidation tokens are strings that travel through headers, cookies, or query parameters.
- Edge Middleware runs in a V8 isolate with a limited runtime, so you can’t rely on Node‑only libraries.
- TypeScript can protect you from mismatched shapes, but the default Next.js examples often fall back to
anyor manual string parsing.
This article shows how to wire ISR and Edge Middleware together while keeping the whole flow type‑safe. We’ll build a small e‑commerce product page that:
- Serves a static page generated at build time.
- Revalidates the page every 60 seconds or when a webhook signals a price change.
- Uses Edge Middleware to validate a signed revalidation token and to inject a per‑region price discount.
All code is written in TypeScript and runs on Vercel Edge Runtime (or any platform that supports the standard Edge API).
1. The moving parts
| Piece | Responsibility | Where it runs |
|---|---|---|
pages/products/[slug].tsx |
React component that renders product data. | Node (build) and Edge (ISR) |
getStaticProps |
Fetches product data, returns revalidate interval. |
Node (build) |
pages/api/revalidate.ts |
Webhook endpoint that triggers ISR via res.revalidate. |
Node (API route) |
middleware.ts |
Edge Middleware that validates a signed token and adds a discount header. | Edge (CDN) |
lib/types.ts |
Centralised TypeScript definitions shared across all layers. | Shared |
The key to type safety is sharing the same type definitions across the API route, the page, and the middleware. We’ll keep those definitions in a single file (lib/types.ts) and import them wherever needed.
2. Defining the contract
Create lib/types.ts:
// lib/types.ts
/** The shape of the product data we store in the CMS / DB */
export interface Product {
id: string;
slug: string;
name: string;
description: string;
priceCents: number; // stored as integer to avoid floating point errors
}
/** Payload sent by the revalidation webhook */
export interface RevalidatePayload {
/** The slug of the product that changed */
slug: string;
/** A timestamp (ISO string) for logging / debugging */
updatedAt: string;
}
/** The shape of the token that Edge Middleware expects */
export interface RevalidateToken {
/** HMAC‑signed slug */
slug: string;
/** Expiration epoch (seconds) */
exp: number;
}
/** Header added by middleware to influence rendering */
export const DISCOUNT_HEADER = 'x-region-discount' as const;
export type DiscountHeader = typeof DISCOUNT_HEADER;
Because the same file is imported by the page, the API route, and the middleware, any change to the contract instantly surfaces as a TypeScript error wherever the contract is used incorrectly.
3. Building the product page with ISR
// pages/products/[slug].tsx
import { GetStaticProps, GetStaticPaths, NextPage } from 'next';
import { Product, DISCOUNT_HEADER } from '../../lib/types';
import { fetchProductBySlug } from '../../lib/api';
import { useEffect, useState } from 'react';
import { NextRequest, NextResponse } from 'next/server';
type Props = {
product: Product;
};
const ProductPage: NextPage<Props> = ({ product }) => {
const [discount, setDiscount] = useState(0);
// The Edge Middleware may add a discount header; we read it client‑side
useEffect(() => {
const header = document
.querySelector('meta[name="discount"]')
?.getAttribute('content');
if (header) setDiscount(parseInt(header, 10));
}, []);
const finalPrice = (product.priceCents - discount) / 100;
return (
<>
<meta name="discount" content={discount.toString()} />
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>
Price:{' '}
<strong>
${finalPrice.toFixed(2)} {discount > 0 && `(discount applied)`}
</strong>
</p>
</>
);
};
export const getStaticPaths = async () => {
// In a real app you’d fetch all slugs from a DB.
const slugs = ['red‑shirt', 'blue‑jeans'];
return {
paths: slugs.map((slug) => ({ params: { slug } })),
fallback: 'blocking',
};
};
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
const slug = params?.slug as string;
const product = await fetchProductBySlug(slug);
if (!product) {
return { notFound: true };
}
return {
props: { product },
// Revalidate every 60 seconds *or* when the webhook triggers a manual revalidation.
revalidate: 60,
};
};
export default ProductPage;
Why this is type‑safe
fetchProductBySlugreturnsPromise<Product | null>– the page can’t accidentally treat a missing product as valid.revalidateis a number, not a string, so the compiler will warn if you accidentally pass a string.- The discount header name is a const (
DISCOUNT_HEADER), preventing typos when reading the header later.
4. Edge Middleware that validates a signed token
Edge Middleware runs before the request reaches the page. We’ll use a simple HMAC‑based token that the webhook can generate. The middleware will:
- Look for a
?revalidateToken=query param. - Verify the HMAC signature and expiration.
- If valid, add a custom header (
x-region-discount) that the page can read.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { RevalidateToken, DISCOUNT_HEADER } from './lib/types';
import crypto from 'crypto';
// Secret must be the same in the webhook and the middleware.
// In production you’d store it in an environment variable.
const HMAC_SECRET = process.env.HMAC_SECRET ?? 'dev-secret';
// Helper: verify HMAC signature
function verifyToken(token: string): RevalidateToken | null {
try {
const payload = JSON.parse(Buffer.from(token, 'base64url').toString('utf8'));
const { slug, exp, sig } = payload as RevalidateToken & { sig: string };
const data = `${slug}:${exp}`;
const expectedSig = crypto
.createHmac('sha256', HMAC_SECRET)
.update(data)
.digest('hex');
if (crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
if (Date.now() / 1000 < exp) {
return { slug, exp };
}
}
} catch {
// ignore malformed token
}
return null;
}
// Example: region‑based discount map
const REGION_DISCOUNT = {
'us-east-1': 500, // $5.00 discount in cents
'eu-west-1': 300,
} as const;
export async function middleware(req: NextRequest) {
const url = req.nextUrl.clone();
// 1️⃣ Extract token
const token = url.searchParams.get('revalidateToken');
if (!token) {
return NextResponse.next();
}
// 2️⃣ Verify token
const decoded = verifyToken(token);
if (!decoded) {
return new NextResponse('Invalid token', { status: 401 });
}
// 3️⃣ Attach discount header based on region (demo uses a static map)
const region = req.geo?.region ?? 'us-east-1';
const discount = REGION_DISCOUNT[region as keyof typeof REGION_DISCOUNT] ?? 0;
const resp = NextResponse.next();
resp.headers.set(DISCOUNT_HEADER, discount.toString());
// 4️⃣ Optional: rewrite URL to force ISR revalidation path
// If the token is for a specific slug, we can rewrite to that page.
url.pathname = `/products/${decoded.slug}`;
resp.headers.set('x-revalidated-slug', decoded.slug);
return resp.rewrite(url);
}
// Apply middleware only to product pages
export const config = {
matcher: '/products/:slug*',
};
Type safety highlights
verifyTokenreturnsRevalidateToken | null. The caller must handle thenullcase, preventing accidental use of an unverified token.REGION_DISCOUNTis declaredas const, giving each entry a literal type (500 | 300). When we readdiscount, TypeScript knows it’s a number, notany.- The
matcherensures the middleware only runs where we expect it, avoiding accidental execution on unrelated routes.
5. Secure revalidation webhook
The webhook is a classic Node API route that calls res.revalidate. It must generate the same HMAC token the middleware expects.
// pages/api/revalidate.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { RevalidatePayload } from '../../lib/types';
import crypto from 'crypto';
const HMAC_SECRET = process.env.HMAC_SECRET ?? 'dev-secret';
const TOKEN_TTL = 30; // seconds
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).end('Method Not Allowed');
}
// 1️⃣ Validate payload shape (runtime check, not just TS)
const payload = req.body as Partial<RevalidatePayload>;
if (!payload.slug) {
return res.status(400).json({ error: 'Missing slug' });
}
// 2️⃣ Trigger ISR for the specific page
try {
await res.revalidate(`/products/${payload.slug}`);
} catch (err) {
return res.status(500).json({ error: 'Failed to revalidate' });
}
// 3️⃣ Generate a signed token that the Edge Middleware can consume
const exp = Math.floor(Date.now() / 1000) + TOKEN_TTL;
const data = `${payload.slug}:${exp}`;
const sig = crypto.createHmac('sha256', HMAC_SECRET).update(data).digest('hex');
const tokenPayload = {
slug: payload.slug,
exp,
sig,
};
const token = Buffer.from(JSON.stringify(tokenPayload)).toString('base64url');
// 4️⃣ Respond with the token – the client can redirect to the page with ?revalidateToken=
res.status(200).json({ revalidateToken: token });
}
Why we still do a runtime check: The webhook is an external entry point (e.g., a CMS or Stripe webhook). Even though the TypeScript type says RevalidatePayload, the actual HTTP body can be malformed. A quick if (!payload.slug) guard prevents a malformed request from causing a cryptographic error.
6. End‑to‑end flow
- Price change in the CMS triggers a POST to
/api/revalidatewith{ slug: 'red-shirt' }. - The API route calls
res.revalidate('/products/red-shirt')– Next.js regenerates the static HTML in the background. - The same route returns a signed
revalidateToken. - The client (or a server‑side process) redirects the user to
/products/red-shirt?revalidateToken=…. - Edge Middleware validates the token, adds
x-region-discountbased on the request’s geo location, and rewrites the URL to the product page. - The page receives the discount header, applies it to the displayed price, and the user sees the fresh content instantly.
All steps are type‑checked, and any mismatch (e.g., forgetting to include slug in the webhook payload) surfaces at compile time.
7. Testing the contract
Because the contract lives in a single file, you can write unit tests that import the same types:
// __tests__/token.test.ts
import { verifyToken } from '../middleware';
import { RevalidateToken } from '../lib/types';
import crypto from 'crypto';
const HMAC_SECRET = 'test-secret';
function sign(token: RevalidateToken): string {
const data = `${token.slug}:${token.exp}`;
const sig = crypto.createHmac('sha256', HMAC_SECRET).update(data).digest('hex');
return Buffer.from(JSON.stringify({ ...token, sig })).toString('base64url');
}
test('verifyToken accepts a valid token', () => {
const token: RevalidateToken = { slug: 'blue-jeans', exp: Math.floor(Date.now() / 1000) + 60 };
const signed = sign(token);
expect(verifyToken(signed)).toEqual(token);
});
test('verifyToken rejects expired token', () => {
const token: RevalidateToken = { slug: 'blue-jeans', exp: 0 };
const signed = sign(token);
expect(verifyToken(signed)).toBeNull();
});
Running these tests guarantees that the cryptographic contract stays in sync between the webhook and the middleware.
8. Deploying to Vercel (or any Edge‑compatible platform)
- Environment variables – set
HMAC_SECRETin the dashboard. - Enable Edge Runtime – the
middleware.tsfile automatically runs at the edge; no extra config needed. - Build –
next buildwill generate the ISR pages and the edge bundle. - Cache‑control – ISR pages are cached by the CDN; the revalidation request forces a cache purge automatically.
If you use a self‑hosted platform (e.g., Cloudflare Workers with Next.js), make sure the runtime supports the crypto module (or replace it with the Web Crypto API, which has the same type signatures).
9. Common pitfalls and how type safety helps
| Pitfall | How TypeScript catches it |
|---|---|
Accidentally passing a string to revalidate instead of a number |
revalidate: "60" triggers a compile‑time error because revalidate expects number. |
Misspelling the discount header name in the page (x-region-discont) |
Header name is exported as a const; using a different string produces a lint warning or a TypeScript error if you enable noImplicitAny. |
Using the wrong shape for the webhook payload (e.g., slugId instead of slug) |
The API route expects RevalidatePayload; accessing payload.slug on a mismatched object raises an error. |
Forgetting to handle the null return from verifyToken |
The middleware must explicitly check if (!decoded), otherwise the compiler warns about possible null dereference. |
Mixing Node crypto with the Edge crypto.subtle API |
By typing the import (import crypto from 'crypto') only in Node‑only files, the Edge bundle will fail to compile if you accidentally use it there. |
10. Takeaways
- Centralise types – a single
lib/types.tsfile becomes the source of truth for ISR, middleware, and webhook contracts. - Validate at runtime where the boundary is external – even with TypeScript, always guard against malformed payloads from the outside world.
- Leverage const assertions – they give you literal types for header names and discount values, eliminating magic strings.
- Keep the Edge code minimal – only cryptographic verification and header injection; heavy logic stays in Node where you have full library support.
- Test the shared contract – unit tests that import the same types ensure that both sides of the token exchange stay compatible.
By following this pattern you can enjoy the performance benefits of ISR, the flexibility of Edge Middleware, and the confidence that every piece of data flowing through your system is type‑checked from end‑to‑end.
Member discussion