feat(cms): add redirects plugin + middleware for 301/302 handling

Install @payloadcms/plugin-redirects, configure for 'pages' collection
with 301/302 types. Create src/middleware.ts that reads the redirects
collection via REST API (cached 5 min) and applies them before Next.js
renders — editors can manage redirects from the CMS admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-15 16:56:30 +01:00
parent f808ad6b42
commit bf084e37c9
4 changed files with 69 additions and 0 deletions

View file

@ -42,6 +42,7 @@
"@payloadcms/email-resend": "^3.84.1",
"@payloadcms/live-preview-react": "^3.84.1",
"@payloadcms/next": "^3.84.0",
"@payloadcms/plugin-redirects": "^3.84.1",
"@payloadcms/plugin-seo": "^3.84.1",
"@payloadcms/richtext-lexical": "^3.84.0",
"@react-email/components": "^1.0.12",

View file

@ -2,6 +2,7 @@ import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { seoPlugin } from '@payloadcms/plugin-seo'
import { redirectsPlugin } from '@payloadcms/plugin-redirects'
import { resendAdapter } from '@payloadcms/email-resend'
import sharp from 'sharp'
import path from 'path'
@ -89,6 +90,13 @@ export default buildConfig({
],
plugins: [
redirectsPlugin({
collections: ['pages'],
redirectTypes: ['301', '302'],
overrides: {
admin: { group: 'Контент сайту' },
},
}),
seoPlugin({
collections: ['pages', 'blog-posts', 'locations'],
globals: [

13
pnpm-lock.yaml generated
View file

@ -20,6 +20,9 @@ importers:
'@payloadcms/next':
specifier: ^3.84.0
version: 3.84.1(@types/react@19.2.14)(graphql@16.14.0)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.0)(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
'@payloadcms/plugin-redirects':
specifier: ^3.84.1
version: 3.84.1(payload@3.84.1(graphql@16.14.0)(typescript@6.0.3))
'@payloadcms/plugin-seo':
specifier: ^3.84.1
version: 3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.0)(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
@ -1548,6 +1551,11 @@ packages:
next: '>=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.2 <17.0.0'
payload: 3.84.1
'@payloadcms/plugin-redirects@3.84.1':
resolution: {integrity: sha512-izKKI5Mm6nosetsAxlqbdOKxraYPlHSGy246D4YbsDemAV4CLOpGcQKTaeTW/JAqYt+RlcvzcWXm0qNMX+2MeQ==}
peerDependencies:
payload: 3.84.1
'@payloadcms/plugin-seo@3.84.1':
resolution: {integrity: sha512-9FYs5ML/eWR/A/rQfHt2NhPzkJWbUx5SN/+lEQ90r3c3Z8CQUVpt4vETXSI9Gxi764lTutIGemuZAJK9WRy3Lw==}
peerDependencies:
@ -6988,6 +6996,11 @@ snapshots:
- supports-color
- typescript
'@payloadcms/plugin-redirects@3.84.1(payload@3.84.1(graphql@16.14.0)(typescript@6.0.3))':
dependencies:
'@payloadcms/translations': 3.84.1
payload: 3.84.1(graphql@16.14.0)(typescript@6.0.3)
'@payloadcms/plugin-seo@3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.0)(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)':
dependencies:
'@payloadcms/translations': 3.84.1

47
src/middleware.ts Normal file
View file

@ -0,0 +1,47 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
let redirectsCache: { from: string; to: string; type: string }[] | null = null
let cacheExpiry = 0
async function getRedirects(siteURL: string) {
if (redirectsCache && Date.now() < cacheExpiry) return redirectsCache
try {
const res = await fetch(`${siteURL}/api/redirects?limit=100&depth=1`, {
next: { revalidate: 300 },
})
if (!res.ok) return []
const data = (await res.json()) as {
docs: { from: string; to: { url?: string }; redirectType: string }[]
}
redirectsCache = data.docs.map((r) => ({
from: r.from,
to: r.to?.url ?? '/',
type: r.redirectType,
}))
cacheExpiry = Date.now() + 5 * 60 * 1000
return redirectsCache
} catch {
return []
}
}
export async function middleware(request: NextRequest) {
const siteURL = process.env['NEXT_PUBLIC_SITE_URL'] ?? 'http://localhost:3000'
const pathname = request.nextUrl.pathname
const redirects = await getRedirects(siteURL)
const match = redirects.find((r) => r.from === pathname)
if (match) {
const status = match.type === '302' ? 302 : 301
return NextResponse.redirect(new URL(match.to, request.url), status)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/|admin/).*)'],
}