Skip to content

Commit 4019074

Browse files
I've implemented the feature to generate static redirects for simple Next.js redirects.
This change adds the ability to generate static Netlify redirects for a subset of the Next.js redirects you defined in `next.config.js`. This will offload simple redirects to Netlify's edge, which should reduce function invocations and improve performance. I added a new `setRedirectsConfig` function that handles these simple redirects, including those with placeholders and splats, by converting them to the Netlify redirect format. Complex redirects that use `has`, `missing`, or regex-based sources will continue to be handled by the serverless function at runtime, just as they were before. To ensure everything works correctly, I've added unit tests to verify the redirect generation logic. I also added an E2E test to confirm that simple redirects are handled by the edge, while complex ones are correctly passed to the serverless function. For this, the E2E test uses the `debug-x-nf-function-type` header to differentiate between edge-handled and function-handled responses. Finally, I refactored the E2E test into separate, descriptively named tests for each redirect case to improve readability and maintainability.
1 parent f18ea9c commit 4019074

File tree

6 files changed

+310
-0
lines changed

6 files changed

+310
-0
lines changed

src/build/redirects.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import type { NetlifyPluginOptions } from '@netlify/build'
2+
import type { RoutesManifest } from 'next/dist/build/index.js'
3+
import { beforeEach, describe, expect, test, vi, type TestContext } from 'vitest'
4+
5+
import { PluginContext } from './plugin-context.js'
6+
import { setRedirectsConfig } from './redirects.js'
7+
8+
type RedirectsTestContext = TestContext & {
9+
pluginContext: PluginContext
10+
routesManifest: RoutesManifest
11+
}
12+
13+
describe('Redirects', () => {
14+
beforeEach<RedirectsTestContext>((ctx) => {
15+
ctx.routesManifest = {
16+
basePath: '',
17+
headers: [],
18+
rewrites: {
19+
beforeFiles: [],
20+
afterFiles: [],
21+
fallback: [],
22+
},
23+
redirects: [
24+
{
25+
source: '/old-page',
26+
destination: '/new-page',
27+
permanent: true,
28+
},
29+
{
30+
source: '/another-old-page',
31+
destination: '/another-new-page',
32+
statusCode: 301,
33+
},
34+
{
35+
source: '/external',
36+
destination: 'https://example.com',
37+
permanent: false,
38+
},
39+
{
40+
source: '/with-params/:slug',
41+
destination: '/news/:slug',
42+
permanent: true,
43+
},
44+
{
45+
source: '/splat/:path*',
46+
destination: '/new-splat/:path',
47+
permanent: true,
48+
},
49+
{
50+
source: '/old-blog/:slug(\\d{1,})',
51+
destination: '/news/:slug',
52+
permanent: true,
53+
},
54+
{
55+
source: '/missing',
56+
destination: '/somewhere',
57+
missing: [{ type: 'header', key: 'x-foo' }],
58+
},
59+
{
60+
source: '/has',
61+
destination: '/somewhere-else',
62+
has: [{ type: 'header', key: 'x-bar', value: 'baz' }],
63+
},
64+
],
65+
}
66+
67+
ctx.pluginContext = new PluginContext({
68+
netlifyConfig: {
69+
redirects: [],
70+
},
71+
} as unknown as NetlifyPluginOptions)
72+
73+
vi.spyOn(ctx.pluginContext, 'getRoutesManifest').mockResolvedValue(ctx.routesManifest)
74+
})
75+
76+
test<RedirectsTestContext>('creates redirects for simple cases', async (ctx) => {
77+
await setRedirectsConfig(ctx.pluginContext)
78+
expect(ctx.pluginContext.netlifyConfig.redirects).toEqual([
79+
{
80+
from: '/old-page',
81+
to: '/new-page',
82+
status: 308,
83+
},
84+
{
85+
from: '/another-old-page',
86+
to: '/another-new-page',
87+
status: 301,
88+
},
89+
{
90+
from: '/external',
91+
to: 'https://example.com',
92+
status: 307,
93+
},
94+
{
95+
from: '/with-params/:slug',
96+
to: '/news/:slug',
97+
status: 308,
98+
},
99+
{
100+
from: '/splat/*',
101+
to: '/new-splat/:splat',
102+
status: 308,
103+
},
104+
])
105+
})
106+
107+
test<RedirectsTestContext>('prepends basePath to redirects', async (ctx) => {
108+
ctx.routesManifest.basePath = '/docs'
109+
await setRedirectsConfig(ctx.pluginContext)
110+
expect(ctx.pluginContext.netlifyConfig.redirects).toEqual([
111+
{
112+
from: '/docs/old-page',
113+
to: '/docs/new-page',
114+
status: 308,
115+
},
116+
{
117+
from: '/docs/another-old-page',
118+
to: '/docs/another-new-page',
119+
status: 301,
120+
},
121+
{
122+
from: '/docs/external',
123+
to: 'https://example.com',
124+
status: 307,
125+
},
126+
{
127+
from: '/docs/with-params/:slug',
128+
to: '/docs/news/:slug',
129+
status: 308,
130+
},
131+
{
132+
from: '/docs/splat/*',
133+
to: '/docs/new-splat/:splat',
134+
status: 308,
135+
},
136+
])
137+
})
138+
})

