docs/oss/migrating/migrating-from-nextjs.md
This guide is for teams running a Next.js frontend against a Rails backend (or evaluating that split) who want to collapse the two apps into a single Rails application with React on Rails — keeping modern React (server components, streaming, 'use client') while deleting the duplicated auth, API, and deployment layers.
It has two parts:
For the architectural decision itself (one app vs. two), see Next.js with a Separate Rails Backend: Pros and Drawbacks and Comparing React on Rails to Alternatives.
Be honest about the cases where this migration is the wrong move:
Every row in this table maps to a feature that is shipped and documented today. Rows marked [Pro] require React on Rails Pro; RSC and streaming additionally require the Node renderer.
| Next.js (App Router) | React on Rails (Pro) | Docs |
|---|---|---|
| React Server Components (server-first pages) | [Pro] registerServerComponent to register, RSCRoute to embed server components inside client trees | RSC hub, RSC inside client components |
'use client' directive | Same directive, unchanged. [Pro] auto-bundling reads it to choose between ReactOnRails.register and registerServerComponent | Auto-bundling with RSC |
Streaming SSR + <Suspense> (loading.tsx) | [Pro] stream_react_component and stream_react_component_with_async_props | Streaming SSR |
Data fetching in server components (fetch to your own API) | In-process data access — ActiveRecord in the controller or in async-props blocks; no HTTP hop, no API layer to maintain | RSC data fetching, [Pro] DB queries in async props |
Form mutations (useActionState / useFormStatus) | useRailsForm hook — useForm-style data/errors/processing/wasSuccessful posting to a plain Rails controller, with automatic CSRF and 422 model-error mapping (FormResponders concern) | Forms and Mutations |
| Full Route Cache / component caching | [Pro] cached_react_component (fragment caching) and one-line prerender caching | Caching, Fragment caching |
Metadata API (metadata export, generateMetadata) | React 19 native metadata — <title>/<meta>/<link> rendered in components are hoisted to <head>; works with SSR, streaming, and RSC | React 19 Native Metadata |
next/font/local | react_on_rails_font_face view helper — self-hosted .woff2 preload, @font-face with font-display, metric-matched fallback | Font Optimization |
next/link + client-side routing | React Router or TanStack Router integration (your routes, SSR-compatible) | React Router, TanStack Router |
middleware.ts (auth gating, redirects, headers) | Rack middleware and Rails controller filters — auth runs in the same process as the data it protects (Devise/Warden and friends) | Rails on Rack |
next dev (HMR dev server) | bin/dev — Rails plus webpack-dev-server with HMR by default | HMR and dev server modes |
Vercel deploy / self-hosted next start | Any Rack host: one Rails deploy, standard Rails 7.1+ Dockerfile; [Pro] Node renderer ships as a sidecar container when you use SSR/RSC | Docker deployment, Node renderer container deployment |
The headline row is data access. In the two-app stack, a "server component" in Next.js still fetches from your Rails API over HTTP, with auth forwarded across the boundary. After the migration the same component's data comes from ActiveRecord in-process — the API endpoints that existed only to feed the frontend get deleted, not rewritten.
These Next.js capabilities do not have a first-party React on Rails equivalent today. Each is tracked in an open issue; the table lists the interim approach.
| Next.js capability | Status |
|---|---|
Server Actions ('use server') | Tracked in #3867. Today: Rails controller actions receiving regular form posts |
use cache tags / revalidateTag | Tracked in #3871. Today: key-based cache expiry via the cache_key option |
| File-system routing, nested layouts, prefetch | Tracked in #3873. Today: Rails routes + React Router/TanStack Router |
next/image optimization analog | Tracked in #3874. Today: webpack asset pipeline (images) |
You do not need a big-bang rewrite. Because the two-app stack already routes browser traffic through a reverse proxy (or can trivially be put behind one), you can migrate one route group at a time: Rails takes over a path prefix, Next.js keeps serving everything else, and users never notice the seam.
app.example.com + api.example.com), plan to serve the migrated routes from the main domain — same-origin is what lets you delete the cross-origin auth plumbing later.Route all browser traffic through the proxy, with Next.js as the default upstream and Rails opted in per path:
upstream nextjs { server 127.0.0.1:3000; }
upstream rails { server 127.0.0.1:3001; }
server {
listen 443 ssl;
ssl_certificate /etc/ssl/certs/example.com.pem; # replace with your cert path
ssl_certificate_key /etc/ssl/private/example.com.key; # replace with your key path
server_name example.com;
# Terminate TLS at the proxy so both upstreams speak plain HTTP internally.
# nginx proxies upstream over HTTP/1.0 by default, which lacks chunked
# transfer encoding (required for streaming SSR) and disables keep-alive.
# Use HTTP/1.1 and clear the Connection header.
proxy_http_version 1.1;
proxy_set_header Connection "";
# Disabled globally because BOTH stacks stream during the migration:
# Next.js (loading.tsx, async server components) and Rails
# (stream_react_component, RSC async props). nginx buffers the full
# response by default, defeating streaming. Alternatively, keep buffering
# on and have each app send `X-Accel-Buffering: no` on streaming responses.
proxy_buffering off;
# Streaming SSR and RSC async-props responses can hold connections open
# longer than nginx's default 60s proxy_read_timeout. Tune to match your
# slowest expected stream.
# proxy_read_timeout 120s;
# Forward the browser-facing origin so both apps generate correct URLs
# and the shared session cookie stays on example.com.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Migrated route groups go to Rails (grows over time). Match the exact
# path plus its subpaths — a bare prefix like `location /account` would
# also capture /accounting.
location = /account { proxy_pass http://rails; }
location /account/ { proxy_pass http://rails; }
location = /settings { proxy_pass http://rails; }
location /settings/ { proxy_pass http://rails; }
# Rails also keeps serving the existing API for not-yet-migrated pages
location /api/ { proxy_pass http://rails; }
# Everything else stays on Next.js until migrated
location / { proxy_pass http://nextjs; }
}
Each migrated route group is a two-line proxy change — and a two-line rollback if something goes wrong.
:::caution proxy_set_header inheritance
nginx only inherits server-level proxy_set_header directives into location blocks that define none of their own. If you later add a proxy_set_header inside a location, repeat all of the headers above in that block.
:::
Pick the Rails session as the source of truth for authentication:
Set the Rails session cookie on the shared domain. Since the proxy serves both apps from one origin, the browser sends the same cookie to both upstreams. If the cookie was previously scoped to an API subdomain, set the domain explicitly:
# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
key: "_example_session",
domain: "example.com", # the shared, browser-facing domain
secure: true, # HTTPS only — TLS terminates at the proxy
same_site: :lax
Next.js pages that need auth state already forward cookies to the Rails API for data fetching; that keeps working unchanged.
Avoid migrating half of an auth flow. Move login/logout/signup to Rails early (they are usually plain forms — the easiest pages to port), so there is exactly one place that writes the session.
Pick a self-contained route group (start with a low-risk one). For each:
react_component in the view, or stream_react_component / RSCRoute with Pro for streaming and server components.location block for that path prefix from the Next.js upstream to Rails.After each route group moves, the API endpoints that existed only to feed those Next.js pages have no callers left. Delete them (or log access first to confirm). This is where the maintenance win compounds: every migrated route shrinks the API surface you version, document, and secure.
When the last route group flips to Rails:
location blocks point at Rails; the Next.js upstream receives no traffic (verify with proxy access logs).next.config.js / middleware.ts into the proxy config or Rails routes.Most of your React code is framework-agnostic and moves unchanged. The Next.js-specific imports are the work:
'use client' — keep it. With Pro RSC auto-bundling, the directive determines registration (client component vs. server component) exactly as it determines the boundary in Next.js. Without RSC, every component is a client component and the directive is inert but harmless.next/link, useRouter, usePathname — replace with plain <a> tags (Rails-routed pages) or your client router's equivalents (React Router, TanStack Router).next/head / Metadata API — replace with React 19 native <title>/<meta>/<link> tags rendered directly in components (guide).next/image — replace with `` plus webpack-pipeline assets (images); a first-party optimization helper is tracked in #3874.next/font — replace with the react_on_rails_font_face helper for the next/font/local use case (self-hosted .woff2, preload, metric-matched fallback).fetch in server components — delete the HTTP hop: pass props from the controller, or query ActiveRecord in async-props blocks (RSC data fetching).process.env.NEXT_PUBLIC_* — pass values as props from Rails, or configure them in your webpack build (e.g., EnvironmentPlugin/DefinePlugin).If you are adopting server components as part of the move, the RSC migration series covers component restructuring, context/state, and data-fetching patterns in depth — it applies directly to components arriving from Next.js.