# Implementing URL Redirects with Next.js

> Source: https://agilitycms.com/docs/nextjs/implementing-url-redirects-with-next-js

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.js` and return them from `async 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 that `next.config.mjs` is an ES module, so the old `const agility = require(...)` line wouldn't run there anyway.

## The shape

```
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
```

## 1. Sync the redirects to a map at build time

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:

```ts
// 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:

```json
{
  "scripts": {
    "prebuild": "tsx node/prebuild.ts",
    "build": "next build"
  }
}
```

`prebuild` runs automatically before `build` — npm does that for you.

## 2. Resolve the redirect in the proxy

`proxy.ts` is Next 16's renamed `middleware.ts`. Because the map is a plain JSON import, the lookup costs nothing:

```ts
// 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
}
```

```ts
// 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.

## 3. Where the check has to sit

Order matters, and getting it wrong produces bugs that look unrelated:

- **Only check extension-less paths.** Running the lookup on `/_next/static/chunk.js` or `/logo.svg` is wasted work on your highest-volume requests.
- **Put it before locale rewriting.** Editors enter redirects against clean URLs (`/company`), not internal locale-prefixed ones (`/en-us/company`).
- **Put it after preview/draft handling**, so an editor previewing a page isn't bounced away from it.

## 4. Keeping the list fresh

The map is only as current as your last build. Two ways to close that gap, and you can use both:

- **Webhook → deploy.** In Agility, under **Settings → Web Hooks**, point a hook at your host's deploy hook. Publishing a redirect triggers a build, which re-runs `prebuild`.
- **Refresh on a schedule.** Re-run the sync from a cron route handler and write to a store your proxy can read — Agility's `getUrlRedirections` accepts a `lastAccessDate` and replies `isUpToDate: true` when nothing has changed, so a frequent poll is cheap.

## 5. When the list gets large

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"](https://nextjs.org/docs/app/guides/redirecting) 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.

## Gotchas

- **301 vs 302.** Agility gives you `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.
- **Lowercase both sides of the comparison.** URLs arrive in whatever case the visitor typed.
- **Watch for loops.** If a destination is itself an origin elsewhere in the list, the browser will bounce until it gives up. Validate the map when you build it.
- **A redirect beats a page.** If a path exists both in your sitemap and in the redirect list, the proxy wins — the page becomes unreachable. That's usually intended, but it's a confusing way to find out.

## Summary

1. Sync Agility's redirects into a path-keyed map at build time, normalizing `~/`, case, and absolute URLs.
2. Resolve them in `proxy.ts`, not in `next.config` — that way a redirect published today works today.
3. Clone `nextUrl` for relative destinations so preview deployments work.
4. Only check extension-less paths, before locale rewriting.
5. Trigger a rebuild from an Agility web hook, and move to a bloom filter once the list gets big.

Learn more about [managing URL redirects in Agility](https://agilitycms.com/docs/editors/url-redirections).
