Skip to main content
Frameworks

React Router on Easel

Deploy full-stack React Router 7 and 8 applications to Easel with server rendering, streaming, loaders, actions, middleware, static prerendering, and SPA support.

At a glance

Support levelProduction-ready
Supported versionsReact Router 7 and 8
ModeFramework Mode
RenderingStatic, SPA, SSR, streaming, and prerendering
Server runtimeNode.js
Required preset@vercel/react-router
Automatic detectionYes
Most recently tested withReact Router 8.3.x and 7.18.x

React Router 8 is a non-breaking framework upgrade from React Router 7, but it raises the minimum versions of Node.js, React, and Vite. Existing React Router 7 applications can deploy without upgrading.

Version requirements

React Router 8

React Router 8 requires:

  • Node.js 22.22.0 or newer
  • React 19.2.7 or newer
  • Vite 7 or newer
  • An ESM project

React Router 8 enables the behaviors previously introduced through React Router 7’s future.v8_* flags by default.

React Router 7

React Router 7 supports older Node.js, React, and Vite baselines. Some newer framework behaviors, including stable middleware behavior, require future flags depending on the version you use.

For the most consistent migration path to React Router 8, enable the available future.v8_* flags and resolve any compatibility issues before upgrading.

Deploy a React Router application

Easel supports React Router applications using Framework Mode.

Install the deployment preset:

npm install -D @vercel/react-router

Add the preset to react-router.config.ts:

react-router.config.ts
import type { Config } from "@react-router/dev/config";
import { vercelPreset } from "@vercel/react-router/vite";

export default {
  presets: [vercelPreset()],
} satisfies Config;

Then create a project and connect the project’s Git repository, or deploy with the CLI:

easel deploy

Easel detects the React Router project, runs its production build, and provisions the static and server resources described by the generated deployment output.

You can override the install command, build command, root directory, Node.js version, and environment variables in your project settings.

Why run React Router on Easel?

Easel provides a Vercel-compatible React Router deployment path with visible infrastructure, portable application conventions, and integrated edge services.

A React Router deployment includes:

  • Immutable browser assets served through the Easel CDN
  • Regional functions for server rendering, loaders, and actions
  • Streaming HTTP responses
  • Route-aware server bundles
  • CDN caching controlled through standard response headers
  • Preview deployments for branches and pull requests
  • Built-in logs, metrics, traces, WAF, and attack protection

You can inspect deployed functions, request execution, cache behavior, and resource usage from the Easel dashboard.

What Easel deploys

The React Router preset exposes the application’s route structure and generates the deployment output Easel consumes.

React Router outputEasel resource
Browser JavaScript and CSSCDN assets
Public filesCDN assets
Prerendered pagesCDN assets
Server-rendered routesEasel Functions
Loaders and actionsEasel Functions
Resource routesEasel Functions
Server middlewareApplication function pipeline
Streaming responsesStreaming function responses
SPA fallbackCDN routing configuration

The preset allows server bundles to be split according to the application’s route structure rather than placing the entire server application in one undifferentiated function bundle.

Function CPU, memory, and duration remain controlled by Easel project settings and framework route metadata (such as maxDuration). Functions run in US East.

Supported features

FeatureSupportNotes
Framework ModeSupportedUses the React Router Vite plugin
React Router 8SupportedRequires the React Router 8 runtime baseline
React Router 7SupportedFuture flags may be required for newer behavior
Server-side renderingSupportedDynamic document requests run in Easel Functions
Client-side navigationSupportedData requests are handled by the application function
LoadersSupportedRun during document and applicable client-navigation requests
ActionsSupportedHandles mutations and form submissions
Nested routesSupportedIncludes nested layouts and data loading
Route error boundariesSupportedFramework error responses are preserved
StreamingSupportedIncludes Suspense and deferred route data
Resource routesSupportedReturn JSON, files, feeds, and other non-UI responses
Server middlewareSupportedStable in v8. Version-dependent setup in v7
Client middlewareSupportedRuns in the browser during client navigations
Route contextSupportedIncludes type-safe RouterContextProvider behavior
Static prerenderingSupportedGenerated pages deploy to the CDN
SPA modeSupportedDeploys a client-rendered application with route fallback
HTTP redirectsSupportedIncludes redirects returned by loaders, actions, and middleware
Response headersSupportedIncludes route headers exports
Cookie sessionsSupportedStandard Cookie and Set-Cookie headers are preserved
File uploadsSupportedSubject to function request and duration limits
OpenTelemetrySupportedInstrument application server code using standard SDKs
Custom server entrySupportedMust expose the expected Web API request handler
WebSocketsNot supportedFunction routes use HTTP request-response semantics
Persistent local storageNot supportedFunction filesystems are ephemeral

