Migrate @workflow/web from Next.js to React Router v7#1005
Conversation
🦋 Changeset detectedLatest commit: ca37040 The changes in this PR will be included in the next version bump. This PR includes changesets to release 15 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests🌍 Community Worlds (41 failed)turso (41 failed):
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 🌍 Community Worlds
✅ 📋 Other
|
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express | Nitro workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro | Express Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express | Nitro Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
|
This stack of pull requests is managed by Graphite. Learn more about stacking. |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
@workflow/web from Next.js to React Router v7
There was a problem hiding this comment.
Pull request overview
This pull request migrates the @workflow/web package from Next.js App Router to React Router v7 framework mode (Vite-based), significantly reducing the dependency footprint and simplifying the CLI integration. The migration eliminates the ~300MB next package and replaces the child process architecture with an in-process Express server that the CLI can import directly.
Changes:
- Framework migration from Next.js 16 to React Router v7.13.0 with Vite bundler
- RPC transport switched from JSON (Next.js server actions) to CBOR encoding to preserve binary data types
- CLI integration simplified from child process spawning to in-process Express server import
- URL state management migrated from
nuqsto React Router's nativeuseSearchParams
Reviewed changes
Copilot reviewed 74 out of 85 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/web/vite.config.ts | Vite configuration for React Router with Tailwind and path aliases |
| packages/web/react-router.config.ts | React Router framework configuration |
| packages/web/tsconfig.json | Updated TypeScript config for React Router types and ES2022 target |
| packages/web/package.json | Dependencies updated: removed Next.js/SWR/nuqs, added React Router/Express/CBOR |
| packages/web/server/app.ts | Express app with React Router request handler |
| packages/web/server.js | Production server entry point that CLI imports |
| packages/web/app/routes.ts | File-based routing configuration |
| packages/web/app/root.tsx | Root layout migrated from Next.js layout pattern |
| packages/web/app/routes/*.tsx | Route components migrated from Next.js pages |
| packages/web/app/routes/api.rpc.tsx | CBOR-based RPC API endpoint replacing server actions |
| packages/web/app/routes/api.stream.$streamId.tsx | Stream data resource route |
| packages/web/app/lib/rpc-client.ts | Client-side CBOR RPC implementation |
| packages/web/app/lib/url-state.ts | URL state hooks using React Router's useSearchParams |
| packages/web/app/lib/types.ts | Shared types extracted from server actions |
| packages/web/app/globals.css | Font loading via direct @font-face (removed next/font) |
| packages/web/app/components/**/*.tsx | Import paths changed from @/ to ~/, 'use client' directives removed |
| packages/cli/src/lib/inspect/web.ts | Simplified to import and start server in-process |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)
packages/web/app/globals.css:22
- Font file paths use relative URLs pointing to node_modules, which is fragile and may not work correctly in all deployment scenarios. When the built application is deployed, node_modules is typically not included, and these relative paths will break. Consider using Vite's explicit import syntax (e.g.,
import fontUrl from 'geist/...') or copying the font files to the public directory during build.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
VaguelySerious
left a comment
There was a problem hiding this comment.
I don't think this works outside this repository. Running this in flight-booking-app via tarball, it tells me:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'next-themes' imported from /Users/peter/code/workflow-examples/flight-booking-app/node_modules/.pnpm/@workflow+web@https+++workflow-docs-mn332g6ys.vercel.sh+workflow-web.tgz_react-dom@19.2_7bc1f3d5c367e05b7722a809a4625b5e/node_modules/@workflow/web/build/server/assets/server-build-BNusQVv2.js
at Object.getPackageJSONURL (node:internal/modules/package_json_reader:316:9)
at packageResolve (node:internal/modules/esm/resolve:768:81)
at moduleResolve (node:internal/modules/esm/resolve:858:18)
at defaultResolve (node:internal/modules/esm/resolve:990:11)
at #cachedDefaultResolve (node:internal/modules/esm/loader:718:20)
at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:735:38)
at ModuleLoader.resolveSync (node:internal/modules/esm/loader:764:52)
at #resolve (node:internal/modules/esm/loader:700:17)
at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:620:35)
at ModuleJob.syncLink (node:internal/modules/esm/module_job:143:33)
VaguelySerious
left a comment
There was a problem hiding this comment.
Add react/react-dom as
peerDependencies
Won't work for non-react projects!
Replace Next.js framework config with React Router v7.13.0 framework mode: - Add react-router.config.ts, vite.config.ts, server/app.ts, server.js - Update package.json: swap next/swr/nuqs deps for react-router/express/vite - Update tsconfig.json: change path alias @/ to ~/, add .react-router types - Update .gitignore, turbo.json, components.json for new build output - Delete next.config.ts, postcss.config.mjs (PostCSS handled by Vite)
Rename src/ to app/ (React Router convention) and restructure the app: - Create app/root.tsx replacing layout.tsx + layout-client.tsx - Create app/routes.ts with route config for /, /run/:runId, /api/rpc, /api/stream/:streamId - Create route modules: home.tsx, run-detail.tsx - Create api.rpc.tsx resource route (RPC proxy replacing server actions) - Create api.stream.$streamId.tsx resource route (streaming data) - Create app/lib/rpc-client.ts (fetch wrapper for /api/rpc) - Create app/lib/types.ts (shared types for server/client boundary) - Rewrite app/lib/url-state.ts (useSearchParams replacing nuqs) - Add self-hosted Geist fonts via @font-face in globals.css - Convert server actions to .server.ts modules (Vite server-only convention) - Replace all @/ imports with ~/, remove 'use client' directives - Replace next/link, next/navigation with react-router equivalents - Replace nuqs useQueryState with useSearchParams - Update workflow-api-client.ts to use rpc-client instead of server actions - Delete old Next.js layout, pages, server actions, hooks, config files
Replace the child process spawn of 'next start' with an in-process Express server imported from @workflow/web/server: - Dynamically import startServer() from @workflow/web - Eliminate spawn(), serverProcess, readiness polling, cleanup handlers - Server is ready immediately when listen() callback fires - Removes runtime dependency on 'next' being resolvable from CLI
Radix AlertDialog and Sheet render an internal <form method='dialog'>. Outside a native <dialog> element, browsers default to POST on the current URL, which React Router interprets as a route action submission. Fix by preventing default form submission on AlertDialogContent and SheetContent. Also add safety action exports to route modules that redirect stray POSTs back to GET.
Remove migration-era language from JSDoc comments in web.ts.
Point @font-face src URLs at ../node_modules/geist/dist/fonts/ so Vite resolves and bundles them at build time. Removes the committed .woff2 copies from app/assets/fonts/.
Use import('@workflow/web/server') instead of resolving the package
path with createRequire and constructing a file path manually.
Add server.d.ts for TypeScript support.
Only console.error for genuine server-side failures (5xx). API-level client errors (4xx), run-not-found errors, and unrecognized errors from world backends are not logged server-side — the error is returned to the caller for handling.
Move the stray-POST handling from individual route actions to a single catch-all action on root.tsx. This covers all current and future routes without needing per-route action stubs.
Switch the RPC layer from JSON to CBOR encoding to preserve binary data (Uint8Array) across the wire. CBOR natively handles binary types without base64 overhead. Hydration (deserializing input/output/eventData from binary format to JS values) stays server-side for now. Making @workflow/core's hydration code browser-safe requires extracting it from the serialization module which has deep Node.js dependencies (Buffer, AsyncLocalStorage, child_process, etc.). This will be addressed as a prerequisite when e2e encryption lands. - Add cbor-x as runtime dependency - RPC route: encode responses as CBOR - RPC client: send/receive CBOR with proper Content-Type headers - Server actions: hydrate data before CBOR encoding (no JSON round-trip needed since CBOR preserves all JS types) - Hydration errors are caught and return raw data instead of crashing
- Add error handling for malformed CBOR/JSON request bodies (400) - Remove unused readStreamServerAction import from RPC route - Validate streamId format and startIndex parameter in stream route - Decode CBOR error body in RPC client for better error messages - Remove duplicate static file middleware from server/app.ts - Fix dev script to use react-router dev instead of node server.js
Vite's SSR build externalizes certain packages instead of bundling them. When @workflow/web is installed from npm (not in the monorepo), these externalized packages must be in dependencies to be available at runtime. Move all Radix UI, lucide-react, class-variance-authority, clsx, tailwind-merge, date-fns, next-themes, sonner, @xyflow/react, and workspace packages to dependencies. Add react/react-dom as peerDependencies.
Use ssr.noExternal=true in Vite config to bundle all dependencies into the server build. This means @workflow/web only needs express at runtime — all UI dependencies (Radix, lucide-react, etc.) are compiled into the server bundle. Keeps the installed package small.

