Next.js vs React Router v7 vs Astro in 2026: a practical comparison

Marzena Polana

Frontend Developer

2026-08-11

#Development

Time to read

10 mins

In this article

Introduction

The short version

First, clear the air: what happened to Remix?

Framework snapshots in 2026

The mental model

Side-by-side code

Performance: what the actual data says

The Next.js App Router tax

Ecosystem snapshot (May 2026)

How to pick

Our take

Share this article

Introduction

Next.js is the most popular choice - but popularity and fit are not the same thing. We compared all three frameworks with real code, verified benchmarks, and a clear decision guide for 2026.

Versions covered: Next.js 16.2.6 · React Router 7.15.1 · Astro 6.3.7 Last verified: May 2026.

The short version

If you are building a content or marketing site and care about Core Web Vitals out of the box: pick Astro.

If you are building a server-driven web app - lots of forms, mutations, session logic - and want the cleanest data-flow model available in React: pick React Router v7.

If you are building a full-stack product with a mix of static pages, dynamic routes, API needs, and a team that already knows the React ecosystem: pick Next.js.

None of those three answers is wrong. What is wrong is picking Next.js for everything by default because it is the most popular option. Popularity is not the same as fit.

First, clear the air: what happened to Remix?

Remix is not dead, but it is no longer where new development happens.

In late 2024, the Remix team merged with the React Router team at Shopify. The result: React Router v7, released in late 2024, absorbed everything that made Remix compelling - server loaders, server actions, nested routing, progressive enhancement - and became the official continuation of both projects.

Remix v2 (latest: 2.17.4) is now in maintenance mode. The official create-remix CLI redirects you to create-react-router. If you start a new project today and would have reached for Remix, you should reach for React Router v7 instead. That is what this article does.

Framework snapshots in 2026

Next.jsReact Router v7Astro
Current stable16.2.67.15.16.3.7
Previous LTS15.5.18 (backport)--
Major in last 6 monthsNo (v16 shipped Oct 2025)No (v7 shipped late 2024)Yes (v6.0, March 2026)
Rendering modelRSC + SSR + SSG hybridSSR + SSGMPA-first, islands
Primary languageTypeScript / JSXTypeScript / JSX.astro + JSX / Vue / Svelte
React requiredYesYesOptional
npm downloads (May 2025 – May 2026)~1 billion~1.24 billion~51.5 million

The download numbers for React Router include v5 and v6 installs still running in production, not just v7. That context matters. But the trend over the past year is clear: React Router is growing relative to Next.js, while Astro remains a specialist choice with a loyal and rapidly growing audience.

The mental model

Before you look at any code, get the mental model right. These three frameworks have different answers to the most basic question: where does data loading happen, and who is responsible for it?

Next.js (App Router) gives you React Server Components as the default. A component is either a Server Component (no state, no browser events, runs only on the server) or a Client Component (marked with 'use client', runs in the browser). Data fetching happens inside async Server Components or via Server Actions. The model is powerful, but it introduces a new conceptual boundary - server vs client - that cuts through your entire component tree.

React Router v7 gives you a clear route-based data layer. Every route file can export a loader (reads data before render) and an action (handles form submissions). Your components just receive data. There is no useEffect for data fetching, no server/client boundary inside components - just functions on the server and components in the browser, connected by the router.

Astro gives you a different default entirely: HTML-first. Pages are .astro files where the top --- frontmatter block runs on the server at build time (or request time for SSR routes). By default, Astro ships zero JavaScript to the browser. You opt into client-side JavaScript per-component via directives like client:load or client:idle. If you need React, Vue, or Svelte components, you include them explicitly.

Side-by-side code

The same four scenarios in all three frameworks. Same API (jsonplaceholder.typicode.com). Same app shape.

1. FETCHING A LIST FROM AN API

Next.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// app/posts/page.tsx
export const dynamic = 'force-dynamic';

type Post = { id: number; title: string; body: string };

