What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
Tutorials
Agility lets editors manage URL redirects in the Manager, under Settings → URL Redirections — so a marketer can retire /company in favour of /about-us without filing a ticket. Your job is to make the site honour that list.
The pattern below syncs the redirect list to a file at build time and resolves it in the proxy at request time. That combination is what makes a redirect published at 4pm work at 4pm.
Coming from the older recipe? This article used to fetch redirects inside
next.config.jsand return them fromasync redirects(). That still runs, but it has a flaw worth being explicit about:redirects()is evaluated once, at build time, and compiled into the routes manifest. A redirect an editor publishes afterwards does nothing until the next deploy — which is exactly when they most expect it to work. Next's own guidance for managing redirects at scale is to keep them in a data store and resolve them in middleware. Note also thatnext.config.mjsis an ES module, so the oldconst agility = require(...)line wouldn't run there anyway.
node/prebuild.ts # fetch redirects from Agility -> data/redirections.json
data/redirections.json # a path-keyed map, committed or generated per build
proxy.ts # look up the incoming path, redirect if it matches
Agility's getUrlRedirections returns a list. Convert it to a map keyed by origin path, so the lookup at request time is O(1) instead of a scan:
// lib/cms/getRedirections.ts
import agility from "@agility/content-fetch"
import fs from "fs/promises"
export interface Redirection {
id: number
originUrl: string
destinationUrl: string
statusCode: number
}
export const getRedirections = async () => {
const client = agility.getApi({
guid: process.env.AGILITY_GUID,
apiKey: process.env.AGILITY_API_FETCH_KEY,
isPreview: false,
})
const res = await client.getUrlRedirections({ lastAccessDate: undefined })
const map: Record<string, Redirection> = {}
for (const redirection of res.items) {
let key = redirection.originUrl.toLowerCase()
// editors enter "~/company" — the "~" is Agility's site-root marker
if (key.startsWith("~/")) key = key.substring(1)
// ...and sometimes paste a full absolute URL. Keep only the path.
if (key.includes("://")) {
key = key.substring(key.indexOf("/", key.indexOf("://") + 3))
}
if (redirection.destinationUrl.startsWith("~/")) {
redirection.destinationUrl = redirection.destinationUrl.substring(1)
}
map[key] = redirection
}
await fs.writeFile("data/redirections.json", JSON.stringify({ items: map }), "utf8")
return map
}
Those three normalizations are the whole reason this needs code rather than a one-liner. Editors type ~/company, /company, /Company and https://site.com/company, and all four must match the same request.
Run it before every build:
{
"scripts": {
"prebuild": "tsx node/prebuild.ts",
"build": "next build"
}
}
prebuild runs automatically before build — npm does that for you.
proxy.ts is Next 16's renamed middleware.ts. Because the map is a plain JSON import, the lookup costs nothing:
// lib/cms-content/checkRedirect.ts
import allRedirects from "@/../data/redirections.json"
export const checkRedirect = async ({ path }: { path: string }) => {
if (path === "/") return null // the root is never a redirect
return allRedirects.items[path.toLowerCase()] || null
}
// proxy.ts
import { NextResponse, type NextRequest } from "next/server"
import { checkRedirect } from "@/lib/cms-content/checkRedirect"
export async function proxy(request: NextRequest) {
const redirection = await checkRedirect({ path: request.nextUrl.pathname })
if (redirection) {
if (redirection.destinationUrl.startsWith("/")) {
// relative — keep the current host, so this works on every preview deploy
const url = request.nextUrl.clone()
url.pathname = redirection.destinationUrl
return NextResponse.redirect(url, {
status: redirection.statusCode,
headers: { "Cache-Control": "public, max-age=600, stale-while-revalidate" },
})
}
return NextResponse.redirect(redirection.destinationUrl, {
status: redirection.statusCode,
headers: { "Cache-Control": "public, max-age=3600, stale-while-revalidate" },
})
}
return NextResponse.next()
}
Cloning request.nextUrl for relative destinations rather than building an absolute URL from an env var is what keeps redirects working on preview deployments, where the host is different every time.
Order matters, and getting it wrong produces bugs that look unrelated:
/_next/static/chunk.js or /logo.svg is wasted work on your highest-volume requests./company), not internal locale-prefixed ones (/en-us/company).The map is only as current as your last build. Two ways to close that gap, and you can use both:
prebuild.getUrlRedirections accepts a lastAccessDate and replies isUpToDate: true when nothing has changed, so a frequent poll is cheap.A JSON map is the right answer well into the thousands. Past that, you're shipping the whole table into the proxy bundle on every request path. At that point switch to the approach in Next's own "managing redirects at scale" guidance: test the path against a bloom filter in the proxy — small, fast, no false negatives — and only on a hit call a route handler that looks up the real record. Most requests never leave the proxy; the rare false positive costs one extra lookup.
statusCode — pass it through. A 301 is cached by browsers indefinitely and is painful to undo, so use 302 unless the move is genuinely permanent.~/, case, and absolute URLs.proxy.ts, not in next.config — that way a redirect published today works today.nextUrl for relative destinations so preview deployments work.Learn more about managing URL redirects in Agility.