DEV Community

Cover image for Next.js vs React: When Should You Use Each?
Bishoy Bishai
Bishoy Bishai

Posted on • Edited on • Originally published at bishoy-bishai.github.io

Next.js vs React: When Should You Use Each?

Next.js vs. React: Stop Asking "Which Is Better?" Ask This Instead.

The question isn't about features. It's about who renders your app first — and why that changes everything.


Let me tell you about a mistake I've watched teams make more times than I can count.

They're starting a new project. Excitement is high. The tech discussion begins. Someone says "should we use React or Next.js?" Someone else opens a comparison article. The article has a bullet list of features. The team picks based on the longest list of features that match their current requirements.

Six months later, the product has grown. Requirements have shifted. And suddenly they're either:

A) Fighting Next.js's server/client boundaries because they built a deeply stateful, real-time dashboard that didn't need SSR at all — and now every new developer onboarding asks "wait, why is this a Server Component again?"

Or B) Trying to bolt SEO and performance onto a bare React SPA that was always meant to be public-facing, discovering that client-side rendering and Googlebot have a complicated relationship.

Both of these are expensive mistakes. Not because the technology is bad — React and Next.js are both excellent. But because the decision was made for the wrong reasons.

I've been building React applications since 2017. And the question I get asked most consistently by mid-level developers is: "When do I use Next.js and when do I not?"

The tutorials answer this with feature lists. I'm going to answer it with the question that actually matters.

What you'll be able to DO after reading this:

  • Apply the one diagnostic question that cuts through every "React vs. Next.js" debate in under two minutes
  • Understand why the rendering model difference matters — not just that it exists
  • Recognize the two most expensive architectural mistakes teams make, and when you're about to make them
  • Walk away with a decision framework you can use on your next project kickoff, without needing to re-read a comparison article

This is Pillar 4 content — Tactical Analysis. Not a tutorial. Not a beginner guide. This is the thinking you need for architectural decisions, written for developers who've already built things and broken them.


The One Question That Settles It

Before we touch a single feature comparison, I want to give you the diagnostic tool that makes this decision fast and clear. Every time.

"Who is the first person to execute your code — the server or the browser?"

That's it. That's the question.

I know it sounds too simple. Stay with me.

When the browser executes your code first, you're in client-side rendering territory. The user receives an HTML shell — basically empty — and JavaScript that, once downloaded and parsed, builds the UI in the browser. React SPA. This is Vite + React. This is Create React App. The browser does the work.

When the server executes your code first, HTML with actual content is generated before anything hits the browser. The user receives a fully-formed page. The browser can display it before a single line of JavaScript runs. This is Next.js's default behavior — and it's not just an optimization, it's a fundamentally different architecture.

Every consequence of choosing one over the other flows from this distinction. Not from feature lists. From this.

Let me show you what I mean in concrete terms, because the abstraction matters less than what actually happens in practice.


What "Client Renders First" Means in Production

When you build a standard React SPA, here's the sequence of events every time a user opens your app:

  1. Browser requests the page from your server
  2. Server responds with a nearly empty HTML file — <div id="root"></div> and a script tag
  3. Browser downloads your JavaScript bundle (could be 200KB, could be 2MB, depends on your dependencies)
  4. Browser parses and executes the JavaScript
  5. React initializes, renders your component tree
  6. Your components call their useEffect hooks
  7. API calls go out to fetch data
  8. Data comes back
  9. Components re-render with actual content
  10. The user finally sees something meaningful

Steps 3 through 10 all happen in the user's browser. On a fast laptop in Berlin with fiber internet, this feels instant. On a mid-range Android phone in Cairo on a 4G connection that drops to 3G when the elevator doors close — this is a blank screen for 3 to 5 seconds. Sometimes longer.

And for Googlebot? Googlebot doesn't execute JavaScript the way a browser does. It's gotten better — Google now processes JavaScript — but it's slower, it's not guaranteed, and it puts your SEO at the mercy of a crawler's JavaScript budget. For internal tools where nobody cares about Google, this is fine. For a product where organic search is a revenue channel, this is a real problem that doesn't show up in development and shows up painfully in production.

// The standard React SPA data fetch
// What's actually happening: the user sees NOTHING until this whole sequence completes
import React, { useState, useEffect } from 'react';