async function getPosts(): Promise<Post[]> {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5', {
    cache: 'no-store',
  });
  if (!res.ok) throw new Error('Failed to fetch posts');
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();
  return (
    <main>
      <h1>Latest posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

This is a React Server Component. No hooks, no useEffect. The async/await at the top level is the data fetch. cache: 'no-store' tells Next.js to skip its fetch cache - something you will find yourself doing a lot until you fully understand Next.js caching, which has its own section below.

React Router v7

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// app/routes/posts.tsx
import type { Route } from './+types/posts';

type Post = { id: number; title: string; body: string };

export async function loader({ request }: Route.LoaderArgs) {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5', {
    signal: request.signal,
  });
  if (!res.ok) throw new Response('Failed to fetch posts', { status: 502 });
  const posts = (await res.json()) as Post[];
  return { posts };
}

export default function PostsPage({ loaderData }: Route.ComponentProps) {
  const { posts } = loaderData;
  return (
    <main>
      <h1>Latest posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

The loader is a plain async function that runs on the server. The component receives loaderData as a typed prop - types are auto-generated from ./+types/posts. No hook needed. The request.signal wires up cancellation automatically if the user navigates away.

Astro

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
---
// src/pages/posts.astro
type Post = { id: number; title: string; body: string };

const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5');
if (!res.ok) throw new Error('Failed to fetch posts');
const posts = (await res.json()) as Post[];
---

<html lang="en">
  <body>
    <main>
      <h1>Latest posts</h1>
      <ul>
        {posts.map((post) => (
          <li>
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </main>
  </body>
</html>

The --- block is frontmatter - it runs on the server only, never in the browser. The markup below it is the template. Zero JavaScript ships to the client for this page unless you explicitly add it.

2. DYNAMIC ROUTE WITH PARAMS

Next.js

1
2
3
4
5
6
7
8
9
10
11
12
13
// app/blog/[slug]/page.tsx
type BlogPageProps = {
  params: Promise<{ slug: string }>;
};

export default async function BlogPostPage({ params }: BlogPageProps) {
  const { slug } = await params; // params is async in Next.js 15+
  return (
    <article>
      <h1>Blog post: {slug}</h1>
    </article>
  );
}

Note: params became a Promise in Next.js 15. If you search older tutorials and copy their code, it will fail silently - you will get [object Promise] rendering as the slug. This is one of those App Router changes that trips up teams mid-project.

React Router v7

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// app/routes/blog.$slug.tsx
import type { Route } from './+types/blog.$slug';

export async function loader({ params }: Route.LoaderArgs) {
  if (!params.slug) throw new Response('Not found', { status: 404 });
  return { slug: params.slug };
}

export default function BlogPostPage({ loaderData }: Route.ComponentProps) {
  return (
    <article>
      <h1>Blog post: {loaderData.slug}</h1>
    </article>
  );
}

Astro

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
---
// src/pages/blog/[slug].astro
export async function getStaticPaths() {
  // In a real project, fetch slugs from your CMS here
  return [
    { params: { slug: 'hello-world' } },
    { params: { slug: 'getting-started' } },
  ];
}

const { slug } = Astro.params;
---

<html lang="en">
  <body>
    <article>
      <h1>Blog post: {slug}</h1>
    </article>
  </body>
</html>

Astro's dynamic routes are statically generated by default - getStaticPaths runs at build time and produces one HTML file per slug. For fully dynamic SSR routes (unknown slugs at build time), add export const prerender = false and remove getStaticPaths.

3. FORM WITH SERVER-SIDE MUTATION

This is where the three frameworks diverge most sharply.

Next.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// app/newsletter/actions.ts
'use server';
import { redirect } from 'next/navigation';

export async function subscribe(formData: FormData) {
  const email = String(formData.get('email') ?? '').trim();
  if (!email) throw new Error('Email is required');

  await fetch('https://httpbin.org/post', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email }),
  });

  redirect('/newsletter/success');
}
// app/newsletter/page.tsx
import { subscribe } from './actions';

export default function NewsletterPage() {
  return (
    <main>
      <h1>Join the newsletter</h1>
      <form action={subscribe}>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required />
        <button type="submit">Subscribe</button>
      </form>
    </main>
  );
}

Server Actions are one of Next.js's genuinely good ideas - you wire a function directly to a form's action prop, no API route needed. In practice there are sharp edges: setting a cookie inside a Server Action still triggers a full app re-render in some scenarios (GitHub issue #50163, open since 2023, 57 upvotes), which matters as soon as you build authentication flows.

React Router v7

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// app/routes/newsletter.tsx
import type { Route } from './+types/newsletter';
import { redirect } from 'react-router';
import { Form, useActionData } from 'react-router';

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const email = String(formData.get('email') ?? '').trim();
  if (!email) return { error: 'Email is required' };

  await fetch('https://httpbin.org/post', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email }),
  });

  return redirect('/newsletter/success');
}

