DevLift
Back to Blog

Next.js vs Remix (React Router v7): The Full-Stack React Showdown

Next.js has the ecosystem and Vercel's backing. React Router has a cleaner model for interactive apps and a lighter client bundle. Also: "Remix 3" is now a different framework entirely — here's what that means for the choice.

Admin
April 1, 202611 min read2 views

Next.js vs Remix (React Router v7): The Full-Stack React Showdown

Your team just agreed on React for a new project. You need SSR, form handling, and it needs to be fast. Someone says "just use Next.js." Someone else says "Remix." And then someone Googles it and comes back confused because apparently Remix is now React Router v7?

Yeah. Let me clear that up first, then we'll get to the actual comparison.

The Name Change You Might Have Missed

Remix v2's bundler and server runtime moved directly into React Router, shipping as React Router v7 framework mode in November 2024. Packages merged: react-router-dom, @remix-run/react, and @remix-run/server-runtime all consolidated into react-router. The migration path from Remix v2 was essentially a codemod that updates imports; the mental model is identical.

Two things will confuse you if nobody tells you:

React Router is on v8 now, not v7 — 8.0 shipped June 2026, 8.3 in July. The v7 line is still getting patches (7.18.x). Everything in this article applies to both; I say "v7" because that's the release the Remix lineage arrived in and the name most people search for.

"Remix 3" is a different thing entirely, and it is not this. After the merge, the Remix team explicitly freed the Remix name to become something else — and what they announced is a new framework with "no critical dependencies, not even React." It's been in beta preview since April 2026 and is explicitly not production-ready. So if someone says "we should use Remix," find out which one they mean. (Note also that early coverage describing Remix 3 as built on a Preact fork is out of date — that plan was abandoned in favour of their own component model.)

So when I say "Remix" for the rest of this article, I mean the React Router framework-mode lineage — loaders, actions, nested routes. Not Remix 3.

Quick Decision Matrix

Next.jsRemix (RR7)
Content sites (blog, marketing, e-commerce)Best fitWorks, not ideal
Authenticated web apps / dashboardsCan workBest fit
Form-heavy CRUD appsServer Actions helpActions are first-class
React Server ComponentsStable, default modelAvailable but still unstable_-prefixed
Static generation / ISRYesPrerendering exists, not the default
Deploy anywhereSome Vercel lock-inRuntime agnostic
Client bundleHeavier baselineLighter baseline
Ecosystem / community sizeMuch largerGrowing

Routing: Files as UI vs Files as Data Boundaries

Both frameworks use file-based routing, but the mental model is different.

Next.js App Router treats files as UI first. Nested layouts are opt-in — you add layout.tsx files where you need them.

// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="dashboard-shell">
      <Sidebar />
      <main>{children}</main>
    </div>
  );
}
 
// app/dashboard/projects/page.tsx
export default async function ProjectsPage() {
  const projects = await fetchProjects();
  return <ProjectList projects={projects} />;
}

Remix's philosophy: every route file is a data boundary, not just a UI slice. The nesting isn't layout-focused — it's about which data belongs to which part of the URL.

// routes/dashboard.projects.tsx
import type { LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
 
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request);
  const projects = await getProjects(user.id);
  return { projects };
}
 
export default function Projects() {
  const { projects } = useLoaderData<typeof loader>();
  return <ProjectList projects={projects} />;
}

The key difference: in Remix, the loader and the component live in the same file. You can't accidentally forget to connect them. Next.js async components accomplish this too — but you can also have page.tsx files that do no server work at all, which is fine until your team has inconsistent standards.

💡

I'm using useLoaderData<typeof loader>() here because it's the form most people recognise, and it still works in v7 and v8. But it's not what the docs recommend any more. React Router has shipped route-level typegen since 7.0.0: the dev plugin generates +types/<route> and you take loaderData as a prop instead.

import type { Route } from "./+types/projects";
 
export async function loader({ params }: Route.LoaderArgs) {
  return { projects: await getProjects(params.id) };
}
 
export default function Projects({ loaderData }: Route.ComponentProps) {
  return <ProjectList projects={loaderData.projects} />;
}

The generated types are more accurate than typeof loader inference, particularly around params. If you're starting fresh, start here.

Data Fetching: One API vs Many Options

This is where the frameworks diverge most.

Next.js gives you a menu:

  • React Server Components for server-rendered async data
  • fetch() with caching for deduplication and revalidation
  • Server Actions for mutations
  • ISR for revalidating static pages on a schedule or on-demand
  • Client-side via SWR or React Query when you need it

That flexibility is genuinely useful. You can statically generate a marketing page with export const revalidate = 3600, server-render a dashboard, and client-fetch a real-time widget — all in the same app. But the menu gets overwhelming. "Should this page be SSR or ISR?" is a question I've watched teams bike-shed over for two hours.

Remix has one way to load data (loader), one way to mutate it (action).

// routes/settings.account.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request);
  return { user };
}
 
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const user = await requireUser(request);
 
  await updateUser(user.id, {
    name: String(formData.get("name")),
    email: String(formData.get("email")),
  });
 
  return redirect("/settings/account?updated=true");
}
 
