Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
27 changes: 23 additions & 4 deletions packages/remix-edge-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,31 @@ However, if you are using **Remix Vite**, you can instead deploy your existing s

```js
// vite.config.js
import { vitePlugin as remix } from "@remix-run/dev";
import { netlifyPlugin } from "@netlify/remix-edge-adapter/plugin";
import { vitePlugin as remix } from '@remix-run/dev'
import { netlifyPlugin } from '@netlify/remix-edge-adapter/plugin'

export default defineConfig({
plugins: [remix(), netlifyPlugin(),
});
plugins: [remix(), netlifyPlugin()],
})
```

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 Remix SSR handler, which would otherwise run on all dynamic
paths:

```js
// vite.config.js
import { vitePlugin as remix } from '@remix-run/dev'
import { netlifyPlugin } from '@netlify/remix-edge-adapter/plugin'

export default defineConfig({
plugins: [
remix(),
netlifyPlugin({
excludedPaths: ['/ping', '/api/*', '/webhooks/*'],
}),
],
})
```

3. Add an `app/entry.jsx` (.tsx if using TypeScript) with these contents:
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
30 changes: 23 additions & 7 deletions packages/remix-edge-adapter/src/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export default createRequestHandler({

// This is written to the edge functions directory. It just re-exports
// the compiled entrypoint, along with the Netlify function config.
function generateEdgeFunction(handlerPath: string, exclude: Array<string> = []) {
function generateEdgeFunction(handlerPath: string, excludedPath: Array<string>) {
return /* js */ `
export { default } from "${handlerPath}";

Expand All @@ -94,7 +94,7 @@ function generateEdgeFunction(handlerPath: string, exclude: Array<string> = [])
generator: "${name}@${version}",
cache: "manual",
path: "/*",
excludedPath: ${JSON.stringify(exclude)},
excludedPath: ${JSON.stringify(excludedPath)},
};`
}

Expand Down Expand Up @@ -125,7 +125,21 @@ const getEdgeFunctionHandlerModuleId = async (root: string, isHydrogenSite: bool
return findUserEdgeFunctionHandlerFile(root)
}

export function netlifyPlugin(): Plugin {
export interface NetlifyPluginOptions {
/**
* Paths to exclude from being handled by the Remix handler.
*
* @IMPORTANT If you have your own Netlify Functions running on custom `path`s, you
* must exclude those paths here to avoid conflicts.
*
* @type {string[]}
* @default []
*/
excludedPaths?: string[]
}

export function netlifyPlugin(options: NetlifyPluginOptions = {}): Plugin {
const additionalExcludedPaths = options.excludedPaths ?? []
let resolvedConfig: ResolvedConfig
let currentCommand: string
let isSsr: boolean | undefined
Expand Down Expand Up @@ -264,23 +278,25 @@ export function netlifyPlugin(): Plugin {
async writeBundle() {
// Write the server entrypoint to the Netlify functions directory
if (currentCommand === 'build' && isSsr) {
const exclude: Array<string> = ['/.netlify/*']
const excludedPath: Array<string> = ['/.netlify/*']
try {
// Get the client files so we can skip them in the edge function
const clientDirectory = join(resolvedConfig.build.outDir, '..', 'client')
const entries = await readdir(clientDirectory, { withFileTypes: true })
for (const entry of entries) {
// With directories we don't bother to recurse into it and just skip the whole thing.
if (entry.isDirectory()) {
exclude.push(`/${entry.name}/*`)
excludedPath.push(`/${entry.name}/*`)
} else if (entry.isFile()) {
exclude.push(`/${entry.name}`)
excludedPath.push(`/${entry.name}`)
}
}
} catch {
// Ignore if it doesn't exist
}

excludedPath.push(...additionalExcludedPaths)

const edgeFunctionsDirectory = join(resolvedConfig.root, NETLIFY_EDGE_FUNCTIONS_DIR)

await mkdir(edgeFunctionsDirectory, { recursive: true })
Expand All @@ -290,7 +306,7 @@ export function netlifyPlugin(): Plugin {

await writeFile(
join(edgeFunctionsDirectory, EDGE_FUNCTION_FILENAME),
generateEdgeFunction(relativeHandlerPath, exclude),
generateEdgeFunction(relativeHandlerPath, excludedPath),
)
}
},
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
Loading