Next.jsReactFrontendComparison

Next.js vs React: Which Is Better for Your Project?

February 20, 202614 min read

The Question Is Wrong — But the Answer Still Matters

"Next.js or React?" is like "Car or engine?" Next.js is React — with routing, server rendering, an API layer, and a deployment pipeline built around it. React provides the component model. Next.js provides everything else.

Still, the question is valid because the decision between "React SPA with Vite" and "Next.js App Router" has massive implications for architecture, performance, developer experience, and long-term maintainability. We build with both variants every week and give you the honest comparison here — with real code, real numbers, and real project decisions.

Project Structure Comparison

Before we dive into code, it helps to see how the same application organizes itself in both worlds. Take a typical app with product list, detail page, dashboard, and API:

React SPA (Vite)                     Next.js App Router
────────────────────                 ────────────────────
src/                                 app/
├── main.tsx                         ├── layout.tsx
├── App.tsx                          ├── page.tsx
├── router.tsx          <->          ├── products/
├── pages/                           │   ├── page.tsx
│   ├── Home.tsx                     │   └── [slug]/
│   ├── Products.tsx                 │       └── page.tsx
│   ├── ProductDetail.tsx            ├── dashboard/
│   └── Dashboard.tsx                │   └── page.tsx
├── components/                      ├── api/
│   ├── Header.tsx                   │   └── products/
│   ├── ProductCard.tsx              │       └── route.ts
│   └── LoadingSpinner.tsx           ├── components/
├── hooks/                           │   ├── Header.tsx
│   ├── useProducts.ts              │   └── ProductCard.tsx
│   └── useAuth.ts                   └── lib/
├── api/                                 ├── db.ts
│   └── client.ts                        └── auth.ts
└── lib/
    └── utils.ts

+ separate backend (Express)        Everything in one repo.
  on its own port/server            API routes live next to pages.

The difference is immediately visible: with a React SPA you need a separate backend, a separate router, and you have to decide yourself where things go. Next.js gives you a convention that your team understands from day one.

Comparison 1: Routing

React SPA — React Router v7

// src/router.tsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { Home } from "./pages/Home";
import { Products } from "./pages/Products";
import { ProductDetail } from "./pages/ProductDetail";
import { Dashboard } from "./pages/Dashboard";
import { Layout } from "./components/Layout";
import { ProtectedRoute } from "./components/ProtectedRoute";

const router = createBrowserRouter([
  {
    element: <Layout />,
    children: [
      { path: "/", element: <Home /> },
      { path: "/products", element: <Products /> },
      { path: "/products/:slug", element: <ProductDetail /> },
      {
        path: "/dashboard",
        element: (
          <ProtectedRoute>
            <Dashboard />
          </ProtectedRoute>
        ),
      },
    ],
  },
]);

export function App() {
  return <RouterProvider router={router} />;
}

Next.js — App Router

app/
├── layout.tsx              <- Automatic layout
├── page.tsx                <- Route: /
├── products/
│   ├── page.tsx            <- Route: /products
│   └── [slug]/
│       └── page.tsx        <- Route: /products/:slug
└── dashboard/
    ├── layout.tsx          <- Own layout with auth check
    └── page.tsx            <- Route: /dashboard
// app/products/[slug]/page.tsx
export default async function ProductPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const product = await getProduct(slug);

  return <ProductDetail product={product} />;
}

The point: No router setup, no configuration file, no import chain. A file in the right folder = a route. This scales at 5 pages exactly like at 500.

Comparison 2: Data Fetching

This is where the difference becomes most tangible — both in code and in user experience.

React SPA — useEffect + TanStack Query

// src/pages/Products.tsx
import { useQuery } from "@tanstack/react-query";