Summary
nextdependency from the web, CLI, and workflow metapackagesnext startas a child processnuqsURL state management with React Router'suseSearchParams/api/rpc) and a thin CBOR-based clientMotivation
The
nextpackage is ~300MB installed and was the single largest dependency in the monorepo. It also required spawning a separate child process from the CLI to run the o11y web server, adding complexity around process lifecycle management, port readiness polling, and environment variable forwarding.With React Router framework mode, the web package builds to a standard Express-compatible server bundle that the CLI can import and serve directly in its own process.
What changed
Framework swap (
@workflow/web):next.config.ts/postcss.config.mjs→react-router.config.ts/vite.config.tssrc/directory →app/directory (React Router convention)src/app/layout.tsx+layout-client.tsx→app/root.tsxsrc/app/page.tsx→app/routes/home.tsxsrc/app/run/[runId]/page.tsx→app/routes/run-detail.tsx@/→~/'use client'/'use server'directivesData transport:
/api/rpcwith CBOR encodingUint8Arrayand other binary types natively (no base64 overhead)/api/stream/:streamIdresource routeURL state:
nuqs(useQueryState) →useSearchParamsfromreact-routerFonts:
next/font/google→ Geist.woff2files referenced directly fromnode_modules/geistvia@font-facein CSSCLI integration (
@workflow/cli):import('@workflow/web/server').then(m => m.startServer(port))Radix UI compatibility:
onSubmitpreventDefault onAlertDialogContentandSheetContentto prevent Radix's internal<form method="dialog">from triggering React Router route actionsDependencies removed
next,swr,nuqs,@tailwindcss/postcssDependencies added
react-router/@react-router/dev/@react-router/node/@react-router/express(all7.13.0)express,vite,@tailwindcss/vite,cbor-x,isbot,cross-envgeist(devDep)