Skip to content

feat: Generate static redirects for simple Next.js redirects #3040

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions src/build/redirects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { NetlifyPluginOptions } from '@netlify/build'
import type { RoutesManifest } from 'next/dist/build/index.js'
import { beforeEach, describe, expect, test, vi, type TestContext } from 'vitest'

import { PluginContext } from './plugin-context.js'
import { setRedirectsConfig } from './redirects.js'

type RedirectsTestContext = TestContext & {
pluginContext: PluginContext
routesManifest: RoutesManifest
}

describe('Redirects', () => {
beforeEach<RedirectsTestContext>((ctx) => {
ctx.routesManifest = {
basePath: '',
headers: [],
rewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [],
},
redirects: [
{
source: '/old-page',
destination: '/new-page',
permanent: true,
},
{
source: '/another-old-page',
destination: '/another-new-page',
statusCode: 301,
},
{
source: '/external',
destination: 'https://example.com',
permanent: false,
},
{
source: '/with-params/:slug',
destination: '/news/:slug',
permanent: true,
},
{
source: '/splat/:path*',
destination: '/new-splat/:path',
permanent: true,
},
{
source: '/old-blog/:slug(\\d{1,})',
destination: '/news/:slug',
permanent: true,
},
{
source: '/missing',
destination: '/somewhere',
missing: [{ type: 'header', key: 'x-foo' }],
},
{
source: '/has',
destination: '/somewhere-else',
has: [{ type: 'header', key: 'x-bar', value: 'baz' }],
},
],
}

ctx.pluginContext = new PluginContext({
netlifyConfig: {
redirects: [],
},
} as unknown as NetlifyPluginOptions)

vi.spyOn(ctx.pluginContext, 'getRoutesManifest').mockResolvedValue(ctx.routesManifest)
})

test<RedirectsTestContext>('creates redirects for simple cases', async (ctx) => {
await setRedirectsConfig(ctx.pluginContext)
expect(ctx.pluginContext.netlifyConfig.redirects).toEqual([
{
from: '/old-page',
to: '/new-page',
status: 308,
},
{
from: '/another-old-page',
to: '/another-new-page',
status: 301,
},
{
from: '/external',
to: 'https://example.com',
status: 307,
},
{
from: '/with-params/:slug',
to: '/news/:slug',
status: 308,
},
{
from: '/splat/*',
to: '/new-splat/:splat',
status: 308,
},
])
})

test<RedirectsTestContext>('prepends basePath to redirects', async (ctx) => {
ctx.routesManifest.basePath = '/docs'
await setRedirectsConfig(ctx.pluginContext)
expect(ctx.pluginContext.netlifyConfig.redirects).toEqual([
{
from: '/docs/old-page',
to: '/docs/new-page',
status: 308,
},
{
from: '/docs/another-old-page',
to: '/docs/another-new-page',
status: 301,
},
{
from: '/docs/external',
to: 'https://example.com',
status: 307,
},
{
from: '/docs/with-params/:slug',
to: '/docs/news/:slug',
status: 308,
},
{
from: '/docs/splat/*',
to: '/docs/new-splat/:splat',
status: 308,
},
])
})
})
53 changes: 53 additions & 0 deletions src/build/redirects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { posix } from 'node:path'

import type { PluginContext } from './plugin-context.js'

// These are the characters that are not allowed in a simple redirect source.
// They are all special characters in a regular expression.
const DISALLOWED_SOURCE_CHARACTERS = /[()\[\]{}?+|]/
const SPLAT_REGEX = /\/:(\w+)\*$/

/**
* Adds redirects from the Next.js routes manifest to the Netlify config.
*/
export const setRedirectsConfig = async (ctx: PluginContext): Promise<void> => {
const {
redirects,
basePath,
} = await ctx.getRoutesManifest()

for (const redirect of redirects) {
// We can only handle simple redirects that don't have complex conditions.
if (redirect.has || redirect.missing) {
continue
}

// We can't handle redirects with complex regex sources.
if (DISALLOWED_SOURCE_CHARACTERS.test(redirect.source)) {
continue
}

let from = redirect.source
let to = redirect.destination

const splatMatch = from.match(SPLAT_REGEX)
if (splatMatch) {
const param = splatMatch[1]
from = from.replace(SPLAT_REGEX, '/*')
to = to.replace(`/:${param}`, '/:splat')
}

const netlifyRedirect = {
from: posix.join(basePath, from),
to,
status: redirect.statusCode || (redirect.permanent ? 308 : 307),
}

// External redirects should not have the basePath prepended.
if (!to.startsWith('http')) {
netlifyRedirect.to = posix.join(basePath, to)
}

ctx.netlifyConfig.redirects.push(netlifyRedirect)
}
}
65 changes: 65 additions & 0 deletions tests/e2e/redirects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect } from '@playwright/test'
import { test } from '../utils/playwright-helpers.js'