function ProductListing() {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Step 6-8 in the sequence above.
    // This runs AFTER the JS bundle downloaded and executed.
    // AFTER React initialized.
    // The user has been looking at a blank div (or a spinner) until this moment.
    fetch('/api/products')
      .then(res => res.json())
      .then(data => {
        setProducts(data);
        setLoading(false);
      });
  }, []);

  // The user sees this first: just a spinner.
  // No content. No products. Nothing for the search crawler to index.
  if (loading) return <div>Loading...</div>;

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}  {product.price}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

This works perfectly well for an internal dashboard where users are authenticated, the audience is known, and nobody is Googling their way to your CRM. But ship this as a public product catalog and you've built a performance and SEO problem that will require a framework migration to fully fix.


What "Server Renders First" Changes

Now the same scenario in Next.js with Server Components (App Router):

// app/products/page.tsx
// This is a React Server Component — it runs on the server, not in the browser.
// The user receives actual HTML with products already inside it.
// No loading state. No useEffect. No spinner for the initial load.

interface Product {
  id: string;
  name: string;
  price: number;
}

// Notice: no 'use client' directive. This runs on the server.
// Notice: no useEffect, no useState, no loading state.
// The data is fetched before the HTML is generated.
async function ProductsPage() {
  // This fetch runs on the server during the request.
  // It never runs in the browser.
  // The database (or API) response is baked into the HTML before it leaves the server.
  const response = await fetch('https://api.yourstore.com/products', {
    // Next.js extends fetch with caching control.
    // revalidate: 3600 means "cache this for an hour, then refresh"
    // This is ISR — Incremental Static Regeneration.
    // The user gets static speed WITH dynamic data. Chef's kiss.
    next: { revalidate: 3600 }
  });
  const products: Product[] = await response.json();

  // By the time this JSX reaches the browser, it's already HTML.
  // The browser renders it immediately — no JavaScript needed for the initial view.
  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}  {product.price}</li>
      ))}
    </ul>
  );
}

export default ProductsPage;
Enter fullscreen mode Exit fullscreen mode

Walk through what happens now from the user's perspective:

  1. Browser requests the page
  2. Server fetches data from the API
  3. Server renders the React component tree to HTML — with products already in it
  4. Browser receives HTML with actual content
  5. User sees the product list immediately
  6. JavaScript loads in the background for interactivity (adding to cart, filters, etc.)

The user sees content on step 5. In the SPA version, content appears on step 10. That gap — those missing steps — is what Lighthouse scores measure. That gap is what Googlebot indexes. That gap is what users feel on slow connections.


The Rendering Model Spectrum — This Is Not Binary

Here's where most comparison articles fail you. They present this as a binary choice: SPA (React) vs. SSR (Next.js). It's not. Next.js gives you a spectrum of rendering strategies that you can apply per page, per component, even per fetch call.

Understanding this spectrum is what separates developers who use Next.js correctly from developers who fight it constantly.

** The four strategies:**

Strategy 1: Static Site Generation (SSG)

The page is rendered at build time. HTML is generated once, stored, and served from a CDN. Zero server computation per request. Zero database calls per visitor.

// app/blog/[slug]/page.tsx
// generateStaticParams tells Next.js: "build HTML for all of these at compile time"
// Result: blazing fast, CDN-served pages with zero per-request overhead

export async function generateStaticParams() {
  const posts = await getAllBlogPosts(); // Runs once at build time
  return posts.map(post => ({ slug: post.slug }));
}

async function BlogPost({ params }: { params: { slug: string } }) {
  // This also runs at build time, not per request.
  const post = await getBlogPost(params.slug);
  return <article>{post.content}</article>;
}
Enter fullscreen mode Exit fullscreen mode

Use this when: content doesn't change per user and doesn't change frequently. Blog posts. Marketing pages. Documentation.

Strategy 2: Incremental Static Regeneration (ISR)

Static generation, but with a revalidation window. The page is served statically (CDN speed) and regenerated in the background when the cache expires.

