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
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.
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.
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.
| Next.js | React Router v7 | Astro | |
|---|---|---|---|
| Current stable | 16.2.6 | 7.15.1 | 6.3.7 |
| Previous LTS | 15.5.18 (backport) | - | - |
| Major in last 6 months | No (v16 shipped Oct 2025) | No (v7 shipped late 2024) | Yes (v6.0, March 2026) |
| Rendering model | RSC + SSR + SSG hybrid | SSR + SSG | MPA-first, islands |
| Primary language | TypeScript / JSX | TypeScript / JSX | .astro + JSX / Vue / Svelte |
| React required | Yes | Yes | Optional |
| 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.
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.
The same four scenarios in all three frameworks. Same API (jsonplaceholder.typicode.com). Same app shape.
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.
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.
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.
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.
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):
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):
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.
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 4export 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.
npm downloads, May 2025 - May 2026 (source: npm-stat.com):
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):
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.
Pick Astro if:
.astro) and the islands modelPick React Router v7 if:
Pick Next.js if:
Do not pick Next.js if:
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
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
Our services
Proud Member of