test('should handle simple redirects at the edge', async ({ page, redirects }) => {
const response = await page.request.get(`${redirects.url}/simple`, {
maxRedirects: 0,
failOnStatusCode: false,
})
expect(response.status()).toBe(308)
expect(response.headers()['location']).toBe('/dest')
expect(response.headers()['debug-x-nf-function-type']).toBeUndefined()
})

test('should handle redirects with placeholders at the edge', async ({ page, redirects }) => {
const response = await page.request.get(`${redirects.url}/with-placeholder/foo`, {
maxRedirects: 0,
failOnStatusCode: false,
})
expect(response.status()).toBe(308)
expect(response.headers()['location']).toBe('/dest/foo')
expect(response.headers()['debug-x-nf-function-type']).toBeUndefined()
})

test('should handle redirects with splats at the edge', async ({ page, redirects }) => {
const response = await page.request.get(`${redirects.url}/with-splat/foo/bar`, {
maxRedirects: 0,
failOnStatusCode: false,
})
expect(response.status()).toBe(308)
expect(response.headers()['location']).toBe('/dest/foo/bar')
expect(response.headers()['debug-x-nf-function-type']).toBeUndefined()
})

test('should handle redirects with regex in the function', async ({ page, redirects }) => {
const response = await page.request.get(`${redirects.url}/with-regex/123`, {
maxRedirects: 0,
failOnStatusCode: false,
})
expect(response.status()).toBe(308)
expect(response.headers()['location']).toBe('/dest-regex/123')
expect(response.headers()['debug-x-nf-function-type']).toBe('request')
})

test('should handle redirects with `has` in the function', async ({ page, redirects }) => {
const response = await page.request.get(`${redirects.url}/with-has`, {
maxRedirects: 0,
failOnStatusCode: false,
headers: {
'x-foo': 'bar',
},
})
expect(response.status()).toBe(308)
expect(response.headers()['location']).toBe('/dest-has')
expect(response.headers()['debug-x-nf-function-type']).toBe('request')
})

test('should handle redirects with `missing` in the function', async ({ page, redirects }) => {
const response = await page.request.get(`${redirects.url}/with-missing`, {
maxRedirects: 0,
failOnStatusCode: false,
})
expect(response.status()).toBe(308)
expect(response.headers()['location']).toBe('/dest-missing')
expect(response.headers()['debug-x-nf-function-type']).toBe('request')
})
38 changes: 38 additions & 0 deletions tests/fixtures/redirects/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
module.exports = {
async redirects() {
return [
{
source: '/simple',
destination: '/dest',
permanent: true,
},
{
source: '/with-placeholder/:slug',
destination: '/dest/:slug',
permanent: true,
},
{
source: '/with-splat/:path*',
destination: '/dest/:path',
permanent: true,
},
{
source: '/with-regex/:slug(\\d{1,})',
destination: '/dest-regex/:slug',
permanent: true,
},
{
source: '/with-has',
destination: '/dest-has',
permanent: true,
has: [{ type: 'header', key: 'x-foo', value: 'bar' }],
},
{
source: '/with-missing',
destination: '/dest-missing',
permanent: true,
missing: [{ type: 'header', key: 'x-bar' }],
},
]
},
}
15 changes: 15 additions & 0 deletions tests/fixtures/redirects/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "redirects-fixture",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0-rc.0",
"react-dom": "^19.0.0-rc.0"
}
}
1 change: 1 addition & 0 deletions tests/utils/create-e2e-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,4 +448,5 @@ export const fixtureFactories = {
}),
dynamicCms: () => createE2EFixture('dynamic-cms'),
after: () => createE2EFixture('after'),
redirects: () => createE2EFixture('redirects'),
}
Loading