Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b552eb6
feat(vite-plugin-react-router): add edge support
serhalp Oct 28, 2025
0286e3c
test: add RR7 prerender tests
serhalp Oct 29, 2025
2f24fb1
fix: add mechanism to support user NFs
serhalp Oct 29, 2025
596e1d6
chore(deps): upgrade to latest netlify-cli
serhalp Oct 29, 2025
9f930c9
ci: install supported deno version to de-flake tests
serhalp Oct 30, 2025
78d1afe
test: de-flake on-demand revalidation tests
serhalp Oct 30, 2025
6b91c38
fix: simplify unnecessary edge vite config
serhalp Oct 30, 2025
dfb7f5b
refactor: extract shared edge/origin handler code
serhalp Oct 30, 2025
9790bd6
test: fix incorrect assertion
serhalp Oct 30, 2025
a512374
test: de-flake cache tests by isolating retries
serhalp Oct 30, 2025
f672e85
refactor: clean up minor gunk
serhalp Oct 30, 2025
db17f6c
test: fix edge cache assertion again
serhalp Oct 30, 2025
e5d9436
test: assert purge calls succeed
serhalp Oct 31, 2025
9ba3231
build: disable in-suite parallelization
serhalp Oct 31, 2025
ee4f0b4
build: add publint npm package validation
serhalp Oct 31, 2025
c6d75cb
fix: rework edge vs. serverless code organization
serhalp Oct 31, 2025
8f54892
fix: actually include human friendly name on Function
serhalp Oct 31, 2025
e30660a
ci: fix new publint workflow
serhalp Oct 31, 2025
f726914
build: allow code ESM splitting
serhalp Nov 3, 2025
6a26dd9
Revert "fix: actually include human friendly name on Function"
serhalp Nov 3, 2025
eb018ee
build: fix build setup regression
serhalp Nov 3, 2025
47dd051
test: fix RR7 edge fixture
serhalp Nov 3, 2025
3beb85c
fix: backport fix from 2f24fb1 to @netlify/remix-edge-adapter
serhalp Nov 3, 2025
4d68d17
Merge branch 'main' into serhalp/frb-1519-support-edge-rendering-with…
serhalp Nov 5, 2025
4b56e4d
Revert "build: disable in-suite parallelization"
serhalp Nov 5, 2025
f2fa84d
test: remove tiny cruft from fixture
serhalp Nov 5, 2025
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
4 changes: 2 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ jobs:

- run: corepack enable
- name: Install Deno
uses: denoland/setup-deno@v1
uses: denoland/setup-deno@v2
with:
# Should match the `DENO_VERSION_RANGE` from https://github.com/netlify/edge-bundler/blob/main/node/bridge.ts#L17
deno-version: v1.37.0
deno-version: v2.2.4
- run: pnpm install