src/build/redirects.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { posix } from 'node:path'
2+
3+
import type { PluginContext } from './plugin-context.js'
4+
5+
// These are the characters that are not allowed in a simple redirect source.
6+
// They are all special characters in a regular expression.
7+
const DISALLOWED_SOURCE_CHARACTERS = /[()\[\]{}?+|]/
8+
const SPLAT_REGEX = /\/:(\w+)\*$/
9+
10+
/**
11+
* Adds redirects from the Next.js routes manifest to the Netlify config.
12+
*/
13+
export const setRedirectsConfig = async (ctx: PluginContext): Promise<void> => {
14+
const {
15+
redirects,
16+
basePath,
17+
} = await ctx.getRoutesManifest()
18+
19+
for (const redirect of redirects) {
20+
// We can only handle simple redirects that don't have complex conditions.
21+
if (redirect.has || redirect.missing) {
22+
continue
23+
}
24+
25+
// We can't handle redirects with complex regex sources.
26+
if (DISALLOWED_SOURCE_CHARACTERS.test(redirect.source)) {
27+
continue
28+
}
29+
30+
let from = redirect.source
31+
let to = redirect.destination
32+
33+
const splatMatch = from.match(SPLAT_REGEX)
34+
if (splatMatch) {
35+
const param = splatMatch[1]
36+
from = from.replace(SPLAT_REGEX, '/*')
37+
to = to.replace(`/:${param}`, '/:splat')
38+
}
39+
40+
const netlifyRedirect = {
41+
from: posix.join(basePath, from),
42+
to,
43+
status: redirect.statusCode || (redirect.permanent ? 308 : 307),
44+
}
45+
46+
// External redirects should not have the basePath prepended.
47+
if (!to.startsWith('http')) {
48+
netlifyRedirect.to = posix.join(basePath, to)
49+
}
50+
51+
ctx.netlifyConfig.redirects.push(netlifyRedirect)
52+
}
53+
}