export default function NewsletterPage({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>Join the newsletter</h1>
      <Form method="post">
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required />
        <button type="submit">Subscribe</button>
      </Form>
      {actionData?.error && <p>{actionData.error}</p>}
    </main>
  );
}

The action function handles the mutation. The component receives actionData as a typed prop. Form from react-router uses progressive enhancement - it works without JavaScript enabled and enhances with client-side fetch when JavaScript is available.

Astro

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
---
// src/pages/newsletter.astro
export const prerender = false; // opt into SSR for this route

let message = '';

if (Astro.request.method === 'POST') {
  const formData = await Astro.request.formData();
  const email = String(formData.get('email') ?? '').trim();

  if (!email) {
    message = 'Email is required';
  } else {
    await fetch('https://httpbin.org/post', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
    });
    message = 'Subscribed!';
  }
}
---

<html lang="en">
  <body>
    <main>
      <h1>Join the newsletter</h1>
      <form method="POST">
        <label for="email">Email</label>
        <input id="email" name="email" type="email" required />
        <button type="submit">Subscribe</button>
      </form>
      {message && <p>{message}</p>}
    </main>
  </body>
</html>

Astro's server form handling is the most straightforward of the three - it is just HTTP. Check the method, read formData, respond. No framework abstraction, no special primitives to learn. The limitation is that the page re-renders fully on submission (no client-side optimistic UI) unless you layer in a client-side component.

4. LAYOUT NESTING

Next.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// app/layout.tsx — root layout
import Link from 'next/link';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <header>
          <nav>
            <Link href="/">Home</Link> |{' '}
            <Link href="/posts">Posts</Link> |{' '}
            <Link href="/newsletter">Newsletter</Link>
          </nav>
        </header>
        {children}
      </body>
    </html>
  );
}
// app/blog/layout.tsx — nested layout for blog section
export default function BlogLayout({ children }: { children: React.ReactNode }) {
  return (
    <section>
      <aside>Blog sidebar</aside>
      <div>{children}</div>
    </section>
  );
}