- name: Install Playwright Browsers
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/publint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Publint
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
branches:
- '**'
- '!release-please--**'
merge_group:
jobs:
publint:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- run: git config --global core.symlinks true
- uses: actions/checkout@v5
with:
fetch-depth: 0
- run: corepack enable pnpm
- name: Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'pnpm'
- name: Install Deno
uses: denoland/setup-deno@v2
with:
# Should satisfy the `DENO_VERSION_RANGE` from https://github.com/netlify/edge-bundler/blob/main/node/bridge.ts#L17
deno-version: v2.2.4
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm run build:packages
- name: Publint
run: pnpm run --filter '@netlify/*' publint
4 changes: 2 additions & 2 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ jobs:
registry-url: 'https://registry.npmjs.org'
if: ${{ steps.release.outputs.releases_created }}
- name: Install Deno
uses: denoland/setup-deno@v1
uses: denoland/setup-deno@v2
with:
# Should satisfy the `DENO_VERSION_RANGE` from https://github.com/netlify/edge-bundler/blob/main/node/bridge.ts#L17
deno-version: v1
deno-version: v2.2.4
if: ${{ steps.release.outputs.releases_created }}
- run: corepack enable
- name: Install dependencies
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ jobs:
check-latest: true
- run: corepack enable
- name: Install Deno
uses: denoland/setup-deno@v1
uses: denoland/setup-deno@v2
with:
# Should satisfy the `DENO_VERSION_RANGE` from https://github.com/netlify/edge-bundler/blob/main/node/bridge.ts#L17
deno-version: v1
deno-version: v2.2.4
- name: Install
run: pnpm install
- name: Build
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@
"fast-glob": "^3.3.2",
"husky": "^9.0.11",
"lint-staged": "^15.0.0",
"netlify-cli": "^20.1.1",
"netlify-cli": "^23.9.5",
"npm-run-all2": "^6.0.0",
"p-limit": "^5.0.0",
"prettier": "^3.0.0",
"publint": "^0.3.15",
"typescript": "^5.0.0",
"vitest": "^3.0.0"
},
Expand Down
3 changes: 2 additions & 1 deletion packages/remix-adapter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"scripts": {
"prepack": "pnpm run build",
"build": "tsup-node src/index.ts src/vite/plugin.ts --format esm,cjs --dts --target node16 --clean",
"build:watch": "pnpm run build --watch"
"build:watch": "pnpm run build --watch",
"publint": "publint --strict"
},
"repository": {
"type": "git",
Expand Down
3 changes: 2 additions & 1 deletion packages/remix-edge-adapter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"build": "pnpm run build:src && pnpm run build:types",
"build:src": "tsup-node src/index.ts src/vite/plugin.ts --format esm,cjs --dts --target node16 --clean",
"build:types": "deno types > deno.d.ts",
"build:watch": "pnpm run build:src --watch"
"build:watch": "pnpm run build:src --watch",
"publint": "publint --strict"
},
"repository": {
"type": "git",
Expand Down
3 changes: 2 additions & 1 deletion packages/remix-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"scripts": {
"prepack": "pnpm run build",
"build": "tsup-node src/index.ts --format esm,cjs --dts --target node16 --clean",
"build:watch": "pnpm run build --watch"
"build:watch": "pnpm run build --watch",
"publint": "publint --strict"
},
"repository": {
"type": "git",
Expand Down
122 changes: 116 additions & 6 deletions packages/vite-plugin-react-router/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
# React Router Adapter for Netlify

The React Router Adapter for Netlify allows you to deploy your [React Router](https://reactrouter.com) app to
[Netlify Functions](https://docs.netlify.com/functions/overview/).
The React Router Adapter for Netlify allows you to deploy your [React Router](https://reactrouter.com) app to Netlify.

## How to use

To deploy a React Router 7+ site to Netlify, install this package:

```sh
npm install --save-dev @netlify/vite-plugin-react-router
npm install @netlify/vite-plugin-react-router
```

It's also recommended (but not required) to use the
Expand Down Expand Up @@ -38,6 +37,115 @@ export default defineConfig({
})
```

Your app is ready to [deploy to Netlify](https://docs.netlify.com/deploy/create-deploys/).

### Deploying to Edge Functions

By default, this plugin deploys your React Router app to
[Netlify Functions](https://docs.netlify.com/functions/overview/) (Node.js runtime). You can optionally deploy to
[Netlify Edge Functions](https://docs.netlify.com/edge-functions/overview/) (Deno runtime) instead.

First, toggle the `edge` option:

```typescript
export default defineConfig({
plugins: [
reactRouter(),
tsconfigPaths(),
netlifyReactRouter({ edge: true }), // <- deploy to Edge Functions
netlify(),
],
})
```

Second, you **must** provide an `app/entry.server.tsx` (or `.jsx`) file that uses web-standard APIs compatible with the
Deno runtime. Create a file with the following content:

> [!IMPORTANT]
>
> This file uses `renderToReadableStream` (Web Streams API) instead of `renderToPipeableStream` (Node.js API), which is
> required for the Deno runtime. You may customize your server entry file, but see below for important edge runtime
> constraints.

```tsx
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import type { AppLoadContext, EntryContext } from 'react-router'
import { ServerRouter } from 'react-router'
import { isbot } from 'isbot'
import { renderToReadableStream } from 'react-dom/server'

export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
) {
let shellRendered = false
const userAgent = request.headers.get('user-agent')

const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, {
onError(error: unknown) {
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
}
},
})
shellRendered = true

// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
await body.allReady
}

responseHeaders.set('Content-Type', 'text/html')
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
})
}
```

You may need to `npm install isbot` if you do not have this dependency.

Finally, if you have your own Netlify Functions (typically in `netlify/functions`) for which you've configured a `path`,
you must exclude those paths to avoid conflicts with the generated React Router SSR handler:

```typescript
export default defineConfig({
plugins: [
reactRouter(),
tsconfigPaths(),
netlifyReactRouter({
edge: true,
excludedPaths: ['/ping', '/api/*', '/webhooks/*'],
}),
netlify(),
],
})
```

#### Moving back from Edge Functions to Functions

To switch from Edge Functions back to Functions, you must:

1. Remove the `edge: true` option from your `vite.config.ts`
2. **Delete the `app/entry.server.tsx` file** (React Router will use its default Node.js-compatible entry)

#### Edge runtime

Before deploying to Edge Functions, review the Netlify Edge Functions documentation for important details:

- [Runtime environment](https://docs.netlify.com/build/edge-functions/api/#runtime-environment) - Understand the Deno
runtime
- [Supported Web APIs](https://docs.netlify.com/build/edge-functions/api/#supported-web-apis) - Check which APIs are
available
- [Limitations](https://docs.netlify.com/build/edge-functions/limits/) - Be aware of resource limits and constraints

### Load context

This plugin automatically includes all
Expand Down Expand Up @@ -71,7 +179,8 @@ type-safe `RouterContextProvider`. Note that this requires requires v2.0.0+ of `
For example:

