Skip to content

Conversation

@serhalp
Copy link
Member

@serhalp serhalp commented Oct 28, 2025

Summary

This PR adds opt-in support for deploying React Router 7 apps to Netlify Edge Functions (Deno runtime) via the edge: boolean plugin option. Previously, the plugin only supported Netlify Functions (Node.js runtime).

Toward FRB-1519

Changes

  • Add edge?: boolean option to Vite plugin factory
  • When edge is enabled:
    • Configure Vite SSR build with target: 'webworker' and other Deno-compatible build config
    • Generate an edge function in .netlify/v1/edge-functions/, instead of a function .netlify/v1/functions/
  • Add excludedPaths?: string[] option to allow users to exclude paths from the React Router handler. Required when using edge: true and the project also has its own Netlify Functions with a configured path, otherwise only the RR handler runs on all dynamic paths.

Testing

  • Unskip the RR7 edge e2e suite
  • Add a few tests I noticed were missing for edge (or vice versa)
  • I thought I'd add tests to ensure prerendering works while I was here (this was added in RR7 but did not exist in Remix 2)... but unfortunately I discovered that this does not in fact work. I left the tests and marked them as failing.
  • You can also link this locally with https://github.com/netlify/react-router-template/ and confirm it works ✅

Documentation

I added complete documentation for opting in (and back out of) edge deployment.

Implementation

Edge support is more involved than regular serverless functions for three main reasons:

  1. EFs don't have a preferStatic option like NFs.
    • Instead, to avoid needlessly running compute to serve static assets, we must populate the EF's excludedPath with all published client assets. This skips the EF entirely for these paths.
  2. Although there is a precise processing order for EFs and for NFs, all EFs run before any NF.
    • This means that since our generated EF runs on path: '/*', if a user has their own NF with path: '/foo', our EF will run first and render a response from React Router and the user's NF will never run.
    • To that end, this PR adds and documents an excludedPaths?: [] option to the plugin.
    • There were various alternative solutions here, but this seemed like a reasonable approach for now.
  3. React Router 7 contains only a single default built-in server entry and this assumes a Node.js environment.
    • Unfortunately, this means when using edge: true the user must include their own app/entry.server.tsx that uses renderToReadableStream instead of renderToPipeableStream.
    • There may be some magic we can implement here to make this work out of the box. It didn't seem worth it to me to fight against the framework here. We can also always add this later.

@netlify
Copy link

netlify bot commented Oct 28, 2025

Deploy Preview for remix-edge ready!

Name Link
🔨 Latest commit fafc121
🔍 Latest deploy log https://app.netlify.com/projects/remix-edge/deploys/6904b0e24fcdce0007bdc3f3
😎 Deploy Preview https://deploy-preview-562--remix-edge.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link

netlify bot commented Oct 28, 2025

Deploy Preview for remix-serverless ready!

Name Link
🔨 Latest commit fafc121
🔍 Latest deploy log https://app.netlify.com/projects/remix-serverless/deploys/6904b0e2ea845c0007edc7b8
😎 Deploy Preview https://deploy-preview-562--remix-serverless.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions github-actions bot added the type: feature code contributing to the implementation of a feature and/or user facing functionality label Oct 28, 2025
@serhalp serhalp force-pushed the serhalp/frb-1519-support-edge-rendering-with-react-router-7 branch from 5b9a00c to b552eb6 Compare October 29, 2025 00:11
@serhalp serhalp force-pushed the serhalp/frb-1519-support-edge-rendering-with-react-router-7 branch from 744b059 to 7fde98c Compare October 30, 2025 13:55
When the globally installed deno cli isn't a supported version, the build automatically tries to
install a local binary. Unfortunately when running multiple builds concurrently on the same machine
with default configuration, there's a race condition where a binary tries to get written to the same
path where one is currently running, leading to an OS error.

Just install the correct version globally.

See (internal link) https://netlify.slack.com/archives/C03ETTLQ9BP/p1761826639264259
it turns out...

> By default, tag-based purges apply to all of the site’s deploys. To target a specific deploy,
> specify one or more of the following [...]

from https://docs.netlify.com/build/caching/caching-overview/#use-a-function-with-the-purgecache-helper-to-purge-by-cache-tag

Whoops. So concurrent tests were conflicting with one another's expectations.
@serhalp serhalp force-pushed the serhalp/frb-1519-support-edge-rendering-with-react-router-7 branch from 7fde98c to 78d1afe Compare October 30, 2025 14:03
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.

Copy link
Member Author

@serhalp serhalp Oct 30, 2025

Choose a reason for hiding this comment

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

Most of this file was extracted as is. The only change is the context generic and the runtimeName arg.