Any layout.tsx you place in a folder applies to all routes under that folder. They nest automatically. One open bug worth knowing: root layout components that hold global state (sidebars, notification bars) re-render on every navigation in some scenarios (#52558).

React Router v7

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// app/root.tsx
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';

export default function App() {
  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <header>
          <nav>
            <a href="/">Home</a> | <a href="/posts">Posts</a> |{' '}
            <a href="/newsletter">Newsletter</a>
          </nav>
        </header>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}
// app/routes/blog.tsx — layout route for /blog/*
import { Outlet } from 'react-router';

export default function BlogLayout() {
  return (
    <section>
      <aside>Blog sidebar</aside>
      <div>
        <Outlet />
      </div>
    </section>
  );
}
// app/routes/blog._index.tsx — the index page at /blog
export default function BlogIndexPage() {
  return <h1>Blog home</h1>;
}

Astro

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
---
// src/layouts/BaseLayout.astro
const { title = 'Site' } = Astro.props;
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>{title}</title>
  </head>
  <body>
    <header>
      <nav>
        <a href="/">Home</a> | <a href="/posts">Posts</a> | <a href="/newsletter">Newsletter</a>
      </nav>
    </header>
    <slot />
  </body>
</html>
---
// src/layouts/BlogLayout.astro
import BaseLayout from './BaseLayout.astro';
const { title } = Astro.props;
---
<BaseLayout title={title}>
  <section>
    <aside>Blog sidebar</aside>
    <div>
      <slot />
    </div>
  </section>
</BaseLayout>
---
// src/pages/blog/[slug].astro
import BlogLayout from '../../layouts/BlogLayout.astro';
export async function getStaticPaths() {
  return [{ params: { slug: 'hello-world' } }];
}
const { slug } = Astro.params;
---
<BlogLayout title={`Blog: ${slug}`}>
  <article>
    <h1>Blog post: {slug}</h1>
  </article>
</BlogLayout>

Astro layouts are just components that use <slot />. No framework magic, no directory conventions to learn - it is composition.

Performance: what the actual data says

The most honest public dataset on this topic is Astro's 2023 Web Framework Performance Report, compiled from the Chrome User Experience Report (CrUX) and HTTP Archive - both independently managed, publicly available datasets. The Astro team published this, so read it knowing the source. The data is real; the framing might favour Astro.

Key findings:

Core Web Vitals pass rate (real users, Chrome, real-world traffic):

  • Astro was the only framework above 50% of sites passing Google's CWV assessment
  • Next.js came in at roughly 1-in-4 sites passing (~25%)
  • Remix sat in a similar range to Next.js on real-world CWV

Why the gap? The report ties it directly to JavaScript bundle size. Sites that ship less JavaScript to the client consistently post better CWV scores. Astro ships near-zero JS by default. Next.js and React Router both hydrate a React tree, which adds weight.

Lighthouse median score (lab conditions, not real users):

  • No framework achieved a "good" median score (90+)
  • Astro, SvelteKit, and Remix came in above the web average of 34/100
  • Next.js landed below the web average in this dataset

Important caveats: this data is from 2023, Next.js has shipped significant performance improvements in versions 14-16, and the report includes a long tail of older Next.js sites on v12/v13 which drags the numbers down. A fresh 2026 build with Next.js 16 and Turbopack will perform meaningfully better than the 2023 average.

The qualitative conclusion still holds: Astro wins on raw performance for content-heavy pages because of how the architecture works, not because the team coded it better. If you need to minimise JavaScript on the client, Astro's model makes that the default. In Next.js and React Router, low-JS requires discipline and deliberate choices across every route.

The Next.js App Router tax

Next.js is the right choice for many projects. It is also the framework with the steepest learning curve in this list, and the one where production gotchas are most likely to cost you a sprint. Here are three that are real, not opinions:

1. Caching behaviour that surprises everyone

Next.js 15 changed caching defaults significantly after widespread confusion in v13/v14. Fetches are no longer cached by default. Route segments are no longer cached by default. But the caching model - no-store, force-cache, revalidate, unstable_cache, cache() - is still a separate system you need to learn before you can reason about your app's data freshness. Teams regularly ship bugs where data is staler or fresher than expected.

The fix when in doubt:

1
2
3
// Opt out of all caching for a route
export const dynamic = 'force-dynamic';
export const revalidate = 0;

2. Params became async in v15 and breaks old code silently

In Next.js 15, params and searchParams became Promises. If you copy code from a tutorial written for v13 or v14, you will likely write:

1
2
3
4
// This silently fails in Next.js 15+
export default function Page({ params }: { params: { slug: string } }) {
  return <h1>{params.slug}</h1>; // renders "[object Promise]"
}

The correct form:

1
2
3
4
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  return <h1>{slug}</h1>;
}

Next.js 16 adds a cleaner way via the PageProps helper, which is generated from your route structure and gives you autocomplete and strict param keys:

1
2
3
4
5
// app/blog/[slug]/page.tsx — Next.js 16+
export default async function Page(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params; // fully typed, no manual Promise<> annotation
  return <h1>{slug}</h1>;
}