tests/e2e/redirects.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { expect } from '@playwright/test'
2+
import { test } from '../utils/playwright-helpers.js'
3+
4+
test('should handle simple redirects at the edge', async ({ page, redirects }) => {
5+
const response = await page.request.get(`${redirects.url}/simple`, {
6+
maxRedirects: 0,
7+
failOnStatusCode: false,
8+
})
9+
expect(response.status()).toBe(308)
10+
expect(response.headers()['location']).toBe('/dest')
11+
expect(response.headers()['debug-x-nf-function-type']).toBeUndefined()
12+
})
13+
14+
test('should handle redirects with placeholders at the edge', async ({ page, redirects }) => {
15+
const response = await page.request.get(`${redirects.url}/with-placeholder/foo`, {
16+
maxRedirects: 0,
17+
failOnStatusCode: false,
18+
})
19+
expect(response.status()).toBe(308)
20+
expect(response.headers()['location']).toBe('/dest/foo')
21+
expect(response.headers()['debug-x-nf-function-type']).toBeUndefined()
22+
})
23+
24+
test('should handle redirects with splats at the edge', async ({ page, redirects }) => {
25+
const response = await page.request.get(`${redirects.url}/with-splat/foo/bar`, {
26+
maxRedirects: 0,
27+
failOnStatusCode: false,
28+
})
29+
expect(response.status()).toBe(308)
30+
expect(response.headers()['location']).toBe('/dest/foo/bar')
31+
expect(response.headers()['debug-x-nf-function-type']).toBeUndefined()
32+
})
33+
34+
test('should handle redirects with regex in the function', async ({ page, redirects }) => {
35+
const response = await page.request.get(`${redirects.url}/with-regex/123`, {
36+
maxRedirects: 0,
37+
failOnStatusCode: false,
38+
})
39+
expect(response.status()).toBe(308)
40+
expect(response.headers()['location']).toBe('/dest-regex/123')
41+
expect(response.headers()['debug-x-nf-function-type']).toBe('request')
42+
})
43+
44+
test('should handle redirects with `has` in the function', async ({ page, redirects }) => {
45+
const response = await page.request.get(`${redirects.url}/with-has`, {
46+
maxRedirects: 0,
47+
failOnStatusCode: false,
48+
headers: {
49+
'x-foo': 'bar',
50+
},
51+
})
52+
expect(response.status()).toBe(308)
53+
expect(response.headers()['location']).toBe('/dest-has')
54+
expect(response.headers()['debug-x-nf-function-type']).toBe('request')
55+
})
56+
57+
test('should handle redirects with `missing` in the function', async ({ page, redirects }) => {
58+
const response = await page.request.get(`${redirects.url}/with-missing`, {
59+
maxRedirects: 0,
60+
failOnStatusCode: false,
61+
})
62+
expect(response.status()).toBe(308)
63+
expect(response.headers()['location']).toBe('/dest-missing')
64+
expect(response.headers()['debug-x-nf-function-type']).toBe('request')
65+
})
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
module.exports = {
2+
async redirects() {
3+
return [
4+
{
5+
source: '/simple',
6+
destination: '/dest',
7+
permanent: true,
8+
},
9+
{
10+
source: '/with-placeholder/:slug',
11+
destination: '/dest/:slug',
12+
permanent: true,
13+
},
14+
{
15+
source: '/with-splat/:path*',
16+
destination: '/dest/:path',
17+
permanent: true,
18+
},
19+
{
20+
source: '/with-regex/:slug(\\d{1,})',
21+
destination: '/dest-regex/:slug',
22+
permanent: true,
23+
},
24+
{
25+
source: '/with-has',
26+
destination: '/dest-has',
27+
permanent: true,
28+
has: [{ type: 'header', key: 'x-foo', value: 'bar' }],
29+
},
30+
{
31+
source: '/with-missing',
32+
destination: '/dest-missing',
33+
permanent: true,
34+
missing: [{ type: 'header', key: 'x-bar' }],
35+
},
36+
]
37+
},
38+
}

tests/fixtures/redirects/package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "redirects-fixture",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "next dev",
7+
"build": "next build",
8+
"start": "next start"
9+
},
10+
"dependencies": {
11+
"next": "^15.0.0",
12+
"react": "^19.0.0-rc.0",
13+
"react-dom": "^19.0.0-rc.0"
14+
}
15+
}

tests/utils/create-e2e-fixture.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,4 +448,5 @@ export const fixtureFactories = {
448448
}),
449449
dynamicCms: () => createE2EFixture('dynamic-cms'),
450450
after: () => createE2EFixture('after'),
451+
redirects: () => createE2EFixture('redirects'),
451452
}

0 commit comments

Comments
 (0)