The React meta-framework landscape fractured in 2025. Remix merged into React Router v7, then announced v3 would drop React entirely. Next.js doubled down on server components with v16. TanStack Start shipped its first stable release, betting that client-first was the right default. Cloudflare acquired Astro. SvelteKit quietly kept winning developer satisfaction surveys.
If you are choosing a framework for a new project in 2026, the decision is harder than ever -- and the stakes are higher. Your framework choice determines bundle size, type safety, deployment flexibility, hiring pool, and how much JavaScript your users download before they can click a button.
This article compares TanStack Start, Next.js, SvelteKit, Astro, and the Remix/React Router lineage through a client-side lens: what ships to the browser, how interactivity works, and what your users actually experience.
Server-side rendering gets the headlines. Edge functions get the conference talks. But your users experience the client side: the JavaScript that downloads, parses, executes, and ultimately determines whether your app feels fast or sluggish.
Three metrics define client-side quality:
These metrics compound. A 100KB bundle difference means 200-400ms on a median mobile connection. That gap widens on repeat visits if your caching strategy is wrong, and it multiplies across every route transition if your code-splitting is not set up correctly.
Key insight: The best server-side rendering in the world cannot compensate for shipping 300KB of client JavaScript that blocks interactivity. Framework choice is a bundle size decision first and a DX decision second.
Here is the uncomfortable truth: most framework comparisons focus on developer experience, deployment options, and feature lists. Those matter for the team building the app. But the user never sees your DX. They see load time, responsiveness, and whether the button they tapped actually did something.
Not all kilobytes are equal. JavaScript is more expensive than images or CSS because the browser must download, parse, compile, and execute it -- all on the main thread. Here is how the cost breaks down:
| Phase | What Happens | Affected By |
|---|---|---|
| Download | Bytes over the network | Bundle size, compression, CDN |
| Parse | Browser reads and tokenizes the JS | Code complexity, module count |
| Compile | JIT compilation to machine code | Code patterns, eval usage |
| Execute | Framework initialization, hydration | Framework runtime size, component tree depth |
A study by Google's Web Performance team found that on a median mobile device, each kilobyte of JavaScript costs approximately 2ms of main-thread time. That means a 100KB framework runtime consumes 200ms before a single line of your application code runs.
| Metric | Poor Framework Choice | Good Framework Choice |
|---|---|---|
| Initial JS payload | 200-400KB gzipped | 50-120KB gzipped |
| LCP | 2.5-4.0s | 0.8-1.8s |
| INP (Interaction to Next Paint) | 200-500ms | 50-150ms |
| Route transition | 300-800ms | 50-200ms |
| Lighthouse Performance | 45-65 | 85-100 |
The difference between a good and poor choice is not theoretical. It is the difference between passing and failing Core Web Vitals, which directly affects search ranking.
Every server-rendered framework pays a hydration cost. The server sends HTML, then the client downloads JavaScript to make that HTML interactive. During hydration, the framework walks the DOM tree, attaches event handlers, and reconciles its internal state with the rendered HTML.
This cost varies dramatically by framework:
The hydration gap is why a SvelteKit page feels interactive almost instantly while a Next.js page with the same content has a visible "dead zone" where buttons render but do not respond.
TanStack Start is a full-stack React framework built on TanStack Router, Vite, and Nitro. It takes a fundamentally different position than Next.js: client-first by default, with server capabilities available when you need them.
Where Next.js starts from the server and requires "use client" to opt into client-side behavior, TanStack Start starts from the client and uses createServerFn to opt into server behavior. This inversion matters more than it sounds.
The core primitive is createServerFn, which creates a type-safe function that runs on the server but can be called from client code as if it were a local function:
// server/db.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { db } from './database'
const GetTeamSchema = z.object({
teamId: z.string().uuid(),
includeMembers: z.boolean().default(false),
})
export const getTeam = createServerFn()
.inputValidator(GetTeamSchema)
.handler(async ({ data }) => {
const team = await db.query.teams.findFirst({
where: (teams, { eq }) => eq(teams.id, data.teamId),
with: data.includeMembers ? { members: true } : undefined,
})
if (!team) {
throw new Error('Team not found')
}
return team
})
export const updateTeamName = createServerFn({ method: 'POST' })
.inputValidator(z.object({
teamId: z.string().uuid(),
name: z.string().min(1).max(100),
}))
.handler(async ({ data }) => {
const updated = await db
.update(teams)
.set({ name: data.name, updatedAt: new Date() })
.where(eq(teams.id, data.teamId))
.returning()
return updated[0]
})
On the client, you call these functions directly. No fetch calls, no API route definitions, no manual type synchronization:
// routes/teams/$teamId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getTeam, updateTeamName } from '../../server/db'
export const Route = createFileRoute('/teams/$teamId')({
loader: ({ params }) =>
getTeam({ data: { teamId: params.teamId, includeMembers: true } }),
component: TeamPage,
})
function TeamPage() {
const team = Route.useLoaderData()
const [name, setName] = useState(team.name)
const handleSave = async () => {
await updateTeamName({ data: { teamId: team.id, name } })
}
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={handleSave}>Save</button>
{team.members?.map((m) => (
<div key={m.id}>{m.email} — {m.role}</div>
))}
</div>
)
}
Key insight:
createServerFnwith.inputValidator()gives you runtime validation AND compile-time types from a single Zod schema. No tRPC server needed, no API route boilerplate, no manualfetchcalls. The network boundary becomes invisible.
TanStack Router provides the strongest type safety of any React router. Route parameters, search parameters, loader data, and navigation are all fully typed:
// routes/dashboard/analytics.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
const AnalyticsSearchSchema = z.object({
dateRange: z.enum(['7d', '30d', '90d']).default('30d'),
metric: z.enum(['pageviews', 'visitors', 'conversions']).optional(),
brandId: z.string().uuid().optional(),
})
export const Route = createFileRoute('/dashboard/analytics')({
validateSearch: AnalyticsSearchSchema,
loaderDeps: ({ search }) => ({ search }),
loader: async ({ deps: { search }, context: { queryClient } }) => {
await queryClient.ensureQueryData(
analyticsQueryOptions(search.dateRange, search.metric, search.brandId)
)
},
component: AnalyticsDashboard,
})
function AnalyticsDashboard() {
const { dateRange, metric, brandId } = Route.useSearch()
// ^? { dateRange: '7d' | '30d' | '90d'; metric?: 'pageviews' | ... ; brandId?: string }
const navigate = Route.useNavigate()
return (
<div>
<select
value={dateRange}
onChange={(e) =>
navigate({
search: (prev) => ({ ...prev, dateRange: e.target.value as any }),
})
}
>
<option value="7d">Last 7 Days</option>
<option value="30d">Last 30 Days</option>
<option value="90d">Last 90 Days</option>
</select>
{/* Dashboard content */}
</div>
)
}
Every navigate() call, every <Link> component, every useSearch() hook is checked against the route tree at compile time. If you rename a route parameter, TypeScript catches every broken reference.
TanStack Start uses Vite for bundling, which means tree-shaking works correctly out of the box. Server functions are automatically stripped from client bundles -- the client only gets a thin RPC stub that makes an HTTP request.
Practical bundle sizes for a TanStack Start dashboard app:
| Component | Gzipped Size |
|---|---|
| TanStack Router runtime | ~12KB |
| TanStack Query runtime | ~13KB |
| React + ReactDOM | ~44KB |
| App code (10 routes) | ~25-40KB |
| Total initial load | ~90-110KB |
The React runtime is the largest piece. This is an inherent cost of being a React framework -- TanStack Start cannot escape it, but it adds minimal overhead on top.
TanStack Start deploys anywhere Nitro does: Cloudflare Workers, Vercel, Netlify, Node.js, Deno, Bun, AWS Lambda. The cloudflare-module preset generates a Worker-compatible build:
// app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
export default defineConfig({
server: {
preset: 'cloudflare-module',
},
})
Deploy with a single command:
wrangler deploy
Key insight: TanStack Start is the only React framework with first-class Cloudflare Workers support that does not require vendor-specific adapters or middleware hacks. If you deploy to Cloudflare, this is the path of least resistance.
Next.js needs no introduction. It is the most widely deployed React framework, backed by Vercel, and the default choice for most teams starting a React project. Version 16 (October 2025) made Turbopack the default bundler and stabilized Partial Prerendering.
But "most popular" and "best for your use case" are different claims. Let us examine what Next.js actually ships to the browser.
Next.js App Router defaults to Server Components. Components are server-rendered unless you add "use client" at the top of the file. This sounds like it should produce smaller bundles -- and for content-heavy pages, it does.
The problem appears when you build interactive applications. A SaaS dashboard is mostly "use client" components. At that point, you are shipping React, ReactDOM, the Next.js router runtime, AND the RSC protocol overhead for hydrating the server-rendered shell:
// app/dashboard/page.tsx (Server Component - minimal JS)
import { DashboardShell } from './dashboard-shell'
export default async function DashboardPage() {
const data = await fetch('https://api.example.com/metrics')
const metrics = await data.json()
return <DashboardShell initialMetrics={metrics} />
}
// app/dashboard/dashboard-shell.tsx
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
export function DashboardShell({ initialMetrics }: { initialMetrics: Metrics }) {
const [metrics, setMetrics] = useState(initialMetrics)
const [dateRange, setDateRange] = useState('30d')
const router = useRouter()
useEffect(() => {
const interval = setInterval(async () => {
const res = await fetch(`/api/metrics?range=${dateRange}`)
setMetrics(await res.json())
}, 30000)
return () => clearInterval(interval)
}, [dateRange])
return (
<div>
<select
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
>
<option value="7d">7 Days</option>
<option value="30d">30 Days</option>
<option value="90d">90 Days</option>
</select>
<MetricsGrid data={metrics} />
<ChartPanel data={metrics} />
</div>
)
}
// app/api/metrics/route.ts
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const range = searchParams.get('range') || '30d'
const metrics = await db.getMetrics(range)
return NextResponse.json(metrics)
}
Notice the pattern: Server Component fetches initial data, passes it to a Client Component, and then the Client Component re-fetches via an API route for updates. You end up writing the data fetching logic twice and maintaining a separate API route.
Where Next.js genuinely excels on the client side is asset optimization. next/image handles responsive images, lazy loading, format conversion (WebP/AVIF), and blur placeholders automatically:
import Image from 'next/image'
export function HeroSection() {
return (
<Image
src="/hero.jpg"
alt="Product screenshot"
width={1200}
height={800}
priority // Preloads for LCP
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
)
}
next/font eliminates layout shift from font loading by self-hosting fonts and generating optimal @font-face declarations:
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export default function RootLayout({ children }) {
return (
<html className={inter.className}>
<body>{children}</body>
</html>
)
}
These built-in optimizations are difficult to replicate manually and provide measurable LCP improvements, particularly for marketing sites and content platforms.
Next.js 16 with Turbopack has improved significantly, but the framework runtime is not small:
| Component | Gzipped Size |
|---|---|
| React + ReactDOM | ~44KB |
| Next.js router + runtime | ~30-50KB |
| RSC protocol layer | ~8-15KB |
| App code (10 routes) | ~25-40KB |
| Total initial load | ~110-150KB |
The RSC protocol adds overhead that TanStack Start does not have. For content-heavy sites where most components are Server Components, this overhead is justified by the reduction in component JavaScript. For interactive dashboards, it is pure overhead.
Next.js works on other platforms, but it works best on Vercel. Features like ISR, Image Optimization, edge middleware, and analytics are either Vercel-only or require extra configuration elsewhere.
Deploying Next.js to Cloudflare Workers is possible via OpenNext but requires significant configuration and does not support all features. This is not a theoretical concern -- it is a deployment constraint that should factor into your decision.
The Remix story is complicated in 2026, so let us untangle it.
Timeline:
This means if you were a Remix user, your migration path is React Router v7 (for React) or TanStack Start (for a more opinionated framework experience). Many chose TanStack Start.
React Router v7 with framework mode gives you the Remix experience -- file-based routing, loaders, actions, and form handling -- inside React Router:
// routes/contacts/$contactId.tsx
import type { Route } from './+types/contacts.$contactId'
import { Form, useLoaderData } from 'react-router'
export async function loader({ params }: Route.LoaderArgs) {
const contact = await db.contacts.findUnique({
where: { id: params.contactId },
})
if (!contact) throw new Response('Not Found', { status: 404 })
return { contact }
}
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData()
const name = formData.get('name') as string
await db.contacts.update({
where: { id: params.contactId },
data: { name },
})
return { ok: true }
}
export default function Contact() {
const { contact } = useLoaderData<typeof loader>()
return (
<Form method="post">
<input name="name" defaultValue={contact.name} />
<button type="submit">Save</button>
</Form>
)
}
The single most compelling client-side feature of the Remix/React Router lineage is progressive enhancement. Forms work without JavaScript:
// This form submits and works even if JS fails to load
export default function SearchPage() {
return (
<Form method="get" action="/search">
<input type="text" name="q" placeholder="Search..." />
<button type="submit">Search</button>
</Form>
)
}
When JavaScript loads, the same form is enhanced with client-side navigation, optimistic UI, and pending states. This is not achievable in TanStack Start or Next.js App Router -- both require JavaScript for navigation.
Key insight: If your application must work on unreliable networks, in low-bandwidth regions, or for users who disable JavaScript, the Remix/React Router progressive enhancement model is the only viable option in the React ecosystem.
Remix v3 is dropping React for a Preact fork with no migration path from v2. This is a deliberate break. The team wants to own their rendering layer and optimize for LLM-friendly code generation and web standards.
For React developers, this means Remix v3 is a different framework entirely. Do not plan a migration to it if you want to stay in the React ecosystem.
SvelteKit is not a React framework. Including it here is deliberate: if your primary concern is client-side performance, you should seriously consider leaving React behind.
Svelte compiles your components to vanilla JavaScript at build time. There is no virtual DOM, no runtime diffing, no framework code shipped to the browser. The result is dramatically smaller bundles and faster runtime performance.
React re-renders entire component trees and diffs a virtual DOM. Svelte generates surgical DOM updates at compile time:
<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types'
export let data: PageData
let dateRange = '30d'
let metrics = data.metrics
// Reactive declaration: re-runs when dateRange changes
$: filteredMetrics = metrics.filter((m) => m.range === dateRange)
$: totalRevenue = filteredMetrics.reduce((sum, m) => sum + m.revenue, 0)
async function refreshMetrics() {
const res = await fetch(`/api/metrics?range=${dateRange}`)
metrics = await res.json()
}
</script>
<select bind:value={dateRange} on:change={refreshMetrics}>
<option value="7d">7 Days</option>
<option value="30d">30 Days</option>
<option value="90d">90 Days</option>
</select>
<p>Total Revenue: ${totalRevenue.toLocaleString()}</p>
{#each filteredMetrics as metric}
<div class="metric-card">
<h3>{metric.name}</h3>
<span>{metric.value}</span>
</div>
{/each}
// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'
import { db } from '$lib/server/db'
export const load: PageServerLoad = async () => {
const metrics = await db.getMetrics('30d')
return { metrics }
}
The compiled output for this component is roughly 2-3KB. An equivalent React component with useState, useEffect, and useMemo would be larger in application code AND require the 44KB React runtime.
SvelteKit bundle sizes for a dashboard application:
| Component | Gzipped Size |
|---|---|
| Svelte runtime | ~2KB |
| SvelteKit router | ~5KB |
| App code (10 routes) | ~15-25KB |
| Total initial load | ~22-32KB |
This is 3-5x smaller than any React framework. The difference is not marginal -- it is a category difference.
Svelte 5 introduced runes, which replace the $: reactive declarations with a more explicit API:
<script lang="ts">
let count = $state(0)
let doubled = $derived(count * 2)
function increment() {
count++ // Direct mutation, Svelte tracks it
}
</script>
<button onclick={increment}>
{count} x 2 = {doubled}
</button>
Runes make Svelte's reactivity more predictable and closer to signals-based reactivity (like SolidJS). The compiled output is still minimal.
SvelteKit has its own progressive enhancement story through form actions:
<!-- src/routes/settings/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms'
import type { ActionData } from './$types'
export let form: ActionData
</script>
<form method="POST" action="?/updateProfile" use:enhance>
<input name="name" value={form?.name ?? ''} />
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<button type="submit">Save</button>
</form>
// src/routes/settings/+page.server.ts
import type { Actions } from './$types'
import { fail } from '@sveltejs/kit'
export const actions: Actions = {
updateProfile: async ({ request }) => {
const data = await request.formData()
const name = data.get('name') as string
if (name.length < 2) {
return fail(400, { name, error: 'Name must be at least 2 characters' })
}
await db.updateProfile({ name })
return { success: true }
},
}
The trade-off for Svelte's performance is ecosystem size. As of March 2026:
| Metric | React | Svelte |
|---|---|---|
| npm weekly downloads | ~28M | ~1.5M |
| Component libraries | 100+ mature | ~15 mature |
| Job postings (US) | ~45,000 | ~3,000 |
| Stack Overflow questions | ~450K | ~25K |
| UI kits (production-ready) | MUI, Radix, Shadcn, Mantine, Chakra | Skeleton, Melt, Bits UI |
If you need a specific integration -- a rich text editor, a data grid, a charting library -- it probably exists for React and may not exist for Svelte.
Astro takes the most radical approach: ship zero JavaScript by default. Every component renders to static HTML unless you explicitly opt into client-side interactivity with a directive.
Since Cloudflare acquired Astro in January 2026, the framework has strong infrastructure backing and is tightly integrated with the Cloudflare platform.
Astro's island architecture lets you embed interactive components from any framework (React, Svelte, Vue, SolidJS) into otherwise static pages:
---
// src/pages/pricing.astro
import Header from '../components/Header.astro'
import PricingTable from '../components/PricingTable.astro'
import PricingCalculator from '../components/PricingCalculator.tsx'
import Testimonials from '../components/Testimonials.svelte'
import Footer from '../components/Footer.astro'
---
<html>
<body>
<!-- Static: 0 JS -->
<Header />
<!-- Static: 0 JS -->
<PricingTable />
<!-- Interactive island: React component, hydrates when visible -->
<PricingCalculator client:visible />
<!-- Interactive island: Svelte component, hydrates on page load -->
<Testimonials client:load />
<!-- Static: 0 JS -->
<Footer />
</body>
</html>
The client:* directives control when islands hydrate:
| Directive | Behavior | Use Case |
|---|---|---|
client:load |
Hydrate immediately on page load | Critical interactive elements |
client:idle |
Hydrate when browser is idle | Non-critical widgets |
client:visible |
Hydrate when scrolled into view | Below-the-fold content |
client:media |
Hydrate when media query matches | Responsive components |
client:only |
Skip SSR, render only on client | Browser-only APIs |
An Astro page with no client: directives ships zero JavaScript:
---
// src/pages/blog/[slug].astro
import { getEntry } from 'astro:content'
import BlogLayout from '../../layouts/BlogLayout.astro'
const { slug } = Astro.params
const post = await getEntry('blog', slug)
const { Content } = await post.render()
---
<BlogLayout title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<time>{post.data.date.toLocaleDateString()}</time>
<Content />
</article>
</BlogLayout>
This page is pure HTML and CSS. No React runtime, no router, no hydration. The browser receives exactly what it needs and nothing more.
Astro's bundle strategy depends entirely on how many islands you use:
| Scenario | JS Shipped |
|---|---|
| Blog post (no islands) | 0KB |
| Marketing page (1 React island) | ~50KB (React runtime + component) |
| Marketing page (1 Svelte island) | ~5KB (Svelte component, no runtime) |
| Dashboard (many React islands) | ~100-150KB (approaches SPA territory) |
Key insight: Astro's advantage disappears when most of your page is interactive. If you need a full dashboard, you are effectively building a React SPA inside Astro with extra hydration overhead. Use Astro for content-heavy sites with isolated interactivity, not for applications.
Astro is not designed for applications with heavy client-side state. If you find yourself adding client:load to most components, you have the wrong framework. Specific pain points:
| Feature | TanStack Start | Next.js 16 | React Router v7 | SvelteKit | Astro 6 |
|---|---|---|---|---|---|
| Language | React/TSX | React/TSX | React/TSX | Svelte/TS | Any (Astro/TSX) |
| Bundler | Vite | Turbopack | Vite | Vite | Vite |
| SSR | Yes | Yes | Yes | Yes | Yes |
| SSG | Yes | Yes | Yes | Yes | Yes (default) |
| Streaming SSR | Yes | Yes | Yes | Yes | Yes |
| RSC Support | No (planned) | Yes (core) | Experimental | N/A | N/A |
| Server Functions | createServerFn (RPC) | Server Actions | Actions/Loaders | Form Actions | API Routes |
| Type-Safe Routing | Full (params, search, loader) | Partial | Partial | Partial | No |
| Progressive Enhancement | No | No | Yes | Yes (use:enhance) | N/A (static) |
| Image Optimization | Manual | Built-in (next/image) | Manual | Manual (@sveltejs/enhanced-img) | Built-in (astro:assets) |
| Partial Prerendering | No | Yes (PPR) | No | No | Yes (islands) |
| Client-Side Cache | TanStack Query (built-in) | fetch cache | Manual | Manual | N/A |
| Code Splitting | Route-based (automatic) | Route-based (automatic) | Route-based | Route-based | Per-island |
| Platform | TanStack Start | Next.js 16 | React Router v7 | SvelteKit | Astro 6 |
|---|---|---|---|---|---|
| Cloudflare Workers | Native | Via OpenNext | Native | Via adapter | Native (acquired) |
| Vercel | Yes | Native | Yes | Yes | Yes |
| Netlify | Yes | Yes | Yes | Yes | Yes |
| Node.js | Yes | Yes | Yes | Yes | Yes |
| Deno | Yes | Community | Yes | Yes | Yes |
| AWS Lambda | Yes | Yes | Yes | Yes | Yes |
| Static hosting | Yes (SSG) | Yes (SSG) | Yes (SSG) | Yes (SSG) | Yes (default) |
| Docker | Yes | Yes | Yes | Yes | Yes |
| Aspect | TanStack Start | Next.js 16 | React Router v7 | SvelteKit | Astro 6 |
|---|---|---|---|---|---|
| Dev server startup | Fast (Vite) | Fast (Turbopack) | Fast (Vite) | Fast (Vite) | Fast (Vite) |
| HMR speed | <100ms | <100ms (16.2) | <100ms | <100ms | <100ms |
| Learning curve | Medium | High (RSC model) | Low-Medium | Medium (new language) | Low |
| Tutorials/guides | Growing | Extensive | Moderate | Good | Good |
| Error messages | Good | Improving | Good | Excellent | Good |
| Community size | Small but growing | Largest | Medium | Medium | Medium |
To make this comparison concrete, here are bundle sizes for a realistic dashboard application with 10 routes, a data table, charts, and form inputs. Sizes are gzipped, measured at the initial page load (not total application size).
| Framework | Framework Runtime | Router | App Code | Total |
|---|---|---|---|---|
| TanStack Start | 44KB (React) | 12KB | 30KB | ~86KB |
| Next.js 16 | 44KB (React) + 15KB (RSC) | 35KB | 30KB | ~124KB |
| React Router v7 | 44KB (React) | 18KB | 30KB | ~92KB |
| SvelteKit | 2KB (Svelte) | 5KB | 18KB | ~25KB |
| Astro (React islands) | 44KB (React) | 0KB | 30KB | ~74KB* |
*Astro does not include a client-side router, so navigation triggers full page loads unless using View Transitions.
Initial JS Payload (gzipped KB)
SvelteKit ████████ 25KB
Astro* ████████████████████████ 74KB
TanStack ██████████████████████████████ 86KB
RR v7 ████████████████████████████████ 92KB
Next.js ████████████████████████████████████████████ 124KB
0 20 40 60 80 100 120 140
On a 3G connection (400KB/s effective throughput after overhead):
| Framework | Download Time | Parse + Execute | Total TTI Penalty |
|---|---|---|---|
| SvelteKit | ~60ms | ~30ms | ~90ms |
| Astro | ~185ms | ~80ms | ~265ms |
| TanStack Start | ~215ms | ~100ms | ~315ms |
| React Router v7 | ~230ms | ~110ms | ~340ms |
| Next.js 16 | ~310ms | ~140ms | ~450ms |
Key insight: The gap between SvelteKit and React-based frameworks is structural. React's runtime (~44KB) is a fixed tax. If bundle size is your primary constraint, no React framework can compete with Svelte's compiler approach.
After initial load, subsequent route transitions fetch only the new route's code. This is where code-splitting matters:
| Framework | Avg Route Chunk Size | Prefetching | Cache Strategy |
|---|---|---|---|
| TanStack Start | 5-15KB | Link hover | TanStack Query SWR |
| Next.js 16 | 8-20KB | Link viewport | fetch cache + RSC payload |
| React Router v7 | 5-15KB | Manual | Manual |
| SvelteKit | 3-8KB | Link hover | Built-in |
| Astro | Full page* | Prefetch API | Browser cache |
*Astro with View Transitions prefetches full pages but only swaps the changed content.
Based on aggregated data from the Chrome User Experience Report and framework-specific benchmarks, here are typical Core Web Vitals scores for well-built sites:
| Metric | TanStack Start | Next.js 16 | SvelteKit | Astro |
|---|---|---|---|---|
| LCP | 1.2-1.8s | 0.8-1.4s | 1.0-1.5s | 0.5-1.0s |
| INP | 50-120ms | 60-150ms | 30-80ms | 20-50ms |
| CLS | 0.02-0.05 | 0.01-0.03 | 0.02-0.05 | 0.01-0.02 |
Astro wins here because it ships zero or minimal JS. Next.js is strong due to next/image and next/font. TanStack Start has no built-in image optimization.
| Metric | TanStack Start | Next.js 16 | SvelteKit | Astro |
|---|---|---|---|---|
| LCP | 1.0-1.5s | 1.2-2.0s | 0.8-1.2s | N/A* |
| INP | 60-130ms | 80-200ms | 40-100ms | N/A* |
| CLS | 0.01-0.03 | 0.02-0.05 | 0.01-0.03 | N/A* |
*Astro is not recommended for dashboard use cases.
TanStack Start edges ahead of Next.js for dashboards because its smaller router and TanStack Query integration reduce main-thread work. SvelteKit wins on raw numbers but requires the Svelte ecosystem.
| Metric | TanStack Start | Next.js 16 | SvelteKit | Astro |
|---|---|---|---|---|
| LCP | 1.0-1.6s | 0.8-1.2s | 0.9-1.4s | 0.6-1.0s |
| INP | 70-150ms | 80-180ms | 40-100ms | 30-80ms |
| CLS | 0.02-0.04 | 0.01-0.02 | 0.02-0.04 | 0.01-0.02 |
Next.js wins e-commerce because ISR + image optimization + PPR are purpose-built for product pages. Astro with selective islands is the performance champion for catalog-style pages.
How each framework handles data fetching has a direct impact on client-side performance. The framework's approach determines whether users see stale data, how often the browser makes network requests, and how much JavaScript is needed for the data layer.
TanStack Start integrates natively with TanStack Query, giving you stale-while-revalidate (SWR) caching, background refetching, and deduplication out of the box:
// routes/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useSuspenseQuery, queryOptions } from '@tanstack/react-query'
import { getMetrics } from '../server/metrics'
const metricsOptions = (range: string) =>
queryOptions({
queryKey: ['metrics', range],
queryFn: () => getMetrics({ data: { range } }),
staleTime: 30_000, // Consider fresh for 30 seconds
gcTime: 5 * 60_000, // Keep in cache for 5 minutes
refetchInterval: 60_000, // Background refetch every minute
})
export const Route = createFileRoute('/dashboard')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(metricsOptions('30d')),
component: Dashboard,
})
function Dashboard() {
const [range, setRange] = useState('30d')
// Data is already cached from the loader — instant render
// Subsequent range changes trigger SWR: show stale, fetch fresh
const { data, isFetching } = useSuspenseQuery(metricsOptions(range))
return (
<div>
{isFetching && <span className="refresh-indicator" />}
<MetricsGrid data={data} />
</div>
)
}
The critical detail: the loader prefetches data during SSR, and TanStack Query's cache is hydrated on the client. Subsequent navigations to the same route reuse cached data instantly while refetching in the background.
Next.js has its own caching layer built around the fetch API and React Server Components. In v16, the use cache directive replaced the implicit caching model:
// app/dashboard/page.tsx
import { Suspense } from 'react'
async function MetricsPanel({ range }: { range: string }) {
'use cache'
const res = await fetch(`https://api.example.com/metrics?range=${range}`, {
next: { revalidate: 60 }, // Revalidate every 60 seconds
})
const data = await res.json()
return <MetricsGrid data={data} />
}
export default function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ range?: string }>
}) {
const { range = '30d' } = await searchParams
return (
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel range={range} />
</Suspense>
)
}
The Next.js cache operates at the server level. Client-side caching for interactive components still requires a separate solution (React Query, SWR, or manual state management).
SvelteKit uses load functions that run on the server and pass data to components. Client-side caching is minimal -- SvelteKit refetches data on every navigation by default:
// src/routes/dashboard/+page.server.ts
export const load: PageServerLoad = async ({ depends }) => {
depends('app:metrics') // Register dependency for invalidation
const metrics = await db.getMetrics('30d')
return { metrics }
}
<script lang="ts">
import { invalidate } from '$app/navigation'
export let data
// Manual refetch by invalidating the dependency
function refresh() {
invalidate('app:metrics')
}
</script>
SvelteKit's approach is simpler but less powerful than TanStack Query. You do not get automatic background refetching, SWR, or request deduplication without adding a library.
| Capability | TanStack Start | Next.js 16 | React Router v7 | SvelteKit | Astro |
|---|---|---|---|---|---|
| SWR (stale-while-revalidate) | Built-in (Query) | Manual | Manual | Manual | N/A |
| Background refetch | Built-in | Manual | Manual | Manual | N/A |
| Request deduplication | Built-in | Server-side only | Manual | Manual | N/A |
| Optimistic updates | Built-in (mutations) | Server Actions | Form revalidation | use:enhance | N/A |
| Cache persistence | In-memory (client) | Server + CDN | None | None | CDN only |
| Cache hydration (SSR to client) | Automatic | Automatic (RSC) | Manual | Automatic | N/A |
Key insight: If your application requires frequent data updates (dashboards, feeds, collaboration tools), TanStack Start's built-in Query integration saves significant client-side code. In Next.js, you typically add React Query anyway for interactive data -- at which point you are paying for both the RSC cache AND the Query cache.
Client-side state management is where framework philosophy shows up most clearly. The question is not which state library to use, but how much state management the framework handles for you and how much remains your responsibility.
The most underrated form of state management. URL search parameters are shareable, bookmarkable, and survive page refreshes. Each framework handles them differently:
// TanStack Start — Validated, typed URL state
const Route = createFileRoute('/search')({
validateSearch: z.object({
q: z.string().default(''),
page: z.number().default(1),
sort: z.enum(['relevance', 'date', 'price']).default('relevance'),
}),
})
// Next.js — Manual parsing, no validation
const searchParams = useSearchParams()
const q = searchParams.get('q') || ''
const page = parseInt(searchParams.get('page') || '1', 10)
// SvelteKit — Via $page store
import { page } from '$app/stores'
$: q = $page.url.searchParams.get('q') || ''
// React Router v7 — Via useSearchParams
const [searchParams] = useSearchParams()
const q = searchParams.get('q') || ''
TanStack Router's search parameter validation is unique. Every other framework treats search params as untyped strings.
| Framework | Server State Pattern | Client State Pattern | Overlap |
|---|---|---|---|
| TanStack Start | Server functions + Query cache | React state + Query | Minimal -- Query bridges both |
| Next.js | RSC + Server Actions | React state + external lib | Significant -- RSC and client state are separate worlds |
| React Router v7 | Loaders + Actions | React state | Moderate -- loaders handle most server state |
| SvelteKit | Load functions + Actions | Svelte stores | Minimal -- load functions handle server state cleanly |
| Astro | Build-time data fetching | Per-island state | None -- islands are isolated |
Not every application needs Zustand, Jotai, or Redux. Here is when you actually need one:
| Situation | Framework-Provided Solution | Need External Library? |
|---|---|---|
| Server data on screen | Loader/Query (all frameworks) | No |
| Form input state | useState/bind (all frameworks) | No |
| URL-driven filters | Search params (all frameworks) | No |
| Cross-component UI state (modals, sidebars) | Context/stores | Usually no |
| Complex client-only state (drag-and-drop, canvas) | None | Yes -- Zustand, Jotai, Svelte stores |
| Real-time collaboration | None | Yes -- WebSocket + state sync |
| Undo/redo history | None | Yes -- Immer or custom |
Most dashboard applications can get by with URL state for filters, TanStack Query (or equivalent) for server data, and React context or Svelte stores for UI state. Adding Redux or MobX to a new project in 2026 is almost always unnecessary complexity.
Authentication affects client-side architecture because it determines what JavaScript ships for protected vs. public routes, how redirects work, and whether the auth check happens before or after hydration.
TanStack Start provides middleware that runs before route handlers:
// middleware/auth.ts
import { createMiddleware } from '@tanstack/react-start'
import { getSession } from '../server/auth'
export const authMiddleware = createMiddleware().server(async ({ next }) => {
const session = await getSession()
if (!session) {
throw redirect({ to: '/login' })
}
return next({ context: { user: session.user } })
})
// routes/dashboard.tsx — Protected route
export const Route = createFileRoute('/dashboard')({
beforeLoad: ({ context }) => {
// Context from middleware is available and typed
if (!context.user) throw redirect({ to: '/login' })
},
loader: ({ context: { user, queryClient } }) =>
queryClient.ensureQueryData(dashboardOptions(user.orgId)),
component: Dashboard,
})
The auth check runs on the server during SSR and during client-side navigation. Protected route code is still downloaded (code-split per route), but unauthenticated users are redirected before the component renders.
Next.js uses edge middleware that runs before any route:
// middleware.ts (runs at the edge, before SSR)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('session')
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
}
Next.js middleware runs at the CDN edge and can redirect before the server even starts rendering. This is faster for unauthenticated users but adds complexity for authenticated state that needs to flow into components.
SvelteKit uses hooks.server.ts to intercept all requests:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit'
import { redirect } from '@sveltejs/kit'
export const handle: Handle = async ({ event, resolve }) => {
const session = await getSession(event.cookies)
if (session) {
event.locals.user = session.user
}
// Protect routes
if (event.url.pathname.startsWith('/dashboard') && !event.locals.user) {
throw redirect(303, '/login')
}
return resolve(event)
}
| Framework | Auth Check Location | Client Impact |
|---|---|---|
| TanStack Start | Server function / beforeLoad | Redirect happens before component code loads; auth state in router context |
| Next.js | Edge middleware / RSC | Redirect at CDN edge (fastest); auth state must be passed through RSC |
| React Router v7 | Loader | Redirect happens server-side; auth state via loader data |
| SvelteKit | hooks.server.ts | Redirect server-side; auth state via locals |
| Astro | Middleware / page frontmatter | Redirect server-side; no client auth state needed for static pages |
Key insight: Next.js edge middleware provides the fastest auth redirect (CDN level), but TanStack Start's typed middleware context gives the cleanest DX for passing auth state through the application. For most applications, the performance difference is negligible -- the DX difference is not.
If you are moving from one framework to another, here are realistic migration strategies and effort estimates.
This is the smoothest migration path because Pages Router components are already client-side React. Steps:
getServerSideProps / getStaticProps with TanStack Start loaderspages/ directory to TanStack Router file-based routesnext/router with TanStack Router hookscreateServerFn server functionsnext/image and next/font with manual alternatives or Vite pluginsEstimated effort: 2-4 weeks for a medium application (20-40 routes).
Harder because you must convert Server Components back to client components:
"use client" directives (everything is client in TanStack Start)createServerFnEstimated effort: 4-8 weeks for a medium application. RSC patterns do not have 1:1 equivalents.
TanStack provides an official migration guide. Key steps:
loader / action exports with TanStack loaders and server functions<Form> components with standard forms + server function callsEstimated effort: 1-3 weeks. Conceptually similar but different APIs.
This is a rewrite, not a migration. You are changing languages:
Estimated effort: 6-12 weeks for a medium application. Plan for it to take twice as long as you think.
| From / To | TanStack Start | Next.js 16 | SvelteKit | Astro |
|---|---|---|---|---|
| Next.js Pages | 2-4 weeks | 4-6 weeks (to App Router) | 6-12 weeks | 2-4 weeks (content only) |
| Next.js App | 4-8 weeks | N/A | 8-16 weeks | 3-6 weeks (content only) |
| Remix / RR v7 | 1-3 weeks | 3-6 weeks | 6-12 weeks | 2-4 weeks (content only) |
| SvelteKit | 4-8 weeks | 4-8 weeks | N/A | 2-4 weeks (content only) |
| CRA / Vite SPA | 1-2 weeks | 2-4 weeks | 4-8 weeks | N/A (not suitable) |
Recommended: TanStack Start (React) or SvelteKit (non-React)
Rationale: Dashboards are client-heavy applications. You need fast route transitions, client-side caching, and minimal framework overhead. TanStack Start's built-in Query integration and type-safe routing are designed for this. SvelteKit wins if you are willing to leave React.
Next.js is viable but the RSC model adds complexity for interactive applications. The "server-first" default fights against dashboard UX patterns.
Recommended: Astro (static-first) or Next.js (dynamic content)
Rationale: Marketing sites are content-heavy with isolated interactivity. Astro's zero-JS default and island architecture are ideal. If you need dynamic features (personalization, A/B testing, auth), Next.js with ISR and image optimization is the mature choice.
Recommended: Astro (specifically Starlight for docs)
Rationale: Zero JavaScript by default means perfect Lighthouse scores. Astro's content collections make managing Markdown/MDX straightforward. Nothing else comes close for this use case.
Recommended: SvelteKit
Rationale: Svelte's compiler approach eliminates the framework runtime. No React framework can match ~2KB of framework overhead. If bundle size is a hard constraint (embedded widgets, low-bandwidth markets), Svelte is the only serious option.
Recommended: Next.js or TanStack Start
Rationale: Next.js has the largest talent pool. TanStack Start is React-based and increasingly familiar to developers who use TanStack Query (downloaded ~10M times weekly). Both are defensible choices for team scalability.
Recommended: TanStack Start or Astro
Rationale: TanStack Start has native Cloudflare Workers support via Nitro. Astro is now owned by Cloudflare. Next.js on Workers requires OpenNext and sacrifices features.
Recommended: React Router v7 (framework mode) or SvelteKit
Rationale: These are the only frameworks where forms work without JavaScript. If you are building for government, accessibility-critical, or unreliable-network contexts, progressive enhancement is not optional.
Recommended: TanStack Start
Rationale: If you have an existing Create React App or Vite SPA, TanStack Start is the easiest migration because it is client-first. You can add SSR and server functions incrementally without rewriting your components. Next.js App Router would require converting most components to either Server Components or "use client" annotated modules.
These mistakes waste bundle budget regardless of which framework you choose:
| Do Not | Do Instead | Why |
|---|---|---|
Import entire icon libraries (import * as Icons from 'lucide-react') |
Import individual icons (import { Search } from 'lucide-react') |
Tree-shaking cannot eliminate unused icons from barrel exports in all bundlers |
Use moment.js for date formatting |
Use date-fns or Intl.DateTimeFormat |
moment.js is 70KB gzipped and not tree-shakeable |
| Load all chart data on initial render | Lazy-load chart libraries with React.lazy() or dynamic imports |
Chart libraries (Recharts, Chart.js) are 40-80KB; defer until the user sees them |
Put everything in "use client" (Next.js) |
Keep client components at leaf nodes; pass server data via props | Every "use client" component adds to the client bundle |
| Skip route-based code splitting | Use file-based routing (all frameworks support this) | Without splitting, users download code for routes they never visit |
| Bundle environment-specific polyfills | Use browserslist and let the bundler handle it |
Shipping polyfills for features 95%+ of browsers support wastes bytes |
| Inline large JSON datasets in HTML | Fetch data after hydration or use streaming SSR | Large inline JSON blocks increase HTML size and parse time |
| Use CSS-in-JS runtime libraries (styled-components, emotion) | Use static CSS extraction (Tailwind, CSS Modules, vanilla-extract) | Runtime CSS-in-JS adds 10-15KB and increases hydration time |
| Skip image optimization | Use framework-provided image components or manual srcset |
Unoptimized images are the largest LCP bottleneck on most sites |
| Fetch data in useEffect on every component mount | Use a caching layer (TanStack Query, SWR, or framework loaders) | Redundant fetches increase TTI and create layout shift |
| Use a full state management library for simple UI state | Use URL state (search params) for filter/sort state | URL state is free, shareable, and survives refresh |
| Ship development-only code to production | Ensure NODE_ENV=production and remove debug panels |
React DevTools hooks, console logs, and debug boundaries add 5-10KB |
Ask these questions in order. Each answer narrows your options:
Mostly static content → Astro
Mix of content + interactivity → Next.js or SvelteKit
Highly interactive (dashboard, editor, real-time) → TanStack Start or SvelteKit
Yes (team skills, library needs, hiring) → TanStack Start or Next.js
No preference → SvelteKit (best performance) or Astro (best for content)
Cloudflare → TanStack Start or Astro
Vercel → Next.js (best optimization) or any
Multiple platforms → TanStack Start or SvelteKit
Static CDN → Astro
Critical (large team, complex data) → TanStack Start (strongest)
Important → Any modern framework
Not a priority → Astro
Yes → React Router v7 or SvelteKit
No → Any framework
<50KB → SvelteKit or Astro
<100KB → TanStack Start or SvelteKit
<150KB → Any framework
No constraint → Next.js (most features)
Migrating from CRA / Vite SPA → TanStack Start (easiest path)
Migrating from Next.js Pages Router → TanStack Start or Next.js App Router
Migrating from Remix → TanStack Start or React Router v7
Starting fresh → Evaluate all options based on Q1-Q6
| Framework | Hidden Cost |
|---|---|
| TanStack Start | Fewer tutorials, smaller community, edge cases in young framework |
| Next.js | Vercel lock-in for optimal performance, App Router complexity, RSC learning curve |
| React Router v7 | Uncertain future (Remix team focused on v3), less opinionated than alternatives |
| SvelteKit | Smaller hiring pool, fewer component libraries, team retraining cost |
| Astro | Not suitable for applications, limited client-side routing, island state sharing is complex |
There is no universal "best" framework in 2026. The landscape has stratified into clear use-case lanes:
For SaaS dashboards and interactive React applications, TanStack Start offers the best combination of type safety, bundle efficiency, and deployment flexibility. Its client-first model aligns with how dashboards actually work.
For content-heavy sites and marketing pages, Astro's zero-JS default is unbeatable. With Cloudflare backing, it is a safe long-term choice.
For maximum client-side performance regardless of ecosystem, SvelteKit ships 3-5x less JavaScript than any React framework. The ecosystem trade-off is real, but the performance advantage is structural.
For teams deeply invested in Vercel and React, Next.js remains the safest choice. Its ecosystem, tooling, and image optimization are mature. The App Router complexity is the price of entry.
For progressive enhancement, React Router v7 with framework mode carries the Remix torch. It is the only React framework where forms work without JavaScript.
The most important decision is not which framework to pick -- it is which trade-offs you can live with. Every framework sacrifices something. Know what you are giving up, and make sure it is not the thing your users need most.