Server rendering

Server rendering is enabled by default in Framework Mode.

A route can load data on the server and pass it to the component through generated route types:

app/routes/product.tsx
import type { Route } from "./+types/product";

export async function loader({ params }: Route.LoaderArgs) {
  const product = await getProduct(params.productId);

  if (!product) {
    throw new Response("Not found", {
      status: 404,
    });
  }

  return {
    product,
  };
}

export default function Product({
  loaderData,
}: Route.ComponentProps) {
  return (
    <main>
      <h1>{loaderData.product.name}</h1>
      <p>{loaderData.product.description}</p>
    </main>
  );
}

For an initial document request, Easel invokes the application function and streams the rendered response to the client.

After hydration, React Router can request loader data without rerendering the complete document. Those data requests are routed to the same deployed application runtime.

Actions and forms

React Router actions handle mutations and form submissions:

app/routes/new-project.tsx
import {
  Form,
  redirect,
} from "react-router";

import type { Route } from "./+types/new-project";

export async function action({
  request,
}: Route.ActionArgs) {
  const formData = await request.formData();

  const project = await createProject({
    name: String(formData.get("name")),
  });

  return redirect(`/projects/${project.id}`);
}

export default function NewProject() {
  return (
    <Form method="post">
      <label>
        Project name
        <input name="name" required />
      </label>

      <button type="submit">
        Create project
      </button>
    </Form>
  );
}

The browser submits the form to the Easel Function running the React Router server application. Redirects, validation responses, cookies, and error boundaries continue through the framework’s normal response flow.

Streaming

React Router can begin returning a document before all asynchronous data is available.

A loader can return unresolved promises:

app/routes/dashboard.tsx
import {
  Await,
} from "react-router";

import { Suspense } from "react";
import type { Route } from "./+types/dashboard";

export async function loader() {
  return {
    account: await getAccount(),
    activity: getRecentActivity(),
  };
}

export default function Dashboard({
  loaderData,
}: Route.ComponentProps) {
  return (
    <main>
      <h1>{loaderData.account.name}</h1>

      <Suspense fallback={<p>Loading activity…</p>}>
        <Await resolve={loaderData.activity}>
          {(activity) => (
            <ActivityList activity={activity} />
          )}
        </Await>
      </Suspense>
    </main>
  );
}

Easel forwards response chunks as React Router produces them rather than waiting for the entire render to finish.

The function remains active until the stream closes or the invocation reaches its maximum duration.

Middleware

React Router middleware can run before and after the matched route’s handlers.

Use middleware for authentication, request context, logging, instrumentation, response headers, and session handling.

app/routes/dashboard.tsx
import {
  createContext,
  redirect,
} from "react-router";

import type { Route } from "./+types/dashboard";

type User = {
  id: string;
  email: string;
};

export const userContext = createContext<User>();

const requireUser: Route.MiddlewareFunction =
  async ({ request, context }, next) => {
    const user = await authenticateRequest(request);

    if (!user) {
      throw redirect("/login");
    }

    context.set(userContext, user);

    const response = await next();
    response.headers.set("X-Authenticated", "true");

    return response;
  };

export const middleware: Route.MiddlewareFunction[] = [
  requireUser,
];

export async function loader({
  context,
}: Route.LoaderArgs) {
  return {
    user: context.get(userContext),
  };
}