export function Products() {
  const {
    data: products,
    isLoading,
    error,
  } = useQuery({
    queryKey: ["products"],
    queryFn: async () => {
      const res = await fetch("https://api.example.com/products");
      if (!res.ok) throw new Error("Error loading data");
      return res.json();
    },
  });

  if (isLoading) {
    return (
      <div className="flex justify-center p-12">
        <div className="animate-spin h-8 w-8 border-4 border-blue-500
                        border-t-transparent rounded-full" />
      </div>
    );
  }

  if (error) {
    return <p className="text-red-500">Error: {error.message}</p>;
  }

  return (
    <div className="grid grid-cols-3 gap-6">
      {products.map((p) => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  );
}

Next.js — Server Component

// app/products/page.tsx
import { db } from "@/lib/db";
import { ProductCard } from "@/components/ProductCard";

export default async function ProductsPage() {
  const products = await db.product.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <div className="grid grid-cols-3 gap-6">
      {products.map((p) => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  );
}

What happens here: The Next.js version has no loading state, no error boundary boilerplate, no API intermediary. The component fetches data directly on the server — with a single await. The HTML output goes fully rendered to the browser. The JavaScript for db, findMany, and the fetch logic is not sent to the client.

Concretely: the React SPA version sends TanStack Query (~13 KB gzipped) plus your fetch logic plus loading spinner code to every client. The Server Component version sends only the finished HTML and the code for the interactive ProductCard.

Comparison 3: API Handling

React SPA — Separate Express Server

// backend/src/routes/products.ts (Express)
import { Router } from "express";
import { db } from "../lib/db";
import { authenticate } from "../middleware/auth";

const router = Router();

router.get("/api/products", async (req, res) => {
  try {
    const { category, sort } = req.query;

    const products = await db.product.findMany({
      where: category ? { category: String(category) } : undefined,
      orderBy: { price: sort === "asc" ? "asc" : "desc" },
    });

    res.json(products);
  } catch (error) {
    res.status(500).json({ error: "Internal error" });
  }
});

router.post("/api/products", authenticate, async (req, res) => {
  const product = await db.product.create({ data: req.body });
  res.status(201).json(product);
});

export default router;

For this you need: a separate server process, CORS configuration, custom error handling, port management, separate deployment pipeline.

Next.js — Route Handler

// app/api/products/route.ts
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl;
  const category = searchParams.get("category");
  const sort = searchParams.get("sort");

  const products = await db.product.findMany({
    where: category ? { category } : undefined,
    orderBy: { price: sort === "asc" ? "asc" : "desc" },
  });

  return NextResponse.json(products);
}

export async function POST(request: NextRequest) {
  const session = await auth();
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await request.json();
  const product = await db.product.create({ data: body });

  return NextResponse.json(product, { status: 201 });
}

Same logic, same database queries — but no separate server, no CORS, no port management. The route lives directly next to your pages and deploys together. And if you use Server Components, you often don't need API routes for GET requests at all, because you can access the database directly in the component.

Comparison 4: SEO and Meta Tags

React SPA — react-helmet-async

// src/pages/ProductDetail.tsx
import { Helmet } from "react-helmet-async";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";

export function ProductDetail() {
  const { slug } = useParams();
  const { data: product } = useQuery({
    queryKey: ["product", slug],
    queryFn: () => fetch(`/api/products/${slug}`).then((r) => r.json()),
  });

  if (!product) return null;

  return (
    <>
      <Helmet>
        <title>{product.name} | MyShop</title>
        <meta name="description" content={product.description} />
        <meta property="og:title" content={product.name} />
        <meta property="og:image" content={product.image} />
      </Helmet>
      <div>{/* ... Product UI ... */}</div>
    </>
  );
}

The problem: Search engines see an empty <div id="root"> on first crawl. The Helmet content only gets inserted after JavaScript download, the API call, and rendering. Googlebot can handle this now, but it's slower, less reliable, and for social media previews (Open Graph) it doesn't work at all.

Next.js — generateMetadata

// app/products/[slug]/page.tsx
import { db } from "@/lib/db";
import { Metadata } from "next";

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const product = await db.product.findUnique({ where: { slug } });

  return {
    title: `${product.name} | MyShop`,
    description: product.description,
    openGraph: {
      title: product.name,
      images: [product.image],
    },
  };
}

export default async function ProductPage({ params }: Props) {
  const { slug } = await params;
  const product = await db.product.findUnique({ where: { slug } });

  return <div>{/* ... Product UI ... */}</div>;
}

The result: Meta tags are rendered on the server and included in the initial HTML. Every crawler — Google, LinkedIn, Twitter, Slack previews — sees the correct tags immediately. No flicker, no waiting for client JavaScript.

Server Components in Detail

Server Components are the biggest architectural shift in React since Hooks. Here's a more realistic example — a dashboard widget showing revenue data:

Traditional React (Client Component)

"use client";
import { useState, useEffect } from "react";

export function RevenueWidget() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [period, setPeriod] = useState("30d");

  useEffect(() => {
    setLoading(true);
    fetch(`/api/revenue?period=${period}`)
      .then((r) => {
        if (!r.ok) throw new Error("API error");
        return r.json();
      })
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [period]);

  if (loading) return <Skeleton className="h-48" />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <div>
      <PeriodFilter value={period} onChange={setPeriod} />
      <RevenueChart data={data} />
      <p className="text-2xl font-bold">
        {new Intl.NumberFormat("en-US", {
          style: "currency",
          currency: "USD",
        }).format(data.total)}
      </p>
    </div>
  );
}

