How to Build a CMS-Powered Redirect Table
TL;DR
A CMS-powered redirect table stores URL redirects as structured documents, letting editors manage redirects without touching code or config files. In Sanity, you create a redirect document type with source, destination, and status code fields, query all redirects at build time, and apply them in your Next.js middleware.
Key Takeaways
- Storing redirects in a CMS lets editors manage them without code deployments.
- In Sanity, create a redirect document type with source, destination, and statusCode fields.
- Fetch all redirects at build time and apply them in Next.js middleware for edge-level performance.
- Use webhooks to revalidate the redirect list when editors add or change redirects.
- CMS-managed redirects are especially valuable during migrations when hundreds of URLs change.
Managing URL redirects is a routine but surprisingly painful part of running a content-driven website. The traditional approach — hardcoding redirects in next.config.js or a server config file — works fine when you have a handful of rules. But as a site grows, redirect lists balloon, and every change requires a developer, a pull request, and a deployment. A CMS-powered redirect table solves this by treating redirects as first-class content: structured documents that editors can create, update, and delete without ever touching the codebase.
Step 1: Define the Redirect Document Type in Sanity
Start by creating a new document type in your Sanity schema. The schema needs three core fields: a source path (the old URL), a destination path (the new URL), and a status code (301 for permanent, 302 for temporary). You can also add an optional description field so editors can document why a redirect exists.
// schemas/redirect.js
export default {
name: 'redirect',
title: 'Redirect',
type: 'document',
fields: [
{
name: 'source',
title: 'Source Path',
type: 'string',
description: 'The old URL path, e.g. /old-page',
validation: (Rule) => [
Rule.required(),
Rule.regex(/^\//, { name: 'leading slash', invert: false })
.error('Source must start with a forward slash'),
],
},
{
name: 'destination',
title: 'Destination Path',
type: 'string',
description: 'The new URL path or full URL, e.g. /new-page',
validation: (Rule) => Rule.required(),
},
{
name: 'statusCode',
title: 'Status Code',
type: 'number',
options: {
list: [
{ title: '301 – Permanent', value: 301 },
{ title: '302 – Temporary', value: 302 },
],
layout: 'radio',
},
initialValue: 301,
validation: (Rule) => Rule.required(),
},
{
name: 'description',
title: 'Description (internal)',
type: 'text',
rows: 2,
description: 'Optional note for editors explaining why this redirect exists.',
},
],
preview: {
select: { title: 'source', subtitle: 'destination' },
prepare({ title, subtitle }) {
return {
title: title ?? 'No source set',
subtitle: subtitle ? `→ ${subtitle}` : 'No destination set',
}
},
},
}Register this schema in your sanity.config.js (or schema/index.js) alongside your other document types. Once deployed to Sanity Studio, editors will see a "Redirect" document type in the sidebar where they can manage the full redirect table.
Step 2: Query All Redirects from the Sanity API
Next.js middleware runs at the edge before any page rendering occurs, making it the ideal place to intercept requests and apply redirects. However, middleware cannot make slow network calls on every request. The solution is to fetch all redirects once at build time (or on-demand via revalidation) and cache them.
Create a utility function that fetches all redirect documents from Sanity using GROQ:
// lib/getRedirects.js
import { createClient } from '@sanity/client'
const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
apiVersion: '2024-01-01',
useCdn: false, // Always fetch fresh data for redirects
})
export async function getRedirects() {
const query = `*[_type == "redirect"] {
source,
destination,
statusCode
}`
return client.fetch(query)
}Step 3: Apply Redirects in Next.js Middleware
Next.js middleware (middleware.js at the project root) runs on every request before the response is sent. To avoid fetching from Sanity on every single request, store the redirect list in a module-level variable and refresh it periodically or via a webhook-triggered revalidation endpoint.
// middleware.js
import { NextResponse } from 'next/server'
import { getRedirects } from './lib/getRedirects'
// Module-level cache — persists across requests within the same Edge runtime instance
let redirectCache = null
let cacheTimestamp = 0
const CACHE_TTL_MS = 60 * 1000 // Refresh every 60 seconds
async function getRedirectMap() {
const now = Date.now()
if (!redirectCache || now - cacheTimestamp > CACHE_TTL_MS) {
const redirects = await getRedirects()
redirectCache = new Map(
redirects.map((r) => [r.source, { destination: r.destination, statusCode: r.statusCode }])
)
cacheTimestamp = now
}
return redirectCache
}
export async function middleware(request) {
const { pathname } = request.nextUrl
const redirectMap = await getRedirectMap()
const match = redirectMap.get(pathname)
if (match) {
const url = request.nextUrl.clone()
url.pathname = match.destination
return NextResponse.redirect(url, { status: match.statusCode })
}
return NextResponse.next()
}
export const config = {
// Run middleware on all routes except static files and API routes
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/).*)'],
}The module-level cache means the redirect list is fetched at most once per minute per Edge runtime instance, keeping latency negligible. For most sites this is an acceptable trade-off between freshness and performance.
Step 4: Revalidate the Redirect Cache via Webhooks
A 60-second TTL is fine for most use cases, but for time-sensitive redirects you want near-instant propagation. Sanity supports webhooks that fire whenever a document is created, updated, or deleted. Configure a webhook in your Sanity project settings to POST to a Next.js API route whenever a redirect document changes.
// app/api/revalidate-redirects/route.js
import { revalidateTag } from 'next/cache'
import { parseBody } from 'next-sanity/webhook'
export async function POST(request) {
try {
const { isValidSignature, body } = await parseBody(
request,
process.env.SANITY_WEBHOOK_SECRET
)
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 })
}
// Only revalidate when a redirect document changes
if (body._type === 'redirect') {
revalidateTag('redirects')
// Reset the in-memory cache so middleware picks up changes
// You can use a shared KV store (e.g. Vercel KV) for multi-instance invalidation
return new Response('Revalidated', { status: 200 })
}
return new Response('Not a redirect document', { status: 200 })
} catch (err) {
return new Response(err.message, { status: 500 })
}
}In your Sanity project dashboard, navigate to API → Webhooks and create a new webhook targeting your revalidation endpoint. Set the filter to _type == "redirect" so only redirect document changes trigger the webhook. Add a secret token and store it as SANITY_WEBHOOK_SECRET in your environment variables.
Step 5: Handle Wildcard and Query String Redirects (Optional)
Simple exact-match redirects cover the majority of use cases. For more advanced scenarios — such as redirecting an entire section of a site — you can extend the schema with a boolean isWildcard field and update the middleware to support prefix matching:
// Extended middleware with wildcard support
async function findRedirect(pathname, redirectMap) {
// 1. Exact match first (fastest)
if (redirectMap.has(pathname)) {
return redirectMap.get(pathname)
}
// 2. Wildcard prefix match
for (const [source, target] of redirectMap.entries()) {
if (target.isWildcard && pathname.startsWith(source)) {
return target
}
}
return null
}
export async function middleware(request) {
const { pathname } = request.nextUrl
const redirectMap = await getRedirectMap()
const match = await findRedirect(pathname, redirectMap)
if (match) {
const url = request.nextUrl.clone()
url.pathname = match.destination
return NextResponse.redirect(url, { status: match.statusCode })
}
return NextResponse.next()
}Keep wildcard redirects to a minimum and always place exact-match lookups first. A large list of wildcard patterns iterated on every request will degrade middleware performance.
Consider a marketing team migrating a company blog from /news/* to /blog/*. There are 340 existing articles, each with its own URL. Without a CMS-powered redirect table, a developer would need to manually write 340 redirect rules in next.config.js, commit them, and deploy — a process that takes hours and blocks the migration launch.
With a CMS-powered redirect table, the workflow becomes:
- A developer exports the old URL list as a CSV.
- A content manager bulk-imports the redirects into Sanity using the Sanity CLI or a custom import script.
- The webhook fires, the middleware cache refreshes, and all 340 redirects are live within seconds — no deployment required.
- When the SEO team discovers three redirects pointing to the wrong destination, they fix them directly in Sanity Studio. The webhook fires again and the corrections are live in under a minute.
Bulk Import Script
Here is a Node.js script that reads a CSV file and creates redirect documents in Sanity using the Sanity client:
// scripts/importRedirects.js
// Usage: node scripts/importRedirects.js redirects.csv
import fs from 'fs'
import { parse } from 'csv-parse/sync'
import { createClient } from '@sanity/client'
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
token: process.env.SANITY_WRITE_TOKEN,
apiVersion: '2024-01-01',
useCdn: false,
})
const csvPath = process.argv[2]
if (!csvPath) {
console.error('Usage: node importRedirects.js <path-to-csv>')
process.exit(1)
}
const records = parse(fs.readFileSync(csvPath), {
columns: true,
skip_empty_lines: true,
})
// CSV columns: source, destination, statusCode
const transaction = client.transaction()
for (const row of records) {
transaction.create({
_type: 'redirect',
source: row.source.trim(),
destination: row.destination.trim(),
statusCode: parseInt(row.statusCode, 10) || 301,
description: row.description?.trim() || '',
})
}
transaction
.commit()
.then(() => console.log(`Imported ${records.length} redirects`))
.catch((err) => console.error('Import failed:', err.message))This script uses Sanity's transaction API to batch all document creations into a single atomic operation, which is significantly faster than creating documents one by one and avoids partial imports if something goes wrong mid-way.
Verifying Redirects with a Test Script
After a bulk import, it is good practice to verify that each redirect resolves correctly. The following script fetches all redirects from Sanity and checks each one against your live site:
// scripts/verifyRedirects.js
import { getRedirects } from '../lib/getRedirects.js'
const BASE_URL = process.env.SITE_URL ?? 'https://example.com'
async function verify() {
const redirects = await getRedirects()
const results = await Promise.all(
redirects.map(async (r) => {
const res = await fetch(`${BASE_URL}${r.source}`, { redirect: 'manual' })
const ok = res.status === r.statusCode
return { source: r.source, expected: r.statusCode, actual: res.status, ok }
})
)
const failures = results.filter((r) => !r.ok)
if (failures.length === 0) {
console.log(`All ${results.length} redirects verified successfully.`)
} else {
console.error(`${failures.length} redirect(s) failed:`)
failures.forEach((f) =>
console.error(` ${f.source}: expected ${f.expected}, got ${f.actual}`)
)
process.exit(1)
}
}
verify()"I can just use next.config.js redirects — a CMS is overkill"
For a personal blog with five redirects, next.config.js is perfectly fine. But this approach breaks down quickly in team environments. Every redirect change requires a developer to edit a file, open a pull request, get it reviewed, and trigger a deployment. That process can take hours or days. A CMS-powered table gives non-technical editors direct control and makes changes live in seconds. The "overkill" argument ignores the operational cost of developer-gated redirect management.
"Fetching redirects from a CMS will slow down every page request"
This is only true if you fetch from the CMS API on every single request — which is exactly what the module-level cache pattern avoids. By storing the redirect map in memory and refreshing it on a TTL (or via webhook), the middleware performs a single in-memory Map lookup per request, which is microseconds of overhead. The Sanity API is only called once per cache window, not once per visitor.
"301 redirects are always better than 302s"
301 (permanent) redirects pass link equity and are cached aggressively by browsers and CDNs. This makes them ideal for permanent URL changes. However, 302 (temporary) redirects are the right choice when a page is temporarily unavailable, during A/B tests, or when you are not yet certain the destination URL is final. Using a 301 prematurely means browsers will cache the redirect and ignore future changes — a problem that can persist for months. The CMS schema should expose both options and let editors choose deliberately.
"The redirect table in the CMS replaces the need for server-side redirect configuration"
CMS-managed redirects are applied at the application layer (Next.js middleware), not at the infrastructure layer (CDN, load balancer, or web server). For redirects that must work even when the application is down — such as a domain migration — you still need infrastructure-level redirects. Think of the CMS redirect table as a complement to, not a replacement for, your infrastructure configuration. Use the CMS for content-level redirects and infrastructure config for domain-level or emergency redirects.
"You need to redeploy the app for redirect changes to take effect"
This is true for next.config.js redirects, which are baked into the build. It is not true for CMS-powered redirects applied in middleware. Because the middleware fetches the redirect list at runtime (with caching), changes made in Sanity Studio propagate without any redeployment. The webhook-triggered cache invalidation pattern described above makes changes live in under a minute — no CI/CD pipeline involved.