npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

payload-slug-redirects

v2.0.0

Published

PayloadCMS plugin that automatically creates slug redirects when document slugs change

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

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

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

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.

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

Exports

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

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