Server middleware runs inside the deployed React Router application function. It is distinct from Easel’s platform-level edge request pipeline and WAF.

Middleware in React Router 8

Middleware behavior is enabled by default in React Router 8.

No future.v8_middleware flag is required.

Middleware in React Router 7

React Router 7 versions that expose middleware behind a future flag require it in react-router.config.ts:

react-router.config.ts
import type { Config } from "@react-router/dev/config";
import { vercelPreset } from "@vercel/react-router/vite";

export default {
  presets: [vercelPreset()],
  future: {
    v8_middleware: true,
  },
} satisfies Config;

The exact future flags available depend on the installed React Router 7 release.

Request context

Use React Router’s context APIs to make request-specific dependencies available to middleware, loaders, and actions.

A custom server entry can seed the request context before React Router handles the request:

app/entry.server.ts
import {
  createRequestHandler,
  RouterContextProvider,
} from "react-router";

import { userContext } from "./context";
import * as build from "virtual:react-router/server-build";

const handleRequest = createRequestHandler(build);

export default async function handler(
  request: Request,
) {
  const context = new RouterContextProvider();

  const user = await authenticateRequest(request);

  if (user) {
    context.set(userContext, user);
  }

  return handleRequest(request, context);
}

Use request context for dependencies that belong to one invocation, such as authenticated users, database transaction handles, request identifiers, or platform metadata.

Do not use module-level mutable state as a substitute for request context. A function instance may process multiple requests during its lifetime.

Resource routes

A route that does not export a default component can return a non-HTML response:

app/routes/api.health.ts
import type { Route } from "./+types/api-health";

export async function loader({
  request,
}: Route.LoaderArgs) {
  return Response.json({
    ok: true,
    timestamp: new Date().toISOString(),
  });
}

Resource routes can return:

  • JSON APIs
  • XML and RSS feeds
  • Generated files
  • Redirects
  • Streaming responses
  • Webhook acknowledgements

They run within the same function execution model as document loaders and actions.

Response caching

React Router does not impose one application caching policy. Routes can control browser and CDN behavior using standard HTTP response headers.

Export a headers function from a route:

app/routes/products.tsx
import type { Route } from "./+types/products";

export function headers(
  _: Route.HeadersArgs,
) {
  return {
    "Cache-Control":
      "public, s-maxage=60, stale-while-revalidate=300",
  };
}

export async function loader() {
  return {
    products: await listProducts(),
  };
}

In this example:

  • Browsers follow the browser caching directives in the response
  • Easel’s CDN can reuse the response for 60 seconds
  • A stale response can remain available while it is refreshed for up to five minutes

Do not publicly cache responses containing user-specific or session-specific data.

For personalized routes, use:

Cache-Control: private, no-store

A route’s caching policy should account for every header, cookie, query parameter, and authentication state that can change its response.

See Cache responses at the edge and Invalidate and revalidate cached content.

Static prerendering

React Router can generate selected routes during the production build.

Configure prerendered paths in react-router.config.ts:

react-router.config.ts
import type { Config } from "@react-router/dev/config";
import { vercelPreset } from "@vercel/react-router/vite";

export default {
  presets: [vercelPreset()],
  async prerender() {
    return [
      "/",
      "/about",
      "/pricing",
    ];
  },
} satisfies Config;

Easel deploys the generated HTML and browser assets to the CDN.

Prerendered routes do not require a function invocation unless the application later navigates to server-backed data or another dynamic route.

React Router 8 uses its Vite environment-based prerendering flow. React Router 7 may use a version-dependent implementation, but the resulting static output is deployed through the same Easel path.

SPA mode

Set ssr to false to deploy a client-rendered single-page application:

react-router.config.ts
import type { Config } from "@react-router/dev/config";
import { vercelPreset } from "@vercel/react-router/vite";

export default {
  ssr: false,
  presets: [vercelPreset()],
} satisfies Config;

Easel deploys the generated browser application to the CDN and routes application paths to its SPA entry document.

In SPA mode:

  • Route components run in the browser
  • Server loaders and actions are unavailable
  • clientLoader and clientAction remain available
  • Static application assets are served without invoking a function

Use the Vite guide instead when the application uses React Router only in Data Mode or Declarative Mode and does not use the Framework Mode build system.

Route module splitting

React Router can split route-module exports into smaller browser chunks so data and middleware code can load separately from component code.

React Router 8 enables its updated route-module behavior through top-level configuration and defaults inherited from the v8 release.

In compatible React Router 7 versions, the behavior may be enabled through a future flag:

react-router.config.ts
import type { Config } from "@react-router/dev/config";
import { vercelPreset } from "@vercel/react-router/vite";

export default {
  presets: [vercelPreset()],
  future: {
    v8_splitRouteModules: true,
  },
} satisfies Config;

This is primarily a browser-bundle optimization. Easel also uses the route information exposed by the deployment preset when constructing server bundles.

Sessions and cookies

React Router’s cookie and session APIs work through standard HTTP headers.

app/sessions.server.ts
import {
  createCookieSessionStorage,
  redirect,
} from "react-router";

const sessions = createCookieSessionStorage({
  cookie: {
    name: "__session",
    httpOnly: true,
    path: "/",
    sameSite: "lax",
    secrets: [process.env.SESSION_SECRET!],
    secure: process.env.NODE_ENV === "production",
  },
});

export async function action({
  request,
}: {
  request: Request;
}) {
  const session = await sessions.getSession(
    request.headers.get("Cookie"),
  );

  session.set("userId", "user_123");

  return redirect("/dashboard", {
    headers: {
      "Set-Cookie":
        await sessions.commitSession(session),
    },
  });
}

Cookie-backed sessions do not require server storage, but their contents are sent with each applicable request.

Use a database or durable key-value store for session state that is too large for a cookie, must be revoked centrally, or must be shared with other services.

Environment variables

Configure environment variables in the Easel dashboard or CLI.

Server-side variables are available to loaders, actions, middleware, resource routes, and server entry code:

const databaseUrl = process.env.DATABASE_URL;

Do not expose process.env directly to browser code.

When browser code needs public configuration, return an explicit allowlist from the root loader:

app/root.tsx
import type { Route } from "./+types/root";

export async function loader() {
  return {
    env: {
      PUBLIC_API_ORIGIN:
        process.env.PUBLIC_API_ORIGIN,
    },
  };
}

export default function App({
  loaderData,
}: Route.ComponentProps) {
  return (
    <html lang="en">
      <head />
      <body>
        <Outlet />
        <script
          dangerouslySetInnerHTML={{
            __html: `window.ENV = ${JSON.stringify(
              loaderData.env,
            )}`,
          }}
        />
        <Scripts />
      </body>
    </html>
  );
}

Only expose values that are safe for every visitor to read.

Use separate environment values for production, preview, and development deployments when necessary.

OpenTelemetry

React Router server code runs in the Node.js application function and can use standard OpenTelemetry instrumentation.

Initialize instrumentation before handling application requests:

import { NodeSDK } from "@opentelemetry/sdk-node";

const sdk = new NodeSDK({
  serviceName: "react-router-app",
});

sdk.start();

Easel can ingest application telemetry alongside function logs, request metrics, and platform traces.

Avoid starting a new SDK for each request. Initialize reusable instrumentation at module scope and attach request-specific data through spans or context.

Project configuration

Easel detects conventional React Router Framework Mode settings automatically:

SettingDefault
Install commandDetected from the package manager
Build commandPackage script or react-router build
Development commandPackage script or react-router dev
Static assetsDetected from the framework build
Server outputDetected from the deployment preset
Node.js versionProject or platform default

For React Router 8, select a Node.js version compatible with its required baseline.

For monorepos, set the project root to the directory containing:

  • The application’s package.json
  • react-router.config.ts
  • The Vite configuration
  • The application routes

Local development

Continue using React Router’s normal development server:

npm run dev

The framework dev server provides routing, loaders, actions, server rendering, middleware, and hot module replacement.

Use Easel preview deployments to validate platform-specific behavior, including:

  • The production server bundle
  • Route-aware function packaging
  • CDN caching
  • Streaming through the production network
  • Environment variables
  • Function limits (including duration)
  • WAF and security rules

A preview deployment follows the same build and deployment path as production and receives an immutable URL.

Upgrading from React Router 7 to 8

React Router 8 is designed as a non-breaking application API upgrade for projects that adopted the v8 future behavior in React Router 7.

Before upgrading:

  1. Upgrade the application to the latest React Router 7 release
  2. Enable supported future.v8_* flags
  3. Resolve deprecations and behavior changes
  4. Upgrade Node.js to at least 22.22.0
  5. Upgrade React to at least 19.2.7
  6. Upgrade Vite to version 7 or newer
  7. Confirm the project is ESM-compatible
  8. Upgrade the React Router packages together
  9. Remove obsolete future.v8_* flags
  10. Create an Easel preview deployment and test dynamic routes

React Router 8 makes the v8 middleware, request-passthrough, Vite environment, and related future behavior the default.

A typical package upgrade is:

npm install react-router@^8 \
  @react-router/dev@^8

Upgrade any React Router adapters or deployment presets to versions that explicitly support React Router 8.

Known limitations

WebSockets

React Router routes deployed to Easel Functions cannot accept long-lived WebSocket connections.

Use an external WebSocket provider or a dedicated persistent service for bidirectional connections.

Streaming HTTP responses and Server-Sent Events are separate protocols and may be used within the function’s execution limits.

Local filesystem

The function filesystem is ephemeral. Files written during one invocation are not guaranteed to exist during a later invocation or on another function instance.

Use object storage, a database, or another durable service for persistent data.

Long-running work

Loaders, actions, and middleware run within the function’s maximum duration.

Do not rely on untracked work continuing after a response is returned. Use a durable queue or background-job system for work that requires retries, may exceed the function limit, or must survive instance termination.

Custom Node.js servers

Easel deploys the Web API-compatible React Router request handler generated by the framework integration. An application that requires ownership of a persistent HTTP server, raw TCP sockets, or process-level connection handling may require a dedicated service rather than Easel Functions.

Troubleshooting

The build cannot import React Router 8

Confirm that the project uses:

  • Node.js 22.22.0 or newer
  • React 19.2.7 or newer
  • Vite 7 or newer
  • ESM-compatible configuration

React Router 8 does not support older runtime baselines.

Middleware does not run in React Router 7

Confirm that the installed React Router 7 release supports middleware and that future.v8_middleware is enabled.

Also remember that a client-side navigation with no server loader or action may not make a server request. Server middleware only runs when the navigation reaches the server.

A loader runs locally but not in production

Confirm that SSR has not been disabled with:

ssr: false

SPA mode does not deploy server loaders or actions.

A route returns stale content

Inspect the route’s Cache-Control header. Do not apply shared CDN caching to authenticated or personalized responses unless the cache key safely distinguishes every response variant.

Confirm that:

  • The response includes Set-Cookie
  • The cookie’s domain and path match the request
  • secure cookies are tested over HTTPS
  • The cookie does not exceed browser size limits
  • An ancestor middleware does not replace the response headers

Confirm that the application is built with React Router’s SPA mode rather than as an unrelated static Vite output. The framework deployment output includes the routing fallback Easel needs for application paths.

The application works on React Router 7 but fails after upgrading

Check the React Router 8 runtime baselines first. Then remove obsolete future flags and verify that deployment adapters and Vite plugins support v8.

Run the upgrade through a preview deployment before promoting it to production.

Compatibility policy

Easel tests its React Router integration with representative Framework Mode applications covering:

  • React Router 7 and 8
  • Server rendering
  • Client navigations
  • Loaders and actions
  • Streaming
  • Middleware
  • Request context
  • Resource routes
  • Static prerendering
  • SPA mode
  • Sessions and cookies
  • Response caching
  • Route error boundaries

Stable releases may work beyond the versions listed at the top of this page, but those versions are the most recently verified baselines.

For a newly released React Router version, create a preview deployment before upgrading the production application.

Next steps