Next.js Server Component + Client Interactivity

// app/dashboard/components/RevenueWidget.tsx (Server Component)
import { db } from "@/lib/db";
import { RevenueChart } from "./RevenueChart"; // Client Component
import { PeriodFilter } from "./PeriodFilter"; // Client Component

export default async function RevenueWidget({
  period = "30d",
}: {
  period?: string;
}) {
  const data = await db.order.aggregate({
    _sum: { amount: true },
    _count: true,
    where: {
      createdAt: {
        gte: getStartDate(period),
      },
    },
  });

  const timeline = await db.$queryRaw`
    SELECT DATE(created_at) as day, SUM(amount) as revenue
    FROM orders
    WHERE created_at >= ${getStartDate(period)}
    GROUP BY DATE(created_at)
    ORDER BY day
  `;

  return (
    <div>
      <PeriodFilter current={period} />
      <RevenueChart data={timeline} />
      <p className="text-2xl font-bold">
        {new Intl.NumberFormat("en-US", {
          style: "currency",
          currency: "USD",
        }).format(data._sum.amount)}
      </p>
    </div>
  );
}

Regarding the bundle: The client version sends the entire component plus fetch logic, error handling, and loading states to the browser. The server version sends only the RevenueChart and PeriodFilter — the interactive parts. The database query, SQL logic, Prisma, the aggregation — all of that stays on the server and increases your client bundle by exactly 0 bytes.

Performance Numbers: Real-World Comparison

Typical Lighthouse scores for a marketing site with app section (measured on mobile with 4G throttling):

| Metric | React SPA (Vite) | Next.js (App Router) | Difference | |---------------------------|-------------------|----------------------|---------------| | First Contentful Paint | 2.4 s | 0.8 s | 3x faster | | Largest Contentful Paint | 4.1 s | 1.6 s | 2.5x faster | | Time to Interactive | 4.8 s | 2.1 s | 2.3x faster | | Total Blocking Time | 680 ms | 120 ms | 5.7x less | | Lighthouse Performance | 52 | 94 | +42 points | | Initial JS Bundle | ~210 KB gzip | ~85 KB gzip | -60% |

Why the difference is so large: A React SPA must first load, parse, and execute the entire JavaScript bundle before the user sees anything. Next.js sends server-rendered HTML that's displayed immediately — JavaScript is loaded and hydrated afterwards. For marketing pages and content-heavy sections, this is an enormous advantage.

Important: for pure app sections behind a login (dashboards, tools), the differences are much smaller because SEO and first paint are less critical.

Decision Tree: What Do You Actually Need?

                         ┌──────────────────────┐
                         │   New Web Project     │
                         └──────────┬───────────┘
                                    │
                         ┌──────────▼───────────┐
                         │  Need SEO traffic?    │
                         └──────────┬───────────┘
                            Yes ────┼───── No
                            │       │        │
                    ┌───────▼──┐    │   ┌────▼──────────────┐
                    │ Next.js  │    │   │ Fullstack in one  │
                    │          │    │   │ repo desired?      │
                    └──────────┘    │   └────┬──────────────┘
                                    │   Yes ─┼──── No
                                    │   │    │       │
                                    │ ┌─▼────────┐ ┌─▼──────────┐
                                    │ │ Next.js  │ │ React SPA  │
                                    │ │          │ │ (Vite)     │
                                    │ └──────────┘ └────┬───────┘
                                    │                    │
                                    │            ┌───────▼────────┐
                                    │            │ Will SEO be    │
                                    │            │ relevant later?│
                                    │            └───────┬────────┘
                                    │           Yes ─────┼──── No
                                    │           │        │       │
                                    │    ┌──────▼─────┐  │  ┌───▼────────┐
                                    │    │ Better go  │  │  │ React SPA  │
                                    │    │ Next.js    │  │  │ is perfect │
                                    │    └────────────┘  │  └────────────┘