// This product page is generated statically but refreshes every hour.
// Users get static speed. Content stays reasonably fresh.
// No server computation per request — unless the cache is stale.
async function ProductPage({ params }: { params: { id: string } }) {
  const product = await fetch(`/api/products/${params.id}`, {
    next: { revalidate: 3600 } // Regenerate at most once per hour
  }).then(r => r.json());

  return <ProductDetails product={product} />;
}
Enter fullscreen mode Exit fullscreen mode

Use this when: content changes occasionally but not per-user. Product catalogs. News articles. Pricing pages.

Strategy 3: Server-Side Rendering (SSR)

Generated fresh on every request. Full server computation per visit.

// This page is different for every logged-in user.
// It can't be cached — the data is user-specific.
// SSR is the right call here.
import { cookies } from 'next/headers';

async function DashboardPage() {
  const cookieStore = cookies();
  const sessionToken = cookieStore.get('session')?.value;

  // Fetch user-specific data on the server.
  // Sensitive. Can't be cached. Must be fresh.
  const userData = await fetchUserDashboard(sessionToken);

  return <Dashboard data={userData} />;
}
Enter fullscreen mode Exit fullscreen mode

Use this when: content is user-specific, real-time, or can't be cached. User dashboards. Account pages. Personalized feeds.

Strategy 4: Client-Side Rendering (CSR) — still valid inside Next.js

Some parts of your app are highly interactive and genuinely belong in the browser.

// 'use client' tells Next.js: this component renders in the browser
// Use for: real-time interactions, browser APIs, complex local state
'use client';

import { useState } from 'react';