PageProps is globally available after next dev, next build, or next typegen - no import required. There is no runtime error, no warning - it just renders wrong. This is the kind of subtle breakage that shows up in code review or QA, not in the build.

3. Server Action cookie mutations can trigger full app re-renders

Setting a cookie inside a Server Action - common in auth flows - still triggers a full client-side re-render of the entire application tree in some scenarios. GitHub issue #50163 has been open since May 2023 with confirmed reproduction steps. In React Router v7, a cookie mutation inside an action only revalidates the affected route's loader, which is the expected behaviour.

None of these are dealbreakers. They are the cost of being on the most feature-rich, most Vercel-integrated framework in the ecosystem. Know the cost before you commit.

Ecosystem snapshot (May 2026)

npm downloads, May 2025 - May 2026 (source: npm-stat.com):

  • react-router: ~1.24 billion
  • next: ~1.00 billion
  • astro: ~51.5 million

React Router's number includes all versions (v5, v6, v7) - a large portion is v5/v6 in existing projects. But the direction is notable: in total install volume, React Router now outpaces Next.js over the past year.

GitHub stars (verify at time of publishing - these change frequently):

  • Next.js: ~140,000
  • React Router: growing significantly post-v7 launch
  • Astro: growing rapidly since v1

Jobs: Next.js roles substantially outnumber React Router and Astro roles on LinkedIn. This is a real ecosystem advantage for Next.js - a client asking "can we hire someone to maintain this?" is asking a legitimate question, and Next.js wins that answer for now.

How to pick

Pick Astro if:

  • The site is primarily content - blog, documentation, marketing pages, portfolio
  • Core Web Vitals are a hard requirement (ecommerce, SEO-critical landing pages)
  • Your team is comfortable with a new file format (.astro) and the islands model
  • You need to mix multiple UI frameworks on the same site (React for one component, Svelte for another)

Pick React Router v7 if:

  • The app is form-heavy and server-driven - dashboards, admin panels, data entry tools
  • You want the cleanest progressive enhancement story in React
  • Your team has Remix experience (migration is seamless - React Router v7 was designed as a drop-in)
  • You want explicit, predictable data loading without learning a new caching model

Pick Next.js if:

  • You are building a product that mixes content pages, dynamic routes, API endpoints, and auth
  • Your team is already experienced with App Router
  • You need deep Vercel integration (previews, ISR, edge functions, image optimisation)
  • The project will grow and needs the widest hiring pool

Do not pick Next.js if:

  • The site is 80%+ static content (use Astro)
  • The project is primarily a server-rendered form application and the team is new to App Router (the learning curve will cost weeks)
  • You are rebuilding an existing Remix app (stay on Remix v2 or migrate to React Router v7 directly)

Our take

We build with Next.js on most client projects, not because it is objectively the best framework, but because it fits the typical brief: a product that is partly marketing, partly application, with a team that needs to hire. That combination is where Next.js is genuinely hard to beat.

But in 2026, that default deserves more scrutiny than it used to.

React Router v7 is now a serious alternative for anything application-shaped. The data model is cleaner than App Router, the learning curve is shorter, and the progressive enhancement story is better. If a project is primarily a web application rather than a website, the conversation should include it.

Astro belongs in every pitch for content sites. Recommending a full React framework for a marketing site because "everyone knows React" is a choice that will show up in your client's Lighthouse scores.

The right answer is still project-specific. These notes just help you ask the right questions before you commit.

Have feedback or spotted something outdated? The web moves fast - reach out at hi@devanddeliver.com

Marzena Polana

Frontend Developer

Share this post

Related posts

Want to light up your ideas with us?

Kickstart your new project with us in just 1 step!

Prefer to call or write a traditional e-mail?

Dev and Deliver

sp. z o.o. sp. k.

Address

Józefitów 8

30-039 Cracow, Poland

VAT EU

PL9452214307

Regon

368739409

KRS

94552994