Comment on lines +20 to +24
"./function-handler": {
"types": "./dist/function-handler.d.mts",
"default": "./dist/function-handler.mjs"
},
"./edge-function-handler": {
Copy link
Member Author

Choose a reason for hiding this comment

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

Check out the first lines of FUNCTION_HANDLER and EDGE_FUNCTION_HANDLER to understand what these are for. They're the runtime entry points.

(This should probably always have been an explicit separate export, honestly.)

> 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.

Copy link
Member Author

Choose a reason for hiding this comment

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

this whole fixture is pretty much identical to the react-router-serverless-site one. I just removed durable, added app/entry.server.tsx, and passed in edge: true and excludedPaths in the vite config.

serhalp and others added 4 commits October 30, 2025 16:35
When a test retries, it reuses the deployed fixture and since the cache state is preserved from the
previous run, the initial state is incorrect.

Attaching a unique query string per run isolated each run.
@serhalp serhalp marked this pull request as ready for review October 30, 2025 20:53
@serhalp serhalp requested a review from a team as a code owner October 30, 2025 20:53
There appears to still be some flakiness that I cannot reproduce when running `.only` on a test,
which implies that there's somethhing conflicting somehow across tests running concurrently.
export type { GetLoadContextFunction, RequestHandler } from './server'
export { createRequestHandler, netlifyRouterContext } from './server'
export type { GetLoadContextFunction, RequestHandler } from './function-handler'
export { createRequestHandler, netlifyRouterContext } from './function-handler'
Copy link
Contributor

@pieh pieh Oct 31, 2025

Choose a reason for hiding this comment

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

This is a bit confusing to me. We only export serverless context here. But if user opt into using edge: true, the context we will be providing will be this one for edge and not serverless

export const netlifyRouterContext = createNetlifyRouterContext<NetlifyEdgeContext>()

We don't document that for edge: true case users should be importing from @netlify/vite-plugin-react-router/edge-function-handler to get the "right" context right now

so when users would try to use it as what's documented (and what's added in edge fixture here

import { netlifyRouterContext } from '@netlify/vite-plugin-react-router'
) in their routes the context would be different than one we provide in https://github.com/netlify/remix-compute/blob/serhalp/frb-1519-support-edge-rendering-with-react-router-7/packages/vite-plugin-react-router/src/edge-function-handler.ts#L30-L46

I can see this still ~working (I did not test this, just theory craft given that things generally seems to be working) because of the proxy we did for dev/initial value as far as in fact using EF context (and not getting null / undefined otherwise) (?) I'm not sure if we feel ok relying on this in prod (if this is in fact what happens)

But the types would be potentially incompatible if we leave things as-is?

I do think it makes total sense to preserve this export from main to not generate breaking change for users, but it would seem to me we should document that if you opt into edge - you should import netlifyRouterContext from edge specific module (or add export from main named as netlifyEdgeRouterContext or something like that for ease of use)?

I'm not sure if other exports here are meant to be used by users directly, but if they are - then same thing applies to other exports.

I'm not sure there is any trickery here possible to automate this and automagically give users correct variant of those exports and this seems to me like it might need to be part of docs for edge usage to be correct?

const entries = await readdir(clientDir, { withFileTypes: true })
const excludedPath = [
'/.netlify/*',
...entries.map((entry) => (entry.isDirectory() ? `/${entry.name}/*` : `/${entry.name}`)),
Copy link
Contributor

Choose a reason for hiding this comment

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

If we only check top-level files and dirs in build/client, then at least in my case it would exclude /assets/* which means users won't be allowed to have their own routes that start with /assets/ segment - given this is in new code path for edge rendering, this at least won't cause regressions, but it might lead to very confusing problems for users opting to use edge rendering

Copy link
Member Author

Choose a reason for hiding this comment

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

interesting... that seems pretty reasonable to me, honestly 😄. you put assets in assets/ to have them served under assets/... I think doing extra things with that path is playing with fire!

... ah but wait! You're saying actually there's nothing special about the dir assets/. A project could have foo/bar.jpg and we'd exclude all of foo/* from the function. hmm.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah ok, I guess we should enumerate every file in the tree and specifically exclude it 😢.

Copy link
Contributor

Choose a reason for hiding this comment

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

... ah but wait! You're saying actually there's nothing special about the dir assets/. A project could have foo/bar.jpg and we'd exclude all of foo/* from the function. hmm.

I don't know enough about react-router - I'm not sure if it's possible to inject "random" files to be available (kind of like public directory in Next.js) or wether it would only ever be assets directory in practice?

If it would only ever be assets, then I think it's reasonable enough to accept tradeoff here about not overloading assets path segment by users / playing with fire and to not explode number of excluded paths in configuration that will happen when we need to list all the individual files, but I will leave decision to you here. Just wanted to flag this as potential problem

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature code contributing to the implementation of a feature and/or user facing functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants