React Router v8

This guide starts with an existing React Router v8 framework-mode app.

Install Nitro and Workflow SDK

pnpm add nitro workflow

This integration requires Nitro v3.

Use a shared build directory

Set an explicit build directory in your React Router config:

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

export default {
  ssr: true,
  buildDirectory: "build",
} satisfies Config;

React Router will place browser assets in build/client. Nitro will place the runnable server in build/server.

Create the React Router server handler

Create server/ssr.ts:

server/ssr.ts
import { createRequestHandler } from "react-router";

export default {
  fetch: createRequestHandler(
    () => import("virtual:react-router/server-build"),
    import.meta.env.MODE,
  ),
};

This adapts React Router's generated server build to the Fetch API handler Nitro expects.

Configure Vite

Update vite.config.ts:

vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { nitro } from "nitro/vite";
import { defineConfig } from "vite";
import { workflow } from "workflow/vite";
import reactRouterConfig from "./react-router.config";

export default defineConfig({
  plugins: [
    reactRouter(),
    nitro({
      serverDir: "./server",
      output: {
        dir: reactRouterConfig.buildDirectory,
        serverDir: `${reactRouterConfig.buildDirectory}/server`,
        publicDir: `${reactRouterConfig.buildDirectory}/client`,
      },
    }),
    workflow({ dirs: ["workflows"] }),
  ],
  environments: {
    ssr: {
      build: {
        rollupOptions: {
          input: "./server/ssr.ts",
        },
      },
    },
  },
});

Keep dirs: ["workflows"] so subsequent builds do not scan generated files under build. Place reactRouter() before nitro() in the plugin array.

Create a workflow

Create workflows/greeting.ts:

workflows/greeting.ts
export async function greetingWorkflow(name: string) {
  "use workflow";

  return greet(name);
}

async function greet(name: string) {
  "use step";

  return `Hello, ${name}!`;
}

Start the workflow from a Nitro route

Create server/routes/api/greeting.post.ts:

server/routes/api/greeting.post.ts
import { defineHandler } from "nitro";
import { start } from "workflow/api";
import { greetingWorkflow } from "../../../workflows/greeting";

export default defineHandler(async (event) => {
  const { name } = (await event.req.json()) as { name: string };
  const run = await start(greetingWorkflow, [name]);

  return { runId: run.runId };
});

React Router continues to handle your application routes. Nitro handles this server route at POST /api/greeting, as well as Workflow SDK's internal routes.

Run the app

Start the development server:

pnpm vite dev

Then start a workflow:

curl -X POST \
  -H "content-type: application/json" \
  -d '{"name":"Workflow"}' \
  http://localhost:3000/api/greeting

Build and start the production server:

pnpm vite build
node ./build/server/index.mjs

You can inspect local runs with pnpm workflow web.

Troubleshooting

React Router pages return 404

Check that the ssr environment input points to ./server/ssr.ts.

A second build tries to compile files under build/server

Use workflow({ dirs: ["workflows"] }), remove the existing build directory once, and rebuild.

vite build finishes output but does not exit

Use workflow@5.0.0 or later with Nitro v3.