The honest short version: if you're not sure, go with Next.js. The learning curve is a bit steeper, but you keep all options open. Migrating from Next.js back to a React SPA is easy — the other direction is a complete rewrite.

When We Use What

Theory is one thing. Here are real decisions from our recent projects:

Project A: E-Commerce Replatform

Decision: Next.js

A retailer with 2,000+ product pages needed fast load times and perfect SEO. We used Static Site Generation (SSG) for product pages that automatically update via Incremental Static Regeneration (ISR) on every CMS change. Result: product pages load in under 1 second, organic traffic increased by 34% after relaunch.

Why not React SPA? 2,000 product pages without SSR = invisible to Google. No room for negotiation.

Project B: Internal Analytics Dashboard

Decision: React SPA (Vite + TanStack Query)

A B2B SaaS company needed an internal dashboard with real-time data over WebSockets, complex filter combinations, and drag-and-drop widgets. Everything behind a login, no public pages.

Why not Next.js? Zero SEO requirement, but extremely interactive UI with WebSocket connections that stay permanently open. With Server Components we would have constantly fought the framework because almost every component would have needed "use client". The simpler architecture was the right choice here.

Project C: SaaS with Marketing Site

Decision: Next.js (hybrid)

A startup needed both: a public marketing site with pricing page, blog, and docs (SEO-critical) plus an app behind login (interactive, no SEO). We implemented both in one Next.js project:

  • Marketing pages: Static Site Generation, Server Components, generateMetadata for SEO
  • App section: Client Components with TanStack Query, protected by middleware auth
  • Shared components: design system that works in both sections

Why not two separate projects? One repo, one deployment, one design system, one team. The maintenance costs of two separate frontends would have cost the startup more long-term than the initial extra effort.

When React SPA Is the Better Choice

We're not a Next.js fan club. There are clear cases where a React SPA is the better architecture:

  • Electron apps or desktop software — no server available
  • Extremely interactive tools (whiteboard, IDE, graphics editor) — where 95% of logic lives in the client
  • Micro-frontends in an existing architecture — where you must use existing SPA hosting
  • Existing backend team already has a REST/GraphQL API — and you just need a frontend for it
  • Simple internal tools behind auth — where SSR provides no measurable benefit

In these cases, the leaner setup with Vite, React Router, and TanStack Query is the more honest architecture. Less abstraction, faster builds, simpler mental model.

The Technical Differences at a Glance

| Feature | React SPA (Vite) | Next.js (App Router) | |------------------------|------------------------------|-------------------------------| | Rendering | Client-only | Server + Client (hybrid) | | SEO | Poor (no SSR) | Excellent (SSR/SSG/ISR) | | Routing | React Router (manual) | File-based (automatic) | | Data Fetching | useEffect / TanStack Query | Server Components + await | | API Routes | Separate server needed | Integrated (Route Handlers) | | Image Optimization | Manual | Automatic (next/image) | | Bundle Size | Everything goes to client | Server code stays on server | | Deployment | Static hosting (S3, CDN) | Vercel / Node server / Docker | | Learning Curve | Flatter | Steeper (server vs. client) | | Vendor Lock-in | None | Low (Vercel optimizations) |

Conclusion

React vs. Next.js isn't a matter of faith. It's an architecture decision that depends on your specific requirements. SEO, performance, team size, deployment strategy — those are the factors that matter. Not opinions on Twitter.

Our rule of thumb after dozens of projects with both variants: If your project has a public-facing component that needs to be visible in search engines, there's no way around Next.js. For purely internal, highly interactive applications, a React SPA is often the cleaner solution.


Planning a new project and unsure which architecture is right? We help with the decision — not theoretically, but based on what we deploy to production every week. Talk to us about your project.

SS
SecretStack

AI-native fullstack engineering studio. We build what others can't see — with senior engineers, NDA from first contact.

NDA-first
Senior-only
120+ Projects

Start a Next.js project?

Want to discuss your project with senior engineers?

Get a free consultation