```tsx
import { netlifyRouterContext } from '@netlify/vite-plugin-react-router'
import { netlifyRouterContext } from '@netlify/vite-plugin-react-router/serverless'
// NOTE: if setting `edge: true`, import from /edge ^ instead here
import { useLoaderData } from 'react-router'
import type { Route } from './+types/example'

Expand Down Expand Up @@ -101,10 +210,11 @@ To use middleware,
that this requires requires v2.0.0+ of `@netlify/vite-plugin-react-router`.

To access the [Netlify context](https://docs.netlify.com/build/functions/api/#netlify-specific-context-object)
specifically, you must import our `RouterContextProvider` instance:
specifically, you must import our `RouterContext` instance:

```tsx
import { netlifyRouterContext } from '@netlify/vite-plugin-react-router'
import { netlifyRouterContext } from '@netlify/vite-plugin-react-router/serverless'
// NOTE: if setting `edge: true`, import from /edge ^ instead here

import type { Route } from './+types/home'

Expand Down
14 changes: 12 additions & 2 deletions packages/vite-plugin-react-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"./serverless": {
"types": "./dist/serverless.d.mts",
"default": "./dist/serverless.mjs"
},
"./edge": {
"types": "./dist/edge.d.mts",
"default": "./dist/edge.mjs"
}
},
"files": [
Expand All @@ -26,8 +34,9 @@
],
"scripts": {
"prepack": "pnpm run build",
"build": "tsup-node src/index.ts --format esm,cjs --dts --target node18 --clean",
"build:watch": "pnpm run build --watch"
"build": "tsup-node",
"build:watch": "pnpm run build --watch",
"publint": "publint --strict"
},
"repository": {
"type": "git",
Expand All @@ -48,6 +57,7 @@
"isbot": "^5.0.0"
},
"devDependencies": {
"@netlify/edge-functions": "^2.11.0",
"@netlify/functions": "^3.1.9",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
Expand Down
8 changes: 6 additions & 2 deletions packages/vite-plugin-react-router/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export type { GetLoadContextFunction, RequestHandler } from './server'
export { createRequestHandler, netlifyRouterContext } from './server'
// All these `function-handler` exports are here for backwards compatibility. Now that we have separate exports
// for Function and Edge Functions, we should remove these exports in a future major version.
export type { GetLoadContextFunction, RequestHandler } from './runtimes/netlify-functions'
// Also, we never documented the `createRequestHandler` export, which has a very niche intended use case, and is not
// needed for the Edge Functions exports, so we should remove it as well.
export { createRequestHandler, netlifyRouterContext } from './runtimes/netlify-functions'

export { netlifyPlugin as default } from './plugin'
62 changes: 62 additions & 0 deletions packages/vite-plugin-react-router/src/lib/context.ts
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File extracted as-is. Only change is the TNetlifyContext generic, so that NFs and EFs can pass in their own slightly different context types.

Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { AppLoadContext } from 'react-router'
import { type RouterContext, createContext, RouterContextProvider } from 'react-router'

/**
* A function that returns the value to use as `context` in route `loader` and `action` functions.
*
* You can think of this as an escape hatch that allows you to pass environment/platform-specific
* values through to your loader/action.
*
* NOTE: v7.9.0 introduced a breaking change when the user opts in to `future.v8_middleware`. This
* requires returning an instance of `RouterContextProvider` instead of a plain object. We have a
* peer dependency on >=7.9.0 so we can safely *import* these, but we cannot assume the user has
* opted in to the flag.
*/
export type GetLoadContextFunction<TNetlifyContext> =
| ((
request: Request,
context: TNetlifyContext,
) => Promise<RouterContextProvider> | RouterContextProvider)
| ((request: Request, context: TNetlifyContext) => Promise<AppLoadContext> | AppLoadContext)

/**
* Creates a RouterContext that provides access to Netlify request context.
* Uses a Proxy to always read from the current `Netlify.context` value, which is always
* contextual to the in-flight request.
*
* @example context.get(netlifyRouterContext).geo?.country?.name
*/
export function createNetlifyRouterContext<TNetlifyContext>(): RouterContext<
Partial<TNetlifyContext>
> {
// We must use a singleton because Remix contexts rely on referential equality.
// We can't hook into the request lifecycle in dev mode, so we use a Proxy to always read from the
// current `Netlify.context` value, which is always contextual to the in-flight request.
return createContext<Partial<TNetlifyContext>>(
new Proxy(
// Can't reference `Netlify.context` here because it isn't set outside of a request lifecycle
{},
{
get(_target, prop, receiver) {
return Reflect.get(Netlify.context ?? {}, prop, receiver)
},
set(_target, prop, value, receiver) {
return Reflect.set(Netlify.context ?? {}, prop, value, receiver)
},
has(_target, prop) {
return Reflect.has(Netlify.context ?? {}, prop)
},
deleteProperty(_target, prop) {
return Reflect.deleteProperty(Netlify.context ?? {}, prop)
},
ownKeys(_target) {
return Reflect.ownKeys(Netlify.context ?? {})
},
getOwnPropertyDescriptor(_target, prop) {
return Reflect.getOwnPropertyDescriptor(Netlify.context ?? {}, prop)
},
},
),
)
}

Loading
Loading