digitalbunch

payload slug redirects

by digitalbunch
Redirects & URLs
v3
^3.0.0
MIT

Overview

Summary generated from the README

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.

Features

  • Records old slugs in a dedicated slug-redirects collection when a slug changes
  • Stores document IDs so chained renames resolve to the latest slug
  • Injects a slug or localizedSlugs field when none exists on the collection
  • Tracks slug changes per locale on multi-language sites
  • Provides a server component and a Pages Router resolver for permanent redirects
  • Supports an onChange callback and an optional revalidation endpoint with custom headers

Use cases

  • Preserve old URLs when editors rename pages or posts on a Next.js site
  • Keep search rankings intact after slug changes in multi-locale sites
  • Trigger custom logic or cache revalidation whenever a slug is renamed

From the author

PayloadCMS plugin that automatically creates slug redirects when document slugs change

Installation

npm i payload-slug-redirects

3

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

GitHub stars

Trends appear after 7 days of data. Daily snapshots have not started yet.

npm weekly downloads

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

34/100Fair

Composite score based on GitHub activity, stars, npm downloads, publish recency, dependency count, and package size.

Badge for your README

Payload Directory health badge
Markdown
[![Payload Directory](https://payloaddirectory.dev/badge/1185354194-root.svg)](https://payloaddirectory.dev/plugins/1185354194-root)
HTML
<a href="https://payloaddirectory.dev/plugins/1185354194-root"><img src="https://payloaddirectory.dev/badge/1185354194-root.svg" alt="Payload Directory"></a>
Updated May 12, 2026Created Mar 18, 2026Published Mar 20, 2026

README

payload-slug-redirects

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 version MIT license

Table of contents

Install

npm install payload-slug-redirects
# or
pnpm add payload-slug-redirects

Quick start

// 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:

  1. Injects a slug field into watched collections (unless one already exists).
  2. Adds an afterChange hook that records slug changes.
  3. Creates a slug-redirects collection in your database.

How it works

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.

Working with Payload's native slug field

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
})

Slug field injection

If you don't use Payload's native slugField(), the plugin injects one for you:

Site typeConfigInjected field
Single languagecollections: ['posts']{ name: 'slug', type: 'text' }
Multi languagecollections: ['posts'], locales: ['en', 'ar']{ name: 'localizedSlugs', type: 'json' }

If the field already exists on your collection, the plugin leaves it alone.

Options

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
    },
  },
})

Per-collection overrides

slugRedirectsPlugin({
  collections: [
    'posts',                             // uses plugin defaults
    {
      name: 'case-studies',
      slugField: 'customSlugField',
      onChange: ({ fromSlug, toSlug, locale }) => {
        console.log(`${locale}: ${fromSlug} -> ${toSlug}`)
      },
    },
  ],
  locales: ['en', 'ar'],
})

Options reference

OptionTypeDefaultDescription
enabledbooleantrueDisable the plugin without removing it from config
collectionsArray<string | CollectionEntry>requiredCollections to watch
localesstring[]['en']Locale codes to track
slugFieldstringauto'slug' for single locale, 'localizedSlugs' for multi
revalidateUrlstring--Revalidation endpoint URL (production only)
revalidateHeadersRecord<string, string>--Custom headers for revalidation requests
collection.namestring'slug-redirects'Custom collection slug
collection.visibleInTheUIbooleantrueShow in admin sidebar
collection.onChange(args) => void | Promise<void>--Fires on any slug change

Next.js -- App Router

// 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.

Next.js -- Pages Router

// 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).

API route handler

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' }

The slug-redirects collection

Auto-created by the plugin. Visible in the admin UI under the "System" group by default.

FieldTypeDescription
fromSlugtext, indexedThe old slug
localeselectLocale this redirect applies to
collectionTypeselectSource collection
documentIdtextDocument 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.

Exports

Import pathExports
payload-slug-redirectsslugRedirectsPlugin, buildSlugRedirectsCollection, createRedirectOnSlugChange
payload-slug-redirects/next/appSlugRedirect (RSC)
payload-slug-redirects/next/pagesresolveSlugRedirect()
payload-slug-redirects/nextcreateSlugRedirectHandler()

Requirements

  • PayloadCMS ^3.0.0
  • Next.js >=14.0.0 (optional, only for the Next.js utilities)
  • Node.js 20 or later

Versioning

This project follows semver. Releases are published to npm via GitHub Releases. See the release workflow for details.

Contributing

See CONTRIBUTING.md.

License

MIT

Similar plugins