export default function AccountSettings() {
  const { user } = useLoaderData<typeof loader>();
 
  return (
    <Form method="post">
      <input name="name" defaultValue={user.name} />
      <input name="email" defaultValue={user.email} />
      <button type="submit">Save</button>
    </Form>
  );
}

That <Form> component handles pending states, error recovery, and optimistic UI out of the box. No useState for loading. No try/catch in the component. The loader re-runs after the action completes and the UI reflects the new data automatically.

Remix's <Form> with action patterns work progressively — if JavaScript hasn't loaded yet, the form still submits as a standard POST and the user gets the redirect back. Actual progressive enhancement, not marketing copy.

Caching: Build Time vs HTTP Headers

Next.js caching is aggressive and build-time oriented. Static pages get edge-cached automatically. ISR lets you revalidate them on a schedule or on-demand. This is great for content sites where data changes infrequently and you want zero cold starts.

// Next.js — cache this route, revalidate every hour
export const revalidate = 3600;
 
// Or revalidate on-demand from a Server Action or route handler
import { revalidatePath } from "next/cache";
 
// revalidatePath is synchronous — don't await it.
// And for a dynamic segment the second argument is REQUIRED:
// revalidatePath("/products/[slug]") on its own logs a warning and
// revalidates nothing at all.
revalidatePath("/products/[slug]", "page");

But Next.js's caching layer has a reputation for being surprising. Granularity is tricky — you'll accidentally cache the whole route when you only wanted to cache one data source. Next.js 15 rolled back the aggressive defaults after widespread frustration: fetch requests and GET route handlers are no longer cached unless you ask.

Next.js 16 reworked this again. Partial Prerendering's experimental.ppr flag and the experimental_ppr route export are gone, replaced by a top-level cacheComponents: true config (which also supersedes experimental.dynamicIO and experimental.useCache). Note it's not a straight rename — the docs are explicit that PPR behaves differently in 16 than in the 15 canaries, and advise staying on a 15 canary if you're depending on the old behaviour. If you're evaluating Next.js caching on the strength of blog posts, check which major they were written for.

Remix gives you HTTP. You return headers from your loader:

export async function loader({ request }: LoaderFunctionArgs) {
  const products = await getProducts();
 
  return Response.json(products, {
    headers: {
      "Cache-Control": "public, max-age=60, stale-while-revalidate=3600",
      "CDN-Cache-Control": "public, max-age=3600",
    },
  });
}

If you know HTTP caching semantics, Remix's model clicks immediately. If you don't, there's a learning curve. But the behavior is predictable and debuggable — you can test it with curl like any other HTTP endpoint.

React Server Components: Next.js Is Ahead

Next.js App Router was built from the ground up around RSC. Every component is a Server Component by default. You opt into client components with "use client".

// Runs only on the server — zero JS sent to the browser for this component
export default async function ProductCard({ id }: { id: string }) {
  const product = await db.product.findUnique({ where: { id } });
  return (
    <div className="product-card">
      <h2>{product.name}</h2>
      <p>{product.description}</p>
      <span>${product.price}</span>
    </div>
  );
}

This reduces client JavaScript. Components that don't need interactivity just don't ship to the browser. The tradeoff is a new mental model around serialization boundaries and what can cross the server/client line.

Worth being precise about what actually breaks, because this gets misreported constantly. React's supported prop types across the boundary include primitives, plain objects and arrays, Map, Set, typed arrays, Promises, JSX elements, Server Functions — and Date. A Date survives the boundary as a real Date instance; it is not a problem. What genuinely fails is arbitrary functions, classes, instances of your own classes, objects with a null prototype, and non-globally-registered symbols. So "don't pass class instances" is the rule. "Don't pass Dates" is folklore.

Remix has been more conservative with RSC, but "coming soon" is out of date: React Router shipped unstable RSC support for data mode in 7.7 (July 2025) and framework mode in 7.9 (September 2025), via unstable_reactRouterRSC. It's real and usable today. It is also still unstable_-prefixed in 8.3, with docs warning of breaking changes in minor and patch releases.

So the accurate framing is a maturity gap, not an absence: Next.js has the stable, default, production-proven RSC implementation. React Router has one you can adopt if you're willing to absorb churn. If RSC is a priority, Next.js is still ahead.

💡
For most apps, the practical difference between RSC and Remix loaders is small. Both move data fetching to the server. RSC gives you more granular control over what JavaScript ships; loaders are simpler to reason about.

Architecture: How the Data Flows

Rendering diagram...

The fundamental loop is similar: fetch on server → render → send HTML → handle mutations → refetch. The difference is where the seams are. Next.js has seams at the component level (each async server component is its own data fetch). Remix has seams at the route level (the loader owns all data for a URL segment).

Bundle Size & Performance

Remix ships less baseline JavaScript. That's the direction, and it comes from not shipping Next.js's client-side router state, the RSC flight protocol runtime, and related infrastructure.

