Adds an afterChange hook to selected collections that stores a redirect record whenever a document slug changes. Records are kept in a slug-redirects collection and point to document IDs, so chained renames resolve to the current slug. It includes Next.js helpers for App Router and Pages Router, plus an optional API handler. Requires Payload 3 and Node.js 20 or later.
PayloadCMS plugin that automatically creates slug redirects when document slugs change
Installation
npm i payload-slug-redirects3
Stars
0
Forks
4
Open Issues
5 months ago
Last Updated
0
Weekly Downloads
v2.0.0
npm Version
54.6 KB
Unpacked Size
0
Dependencies
Trends appear after 7 days of data. Daily snapshots have not started yet.
Trends appear after 7 days of data. Daily snapshots have not started yet.
Each point is npm's rolling 7-day download count.
Health Score
Composite score based on GitHub activity, stars, npm downloads, publish recency, dependency count, and package size.
Badge for your README
[](https://payloaddirectory.dev/plugins/1185354194-root)<a href="https://payloaddirectory.dev/plugins/1185354194-root"><img src="https://payloaddirectory.dev/badge/1185354194-root.svg" alt="Payload Directory"></a>README
Automatic slug-change redirects for PayloadCMS v3. When an editor renames a page, the old URL stops working. This plugin records every slug change and gives your Next.js frontend the data it needs to issue a permanent redirect to the current URL.
Chained renames work automatically. If a slug changes from A to B to C, both old URLs resolve to C because redirect records store the document ID, not the destination slug.
npm install payload-slug-redirects
# or
pnpm add payload-slug-redirects
// payload.config.ts
import { slugRedirectsPlugin } from 'payload-slug-redirects'
export default buildConfig({
plugins: [
slugRedirectsPlugin({
collections: ['posts', 'case-studies'],
locales: ['en', 'ar'], // omit for single-language sites
}),
],
})
The plugin does three things:
afterChange hook that records slug changes.slug-redirects collection in your database.When an editor saves a document with a changed slug, the afterChange hook compares the old and new values. If they differ, it creates a redirect record with the old slug, locale, collection type, and document ID.
On the frontend, when a visitor hits a URL that doesn't match any document, you query the slug-redirects collection for the old slug. If a record exists, you fetch the document by its stored ID to get the current slug, then redirect.
Because records point to document IDs (not destination slugs), chain renames resolve correctly without cleanup.
Payload v3 ships with a built-in slugField() helper that auto-generates URL-friendly slugs from a source field like title:
import { slugField } from 'payload'
export const Pages: CollectionConfig = {
slug: 'pages',
fields: [
{ name: 'title', type: 'text', required: true },
slugField({ fieldToUse: 'title' }),
],
}
This plugin is fully compatible with it. When a slug field already exists on a collection, the plugin skips injection and only adds the afterChange hook for redirect tracking.
Single-locale sites work out of the box. The native slugField() creates a slug text field, and the plugin reads it directly in the hook.
Multi-locale sites need one extra step. Payload's native slugField({ localized: true }) stores per-locale values using Payload's built-in localization. The problem is that afterChange hooks only receive the current locale's string value, not an object with all locales. The hook sees "my-post" instead of { en: "my-post", ar: "..." }, which means it can't detect slug changes in other locales from a single save.
The workaround: let the plugin inject its own localizedSlugs JSON field for internal change tracking, and keep using the native slug field for your frontend. They serve different purposes and coexist without conflict:
slugRedirectsPlugin({
collections: ['pages'],
locales: ['en', 'ar'],
// slugField defaults to 'localizedSlugs' for multi-locale setups
// Your native slugField('slug') is untouched -- the plugin adds a separate field
})
If you don't use Payload's native slugField(), the plugin injects one for you:
| Site type | Config | Injected field |
|---|---|---|
| Single language | collections: ['posts'] | { name: 'slug', type: 'text' } |
| Multi language | collections: ['posts'], locales: ['en', 'ar'] | { name: 'localizedSlugs', type: 'json' } |
If the field already exists on your collection, the plugin leaves it alone.
slugRedirectsPlugin({
enabled: true, // set false to disable without removing
collections: ['posts'], // required
locales: ['en', 'ar'], // default: ['en']
slugField: 'localizedSlugs', // default: 'slug' or 'localizedSlugs'
revalidateUrl: 'https://mysite.com/api/revalidate',
revalidateHeaders: { // sent with revalidation requests
Authorization: 'Bearer secret',
},
collection: {
name: 'my-redirects', // default: 'slug-redirects'
visibleInTheUI: false, // default: true
onChange: async ({ fromSlug, toSlug, locale, collectionType }) => {
// fires on any slug change -- async callbacks are supported
},
},
})
slugRedirectsPlugin({
collections: [
'posts', // uses plugin defaults
{
name: 'case-studies',
slugField: 'customSlugField',
onChange: ({ fromSlug, toSlug, locale }) => {
console.log(`${locale}: ${fromSlug} -> ${toSlug}`)
},
},
],
locales: ['en', 'ar'],
})
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Disable the plugin without removing it from config |
collections | Array<string | CollectionEntry> | required | Collections to watch |
locales | string[] | ['en'] | Locale codes to track |
slugField | string | auto | 'slug' for single locale, 'localizedSlugs' for multi |
revalidateUrl | string | -- | Revalidation endpoint URL (production only) |
revalidateHeaders | Record<string, string> | -- | Custom headers for revalidation requests |
collection.name | string | 'slug-redirects' | Custom collection slug |
collection.visibleInTheUI | boolean | true | Show in admin sidebar |
collection.onChange | (args) => void | Promise<void> | -- | Fires on any slug change |
// app/[locale]/posts/[slug]/page.tsx
import { SlugRedirect } from 'payload-slug-redirects/next/app'
export default async function PostPage({ params }) {
const post = await getPost(params.slug, params.locale)
if (!post) {
return (
<SlugRedirect
slug={params.slug}
collectionType="posts"
locale={params.locale}
cmsUrl={process.env.CMS_URL!}
buildUrl={(slug, locale, collectionType) => `/${locale}/${collectionType}/${slug}`}
/>
)
}
return <PostView post={post} />
}
SlugRedirect is a React Server Component. It calls permanentRedirect() (308) or notFound(). It never renders HTML. Pass permanent={false} for a temporary redirect (307) instead.
// pages/posts/[slug].tsx
import { resolveSlugRedirect } from 'payload-slug-redirects/next/pages'
// inside getStaticProps
if (!post) {
const redirect = await resolveSlugRedirect({
fromSlug: slug,
locale: locale ?? 'en',
collectionType: 'posts',
cmsUrl: process.env.NEXT_PUBLIC_CMS_API_URL!,
buildUrl: (s, l) => l === 'ar' ? `/ar/posts/${s}` : `/posts/${s}`,
})
if (redirect) return redirect // { redirect: { destination, permanent: true } }
return { notFound: true }
}
// Pass `permanent: false` for a temporary redirect (302) instead of permanent (301).
Optional endpoint for client-side redirect lookups:
// pages/api/slug-redirects.ts
import { createSlugRedirectHandler } from 'payload-slug-redirects/next'
export default createSlugRedirectHandler({
cmsUrl: process.env.NEXT_PUBLIC_CMS_API_URL!,
})
GET /api/slug-redirects?fromSlug=old-slug&locale=en&collectionType=posts
200 { slug: 'new-slug' }
404 { error: 'No redirect found' }
Auto-created by the plugin. Visible in the admin UI under the "System" group by default.
| Field | Type | Description |
|---|---|---|
fromSlug | text, indexed | The old slug |
locale | select | Locale this redirect applies to |
collectionType | select | Source collection |
documentId | text | Document ID (works with both SQL and MongoDB) |
Write access is locked down. Only the plugin's hook can create records (via overrideAccess). Admins can delete stale records through the UI.
| Import path | Exports |
|---|---|
payload-slug-redirects | slugRedirectsPlugin, buildSlugRedirectsCollection, createRedirectOnSlugChange |
payload-slug-redirects/next/app | SlugRedirect (RSC) |
payload-slug-redirects/next/pages | resolveSlugRedirect() |
payload-slug-redirects/next | createSlugRedirectHandler() |
^3.0.0>=14.0.0 (optional, only for the Next.js utilities)This project follows semver. Releases are published to npm via GitHub Releases. See the release workflow for details.
See CONTRIBUTING.md.
redirects
Manage internal and external redirects from a dedicated collection in the Payload admin panel
100105.3k/wk45.4kv3
localized slugs
Copies localized slugs and full paths into a single localizedSlugs field for multi-locale Payload collections
650/wk9v3
payload page tree
Folder-based hierarchical URL slugs with a visual tree view for managing pages in the Payload admin
590/wk19v3
paths
Stored, queryable document paths for Payload page trees with publish-time uniqueness and a framework-agnostic resolver
560/wk15v3
redirects
Managed redirects collection with a cache-backed Next.js middleware for fast edge-safe matching
560/wk15v3
permalink
WordPress-style slugs, canonical paths and permalink lookup for selected Payload collections
550/wk0v4