function AddToCartButton({ productId }: { productId: string }) {
  const [isAdding, setIsAdding] = useState(false);
  const [added, setAdded] = useState(false);

  const handleClick = async () => {
    setIsAdding(true);
    await addToCart(productId);
    setIsAdding(false);
    setAdded(true);
  };

  return (
    <button onClick={handleClick} disabled={isAdding}>
      {added ? '✓ Added to Cart' : isAdding ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

A real Next.js application uses all four of these strategies simultaneously. The product listing page is ISR. The product detail is ISR with a shorter window. The cart is CSR. The checkout is SSR (user-specific, must be fresh). The blog is SSG.

This is not complexity for its own sake. This is precision — matching the rendering strategy to the actual data requirements of each part of your application.


The Two Expensive Mistakes

I've seen both of these destroy project timelines. Not hypothetically — in real codebases.

Expensive Mistake #1 — Reaching for Next.js when you need a React SPA

This one is subtler than it looks.

You're building an internal analytics dashboard. Real-time charts. Complex filtering. Heavy client-side state. Users authenticate first — nobody is landing on these pages from Google. SEO is irrelevant. The data updates every 30 seconds via WebSocket.

Someone on the team suggests Next.js because "it's the standard now."

Six months in:

  • Every new component requires the 'use client' decision
  • The WebSocket connection logic is fighting the Server Component model
  • Real-time state is awkward to manage when half your tree is server-rendered
  • Developers keep hitting hydration mismatches that take hours to debug
  • Onboarding new developers requires explaining the client/server boundary before they can write their first component

Was Next.js wrong? Technically no. Was it the right tool for this job? Absolutely not. A Vite + React SPA with React Query for data fetching would have been faster to build, easier to reason about, and had zero hydration issues. The complexity of Next.js added zero value to a private, highly interactive, SEO-irrelevant application.

The diagnostic: Before reaching for Next.js, ask: "Does the server rendering this page first give ANY benefit to my users?" For authenticated-only dashboards where the content is entirely dynamic — the answer is usually no.

Expensive Mistake #2 — Building a public product with bare React

This one is more painful because you don't feel it immediately. You feel it at month four when marketing starts asking why your Core Web Vitals are tanking. You feel it when the product manager notices you're not appearing in Google search for your own product name. You feel it when a user on a slow connection tweets that your app feels broken.

I've watched teams spend three months building a public-facing e-commerce product in bare React and then spend another two months migrating to Next.js because the performance gap was affecting conversion rates. That's five months on what should have been three.

// The telltale sign you need Next.js and you're in a React SPA:
// You start adding libraries to solve problems Next.js solves natively.

// react-helmet for SEO meta tags (Next.js has this built-in)
import { Helmet } from 'react-helmet-async';

// You're pre-rendering with react-snap or similar tools
// You're adding react-query with complex cache configuration
// You're writing custom webpack config for code splitting

// When you're recreating the framework, it's time to use the framework.
Enter fullscreen mode Exit fullscreen mode

The diagnostic: if your app is public-facing, content-heavy, or depends on search engine visibility — Next.js should be your starting point, not a migration destination.


The App Router Reality Check

Okay, I want to spend time on something the comparison articles either gloss over or get wrong: Next.js App Router is a paradigm shift, not just an API change.

The Pages Router (the "old" Next.js with getServerSideProps and getStaticProps) was familiar — it felt like React with some special export functions. The App Router with React Server Components is a genuinely different mental model.

What Most Developers Think: Server Components are just components that fetch data on the server. Basically getServerSideProps but cleaner.

What's Actually True in Production: Server Components don't run in the browser at all. They have no access to browser APIs. They can't use useState or useEffect. They can't have event handlers. They're not hydrated — the browser never receives their JavaScript. They exist only as HTML output from the server.

Why the Gap Exists: Because the tutorials show you a Server Component that fetches data and renders it, and it looks almost exactly like a Client Component. The difference isn't visible until you try to add interactivity and get an error, or until you try to access window and your server crashes.

// ⚠️ This will fail — and the error message isn't always clear about why

// app/some-page/page.tsx — this is a Server Component by default
// No 'use client' directive

import { useState } from 'react'; // ❌ ERROR: useState in a Server Component

async function SomePage() {
  const [count, setCount] = useState(0); // ❌ This doesn't exist on the server

  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
  // ❌ Event handlers don't work in Server Components either
}
Enter fullscreen mode Exit fullscreen mode
// ✅ The correct pattern: Server Component + Client Component composition

// app/some-page/page.tsx — Server Component
// Fetches data on the server, passes it down
async function SomePage() {
  const initialData = await fetchInitialData();

  // InteractiveCounter is a Client Component — it gets hydrated in the browser
  // SomePage itself stays server-only — its JavaScript never ships to the browser
  return (
    <div>
      <h1>Static heading  rendered in HTML, no JS needed</h1>
      <InteractiveCounter initialValue={initialData.count} />
    </div>
  );
}

// components/InteractiveCounter.tsx
'use client'; // ← This component runs in the browser

import { useState } from 'react';

function InteractiveCounter({ initialValue }: { initialValue: number }) {
  const [count, setCount] = useState(initialValue);

  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The mental model shift: in App Router, think of your component tree as two layers. The server layer handles data fetching and generates HTML. The client layer handles interactivity and browser APIs. The server layer wraps the client layer — not the other way around.

Once this clicks, App Router becomes powerful and intuitive. Before it clicks, it's a source of confusing errors and "why doesn't this work" moments that make teams question whether they should have stayed on Pages Router.

My honest advice: if your team is new to Next.js AND new to App Router — consider starting with Pages Router for your first project. It's still fully supported. It has a gentler learning curve. And it teaches you the "why" of server rendering in a way that makes App Router make more sense when you get to it.


A Decision Framework You Can Use Tomorrow

Stop asking "which is better?" Start asking these questions at your project kickoff:

Question 1: Who are your users and how do they arrive?

If users arrive via search engines, social shares, or direct links to content — the server needs to render that content before it reaches them. Next.js.

If users arrive via login and spend their time inside an authenticated application — the initial render can happen in the browser. React SPA is fine.

Question 2: How dynamic is your content, and for whom?

Content that's the same for everyone and changes rarely → Static generation. Next.js with SSG or ISR.

Content that's the same for everyone but changes frequently → Server rendering with short cache windows. Next.js with ISR.

Content that's different per user → Either SSR (in Next.js) or CSR (React SPA or 'use client' in Next.js).

Question 3: What is your team's relationship with the server/client boundary?

Teams with strong full-stack experience tend to find Next.js's model natural. Teams that have only done client-side React may find the App Router's server/client boundary genuinely confusing at first.

Factor in the learning curve. A slightly less optimal tool your team understands deeply ships faster than the perfect tool your team fights constantly.

Question 4: Will this application grow to need both?

Here's the thing about React SPAs: you can always add Next.js later. The migration is painful, but it's a known path. Many successful products started as React SPAs and migrated when SEO and performance became priorities.

But the reverse — migrating from Next.js to a React SPA because you don't need SSR — is almost never worth doing. You don't lose anything by using Next.js for an app that doesn't fully use SSR. The overhead is minimal.

When in doubt: if there's any chance this product will become public-facing, default to Next.js. The cost of migrating TO it later is higher than the learning curve of starting with it.


The Uncomfortable Truth

Here's what I want to say that nobody is saying loudly enough:

Next.js won. For production React applications, Next.js is the default. The question is no longer "React or Next.js?" — it's "which Next.js rendering strategy, and which parts of my app need client-side rendering?"

React without a framework is still absolutely valid — for SPAs, for dashboards, for internal tools, for learning. But when someone asks me "should I learn Next.js or just React?" my honest answer is: learn React first (you'll be writing React components regardless), then learn Next.js, because that's what you'll use in most production jobs.

The fact that we're still debating "React vs. Next.js" as equals in 2024 is a symptom of an industry that moves fast but talks slowly. The ecosystem has largely settled. Next.js ships with Vercel, with the React team's blessing, with RSC as the direction React itself is heading. Fighting this is like arguing about whether you should use TypeScript in 2024 — technically optional, practically the default.

That said — understand WHY Next.js makes sense before you default to it. Because developers who use Next.js without understanding the rendering model are the same developers who slap 'use client' on everything to make errors go away, effectively building a React SPA inside a Next.js wrapper. They've paid the complexity cost with zero benefit.

Know the model. Then use the tool.


Real Objections

Objection 1: "Next.js is overkill for a simple project. Vite + React is faster to set up."

Completely valid. If you're building a small side project, a prototype, or a learning exercise — the cognitive overhead of Next.js routing conventions and rendering strategies adds friction you don't need. Vite + React is fast, simple, and excellent for things that don't need SSR. The calculus changes the moment "going to production with real users" enters the picture.

Objection 2: "React Query / TanStack Query solves the same problems as SSR without changing your framework."

Partially true. React Query dramatically improves client-side data fetching — caching, background refetching, loading states. But it doesn't change when the browser receives content. The user still waits for JavaScript to load before seeing anything meaningful. React Query makes the SPA experience better. It doesn't change the fundamental rendering model. If initial load performance and SEO are requirements, React Query is not a substitute for SSR.

Objection 3: "Server Components add too much complexity. I'll just use Pages Router with getServerSideProps."

Legitimate choice and not a wrong one. Pages Router is stable, well-understood, and still fully supported. If your team is productive with it, there's no urgency to migrate to App Router. The main things you lose are automatic streaming, more granular caching control, and the performance benefits of shipping less JavaScript to the client. For many applications, those tradeoffs favor staying on Pages Router until App Router stabilizes further in your team's comfort zone.

That said — new projects should probably start on App Router. The ecosystem is moving there. The documentation is better now. And the performance benefits at scale are real.


📚 Nice to see too

  • 📄 Article: "Rendering Patterns" by Addy Osmani & Lydia Hallie on patterns.dev

    • Why it made my brain itch: The most comprehensive visual breakdown of every rendering strategy — SPA, SSR, SSG, ISR, Streaming — with performance implications for each. Bookmark this one. You'll come back to it every time someone asks you to justify an architecture choice.
  • 📄 Article: "Understanding React Server Components" on the Next.js Blog

    • Why it made my brain itch: Coming directly from the team building it, this explains the why behind RSC in a way that makes the constraints feel intentional rather than arbitrary. Read this before your first App Router project.
  • 🎥 Video: "React Server Components from Scratch" by Theo Browne (t3.gg) on YouTube

    • Skip to minute 6:30: Where he builds RSC from first principles. Watching it get built from scratch is the fastest way to truly understand what the boundary between server and client means.
  • "The best tool is the one that ships."
    — Everyone who's survived a framework debate


✨ Let's keep the conversation going!

If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.

✍️ Read more on my blog: bishoy-bishai.github.io

Let's chat on LinkedIn: linkedin.com/in/bishoybishai

📘 Curious about AI?: You can also check out my book: Surrounded by AI

Top comments (0)