You will see this quantified as "35% less JS — 566 kB vs 371 kB." Those digits are real but badly out of context, and I'd rather you didn't repeat them. They come from a single sentence in a Remix blog post from January 2022, measuring one page of the Next.js Commerce Shopify demo against a Remix port of it. That was Next.js 12 with the Pages Router and client-side fetching — pre-App-Router, pre-RSC, pre-React-19. The figures are also uncompressed; over the wire it was 172 kB vs 120 kB gzipped. The post itself concedes the gap was largely an artifact of that demo's data-fetching choice.

For current reference points: a bare create-react-router app measures around 318 kB uncompressed / 104 kB gzipped. Next.js 16 no longer publishes a comparable figure at all — it removed the size and First Load JS columns from next build output, on the grounds that they were "inaccurate in server-driven architectures using React Server Components." Which is a fair point, and also means anyone quoting you a current Next.js baseline number is computing it themselves.

For Time to First Byte: Remix streams HTML immediately. Next.js streams too via Suspense, but the setup requires more deliberate component architecture.

Build times are more nuanced than the usual framing. Next.js build time scales with statically generated pages times the per-page data-fetch cost, and it genuinely does get slow — community reports on large Pages Router sites put a few thousand pages in the several-minutes range. But the claim that Remix "builds in under 20 seconds regardless of route count" doesn't survive contact with a test. Generating route modules and running react-router build on a 4-core Linux box:

RoutesBuild time
10.76 s
1,0005.1 s
3,00021.5 s
10,000ran out of memory, twice

Growth is super-linear and 3,000 routes already blows past 20 seconds. "Regardless" is the wrong word. And if you use React Router's prerender config to get static output, you're in exactly the same scaling regime as SSG — I measured roughly a 31 ms/page floor even on a trivial page.

The real difference is what the build scales with: Next.js with SSG scales with content volume, React Router defaults to ssr: true and scales with the module graph. That distinction is durable. The round numbers attached to it usually aren't.

Deployment

Next.js runs best on Vercel. It works on other platforms — self-hosted with next start, AWS, Cloudflare — but some features (ISR, Edge Middleware, image optimization at scale) are more seamless or partially Vercel-specific. That's not a bug, it's a business model. Worth knowing about before you're three months in.

Remix was designed to run anywhere. There are three official server adapters — @react-router/cloudflare, @react-router/express, and @react-router/architect (which is how you get to AWS Lambda) — plus @react-router/serve as a built-in production server. Hosts like Fly.io and Railway need no adapter at all; you deploy the Node or Docker template. (You'll see "Fly adapter" mentioned in older material — that's a confusion with the blues-stack template, which was named after a host. There has never been a Fly adapter.)

The runtime wraps standard Web APIs (Request, Response, Headers), which is what makes the apps genuinely portable.

If you're deploying to Vercel anyway, this doesn't matter. If you need Cloudflare Workers, edge deployment, or true platform flexibility, Remix has a structural advantage.

When to Use Next.js

  • Content-heavy public sites: blogs, marketing, documentation, e-commerce product pages. ISR and static generation are its home turf.
  • SEO is critical with frequently changing content: the full ISR + on-demand revalidation story is mature.
  • Your team already knows Next.js: a known tool ships faster. Don't underestimate this.
  • You want RSC today: Next.js has the most complete, production-ready RSC implementation.
  • You're deploying to Vercel: you'll get the most seamless experience.

When to Use Remix (React Router v7)

  • Authenticated web apps: dashboards, admin panels, SaaS products. Loaders and actions fit this flow perfectly.
  • Form-heavy UIs: CRMs, project management tools, anything where the user is constantly creating and editing. Remix's action/Form pattern handles optimistic UI and pending states without third-party state libraries.
  • HTTP fundamentals matter: caching, streaming, progressive enhancement. Remix respects the web platform.
  • Deployment flexibility is a requirement: edge, Cloudflare Workers, AWS Lambda — Remix travels well.
  • Bundle size is a constraint: mobile-heavy audiences, performance-sensitive apps.

When to Use Both

Technically possible via monorepo — a content site on Next.js, the authenticated product on Remix. Companies do this. It's operationally more complex than it sounds, and you end up maintaining two framework upgrade tracks. Only worth it at a certain scale.

⚠️
Don't mix the two frameworks inside a single app. The data fetching mental models conflict — contributors will context-switch between RSC and loaders and nobody's happy.

The honest answer in 2026: Next.js has the ecosystem, the community, and Vercel's backing. React Router has a cleaner mental model for interactive apps and a lighter client footprint. Neither is wrong for the right project.

But defaulting to Next.js for everything because that's what tutorials use — that's a habit worth questioning when you're building something where users log in and do things all day.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.
AdminAugust 3, 20268 min read
Server Actions collapse form handling into a single server function—no API route, no manual fetch, no useState for loading. Here's the pattern that replaces 80% of your mutation boilerplate.
AdminAugust 3, 20267 min read
A monotonic stack maintains elements in order and pops when that order breaks — finding the next greater element for every popped value in O(n) total.
AdminAugust 3, 20265 min read