8 min read

Type‑Safe Incremental Static Regeneration with Edge Middleware: A Practical Guide

Learn how to combine Next.js ISR and Edge Middleware while keeping every step type‑checked in TypeScript.
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 any or 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:

  1. Serves a static page generated at build time.
  2. Revalidates the page every 60 seconds or when a webhook signals a price change.
  3. 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

  • fetchProductBySlug returns Promise<Product | null> – the page can’t accidentally treat a missing product as valid.
  • revalidate is 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:

  1. Look for a ?revalidateToken= query param.
  2. Verify the HMAC signature and expiration.
  3. 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

  • verifyToken returns RevalidateToken | null. The caller must handle the null case, preventing accidental use of an unverified token.
  • REGION_DISCOUNT is declared as const, giving each entry a literal type (500 | 300). When we read discount, TypeScript knows it’s a number, not any.
  • The matcher ensures 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

  1. Price change in the CMS triggers a POST to /api/revalidate with { slug: 'red-shirt' }.
  2. The API route calls res.revalidate('/products/red-shirt') – Next.js regenerates the static HTML in the background.
  3. The same route returns a signed revalidateToken.
  4. The client (or a server‑side process) redirects the user to /products/red-shirt?revalidateToken=….
  5. Edge Middleware validates the token, adds x-region-discount based on the request’s geo location, and rewrites the URL to the product page.
  6. 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)

  1. Environment variables – set HMAC_SECRET in the dashboard.
  2. Enable Edge Runtime – the middleware.ts file automatically runs at the edge; no extra config needed.
  3. Buildnext build will generate the ISR pages and the edge bundle.
  4. 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.ts file 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.