Skip to content

Commit 4a38bc7

Browse files
google-labs-jules[bot]serhalp
authored andcommitted
feat: generate static redirects for simple Next.js redirects
This change introduces the ability to generate static Netlify redirects for a subset of Next.js redirects defined in `next.config.js`. This offloads simple redirects to Netlify's edge, reducing function invocations and improving performance. The new `setRedirectsConfig` function handles 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. Unit tests have been added to verify the redirect generation logic. An E2E test has also been added to ensure that simple redirects are handled by the edge, while complex redirects are correctly passed to the serverless function. The E2E test uses the `debug-x-nf-function-type` header to differentiate between edge-handled and function-handled responses. The E2E test has been refactored to have separate, descriptively named tests for each redirect case to improve readability and maintainability.
1 parent 3986266 commit 4a38bc7

File tree

23 files changed

+418
-0
lines changed

23 files changed

+418
-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, type TestContext, vi } 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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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+
// eslint-disable-next-line unicorn/better-regex, no-useless-escape
8+
const DISALLOWED_SOURCE_CHARACTERS = /[()\[\]{}?+|]/
9+
const SPLAT_REGEX = /\/:(\w+)\*$/
10+
11+
/**
12+
* Adds redirects from the Next.js routes manifest to the Netlify config.
13+
*/
14+
export const setRedirectsConfig = async (ctx: PluginContext): Promise<void> => {
15+
const { redirects, basePath } = await ctx.getRoutesManifest()
16+
17+
for (const redirect of redirects) {
18+
// We can only handle simple redirects that don't have complex conditions.
19+
if (redirect.has || redirect.missing) {
20+
continue
21+
}
22+
23+
// We can't handle redirects with complex regex sources.
24+
if (DISALLOWED_SOURCE_CHARACTERS.test(redirect.source)) {
25+
continue
26+
}
27+
28+
let from = redirect.source
29+
let to = redirect.destination
30+
31+
const splatMatch = from.match(SPLAT_REGEX)
32+
if (splatMatch) {
33+
const [, param] = splatMatch
34+
from = from.replace(SPLAT_REGEX, '/*')
35+
to = to.replace(`/:${param}`, '/:splat')
36+
}
37+
38+
const netlifyRedirect = {
39+
from: posix.join(basePath, from),
40+
to,
41+
status: redirect.statusCode || (redirect.permanent ? 308 : 307),
42+
}
43+
44+
// External redirects should not have the basePath prepended.
45+
if (!to.startsWith('http')) {
46+
netlifyRedirect.to = posix.join(basePath, to)
47+
}
48+
49+
ctx.netlifyConfig.redirects.push(netlifyRedirect)
50+
}
51+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { clearStaleEdgeHandlers, createEdgeHandlers } from './build/functions/ed
1818
import { clearStaleServerHandlers, createServerHandler } from './build/functions/server.js'
1919
import { setImageConfig } from './build/image-cdn.js'
2020
import { PluginContext } from './build/plugin-context.js'
21+
import { setRedirectsConfig } from './build/redirects.js'
2122
import {
2223
verifyAdvancedAPIRoutes,
2324
verifyNetlifyFormsWorkaround,
@@ -99,6 +100,7 @@ export const onBuild = async (options: NetlifyPluginOptions) => {
99100
createEdgeHandlers(ctx),
100101
setHeadersConfig(ctx),
101102
setImageConfig(ctx),
103+
setRedirectsConfig(ctx),
102104
])
103105
})
104106
}

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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function DestHasPage() {
2+
return (
3+
<div>
4+
<h1>Destination Page with Has Condition</h1>
5+
<p>This page is shown when the redirect has condition is met</p>
6+
</div>
7+
)
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function DestMissingPage() {
2+
return (
3+
<div>
4+
<h1>Destination Page with Missing Condition</h1>
5+
<p>This page is shown when the redirect missing condition is met</p>
6+
</div>
7+
)
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function DestRegexPage({ params }) {
2+
return (
3+
<div>
4+
<h1>Destination Page with Regex</h1>
5+
<p>Slug: {params.slug}</p>
6+
</div>
7+
)
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function DestPathPage({ params }) {
2+
return (
3+
<div>
4+
<h1>Destination Page with Splat</h1>
5+
<p>Path: {params.path?.join('/') || ''}</p>
6+
</div>
7+
)
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function DestSlugPage({ params }) {
2+
return (
3+
<div>
4+
<h1>Destination Page with Placeholder</h1>
5+
<p>Slug: {params.slug}</p>
6+
</div>
7+
)
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function DestPage() {
2+
return (
3+
<div>
4+
<h1>Destination Page</h1>
5+
<p>This is the destination for simple redirects</p>
6+
</div>
7+
)
8+
}

0 commit comments

Comments
 (0)