If you have ever built a React app with plain Vite, you remember the moment. Three months in, you have hand rolled a router. You have hand rolled the loading spinner pattern. You have hand rolled metadata for SEO. You have an Express server next to it for the API, and you spend half your time keeping the two in sync. Deploying is a checklist. Production caching is a guess.
You did not want to build a framework. You just wanted to ship a website.
That is the gap Next.js fills.
What is Next.js, really
Think of Next.js as a React framework with the boring parts already done. Routing, data fetching, server side rendering, caching, image optimization, fonts, head tags, deployment, all of it. You write the components, Next.js wires the rest into a coherent app that runs on the server, the edge, and the browser.
Two ideas drive the whole thing:
- Server first. Pages render on the server by default. Only the parts that truly need interactivity ship JavaScript to the browser. Smaller bundles, faster first paint, better SEO, secrets stay safe.
-
The file system is your routing config. A folder is a route. A
page.tsxinside it is the screen. Alayout.tsxis the shared shell. No router config file to maintain.
That is the whole vibe.
Let's pretend we are building one
We want a React framework that handles routing, server rendering, mutations, and caching by default. We will call it Next.js.
For the running example, we are building Mochi's Blog: posts, a single post page, an author dashboard, and a comment form.
Decision 1: The folder is the route
Next.js (App Router) maps your app/ folder onto URLs.
app/
layout.tsx -> shared shell for the whole site
page.tsx -> /
about/
page.tsx -> /about
posts/
page.tsx -> /posts
[slug]/
page.tsx -> /posts/anything
dashboard/
layout.tsx -> shared shell only for /dashboard/*
page.tsx -> /dashboard
settings/
page.tsx -> /dashboard/settings
api/
subscribe/
route.ts -> POST/GET /api/subscribe
A few rules that explain the rest of the framework:
-
page.tsxis a page. It receivesparamsandsearchParamsprops. -
layout.tsxis a shell that wraps every page below it in the tree. Layouts compose: every nested layout wraps inside the parent's. -
[slug]is a dynamic segment. The folder name with brackets becomes a route param. -
route.tsis a backend endpoint. ExportGET,POST,PUT,DELETEfunctions and they become handlers. -
loading.tsxis the suspense fallback for that segment. Streamed in automatically. -
error.tsxis the error boundary for that segment. -
not-found.tsxis shown whennotFound()is thrown.
A minimal layout and home page:
// app/layout.tsx
export const metadata = { title: "Mochi's Blog", description: "Cats and code." };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<header><a href="/">Mochi's Blog</a></header>
<main>{children}</main>
</body>
</html>
);
}
// app/page.tsx
export default function HomePage() {
return <h1>Welcome to Mochi's Blog</h1>;
}
That is a working Next.js app.
Decision 2: Server Components by default
Every component you write is a Server Component unless you opt out. Server Components run on the server, can read from a database directly, can use secrets, and ship zero JavaScript to the browser.
// app/posts/page.tsx
import { db } from "@/lib/db";
export default async function PostsPage() {
const posts = await db.post.findMany({ orderBy: { createdAt: "desc" } });
return (
<ul>
{posts.map((p) => (
<li key={p.id}><a href={`/posts/${p.slug}`}>{p.title}</a></li>
))}
</ul>
);
}
That is the whole page. Async function, awaits the database, returns JSX. No useEffect, no loading state, no API route in between.
When you do need interactivity (state, effects, event handlers, browser APIs), opt into Client Components with "use client" at the top of the file:
// app/posts/[slug]/like-button.tsx
"use client";
import { useState } from "react";
export function LikeButton({ initial }: { initial: number }) {
const [count, setCount] = useState(initial);
return <button onClick={() => setCount(count + 1)}>♥ {count}</button>;
}
The mental model: default to Server Components. Reach for "use client" only on the interactive island. The pattern is server pages with small client islands inside, not whole client trees with little server islands.
Decision 3: Server Actions for mutations
When you need to write data, you do not have to hand build an API route. Write an async function with "use server" and call it from a form or a button.
// app/posts/[slug]/comment-form.tsx
import { addComment } from "./actions";
export function CommentForm({ postId }: { postId: string }) {
return (
<form action={addComment}>
<input type="hidden" name="postId" value={postId} />
<textarea name="body" required />
<button>Post comment</button>
</form>
);
}
// app/posts/[slug]/actions.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function addComment(formData: FormData) {
const postId = String(formData.get("postId"));
const body = String(formData.get("body"));
await db.comment.create({ data: { postId, body } });
revalidatePath(`/posts/${postId}`);
}
That is a full mutation. No API route. No fetch. The browser submits the form, Next.js calls the server function, the server runs it, the page revalidates, and the new comment appears.
For a richer client experience (pending state, error message, optimistic UI), pair the action with React 19's useActionState and useOptimistic hooks. Same patterns you already know, no Next.js specific magic needed.
Decision 4: Three rendering modes, one mental model
Every route picks one of three behaviors:
- Static: built once at deploy time, cached forever, fastest possible. Default for routes with no dynamic data.
- Dynamic: rendered on every request, can use cookies, headers, and search params freely.
-
Streaming: starts sending HTML immediately and fills in slow parts as data arrives. Pairs with
<Suspense>andloading.tsx.
You rarely pick these explicitly. Next.js infers from what your code does. If you read cookies() or headers(), the route becomes dynamic. If you wrap a slow component in <Suspense>, that part streams.
For data that should not be revalidated on every request, you cache and tag it:
import { unstable_cache } from "next/cache";
export const getPostBySlug = unstable_cache(
async (slug: string) => db.post.findUnique({ where: { slug } }),
["post-by-slug"],
{ revalidate: 60, tags: ["posts"] }
);
When you mutate, you invalidate by tag or by path:
import { revalidateTag, revalidatePath } from "next/cache";
revalidateTag("posts"); // any cache tagged "posts" is dropped
revalidatePath("/posts"); // re-render that path on next request
In Next.js 15, the modern surface for this is the use cache directive at the top of a function or component, plus cacheTag() and cacheLife() to control freshness:
"use cache";
import { cacheTag, cacheLife } from "next/cache";
export async function getRecentPosts() {
cacheTag("posts");
cacheLife("hours");
return db.post.findMany({ orderBy: { createdAt: "desc" }, take: 10 });
}
The mental model: cache everything you can, tag what you cache, invalidate by tag when you mutate.
Decision 5: Route handlers for the rest
If your client (a mobile app, a third party webhook, an external integration) needs a real REST endpoint, write a route.ts:
// app/api/posts/route.ts
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export async function GET() {
const posts = await db.post.findMany();
return NextResponse.json(posts);
}
export async function POST(req: Request) {
const body = await req.json();
const post = await db.post.create({ data: body });
return NextResponse.json(post, { status: 201 });
}
Same file system pattern, same colocation. Use Server Actions for app-internal mutations and route handlers for public APIs.
Decision 6: Navigation and links
The <Link> component from next/link does client side navigation, prefetches in view links, and keeps your layouts mounted across navigations.
import Link from "next/link";
<Link href="/posts/mochi-stole-socks">Read post</Link>
<Link href={`/posts/${slug}`} prefetch={false}>Read</Link>
For programmatic navigation, use useRouter() from next/navigation (the new App Router version, not the old next/router).
Decision 7: Images, fonts, and metadata, the boring wins
Next.js has built in components for the things every website gets wrong.
import Image from "next/image";
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
export default function Hero() {
return (
<div className={inter.className}>
<Image
src="/mochi.jpg"
alt="A small white cat with big eyes"
width={1200}
height={800}
priority
/>
</div>
);
}
What that gives you for free: responsive images with srcset, modern formats (AVIF, WebP), lazy loading by default, layout shift prevention, font self hosting with no FOUT, font subsetting.
For SEO, set metadata on each route:
export const metadata = {
title: "Why my cat steals socks",
description: "A short investigation.",
openGraph: { images: ["/cover.png"] },
};
Or generate it dynamically:
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return { title: post.title, description: post.excerpt };
}
These small wins (<Image>, next/font, metadata API) are why most production React apps run on Next.js even when they technically could ship as a Vite SPA.
Decision 8: Middleware and edge
Code that runs on every request, before any route, lives in middleware.ts at the root:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const session = req.cookies.get("session")?.value;
const isAuthed = Boolean(session);
if (req.nextUrl.pathname.startsWith("/dashboard") && !isAuthed) {
return NextResponse.redirect(new URL("/login", req.url));
}
}
export const config = { matcher: ["/dashboard/:path*", "/api/admin/:path*"] };
Middleware runs on the edge by default, so it is fast and globally distributed. Use it for authentication gates, redirects, A/B test bucketing, locale negotiation, bot blocking. Keep it light. It runs on every matched request.
Decision 9: Project structure that scales
A pattern most senior teams settle on:
app/
(marketing)/ -> route group, does not appear in URLs
page.tsx -> /
pricing/page.tsx -> /pricing
(dashboard)/
layout.tsx -> auth-protected layout
dashboard/page.tsx -> /dashboard
api/
layout.tsx
components/
ui/ -> design system primitives (Button, Card, Dialog)
posts/ -> feature components
lib/
db.ts -> database client
auth.ts -> auth helpers
validators/ -> Zod schemas, shared between client and server
The senior level habits:
-
Group routes with
(name)/for sectioning without affecting URLs. -
Keep server only code in
lib/and never import it from a"use client"file. (Addimport "server-only"at the top of files that should never reach the browser.) -
One feature, one folder. A
postsfeature has its server queries, components, and forms colocated. -
Use the official
next.configminimum. Resist the urge to customize Webpack until you must.
Decision 10: Auth, the modern story
Next.js does not ship auth, on purpose. The two clean choices in 2026:
-
Auth.js (formerly NextAuth): full featured, supports OAuth providers, email magic links, credentials. Sets a session cookie. Read sessions from Server Components with
auth(). - Clerk / Kinde / Stack Auth / WorkOS: hosted auth services with React components and a back end you do not run. Trade some money for zero auth code.
-
Roll your own session cookie + a strong library like
iron-sessionorlucia-auth. Reasonable for small projects.
Whatever you pick, the rule is the same: read the session in middleware or in a Server Component, never trust client side flags. Server Components are where authorization decisions belong.
Decision 11: Deployment and runtime
The simplest deploy: push to a Git repo connected to Vercel. Build, deploy, edge middleware, image optimization, all included. That is the path the framework was designed for.
You can also self host:
next build
next start
That gives you a Node.js server. For containerized deploys, the output: "standalone" config produces a minimal output that runs anywhere Node runs.
Two runtime knobs to know:
- Node.js runtime (default): full Node APIs, larger cold start, runs anywhere.
-
Edge runtime: a subset of APIs, V8 isolate based, low latency cold start, geographically distributed. Set per route with
export const runtime = "edge";. Great for middleware and small API handlers.
A peek under the hood
What really happens when a user opens your Next.js app:
- The request hits the edge or your Node server.
- Middleware runs, possibly redirecting or rewriting.
- Next.js matches the URL to a
page.tsxplus its parent layouts. - Server Components render on the server, fetching data, awaiting promises.
- The HTML streams to the browser, with
<Suspense>boundaries filling in as data arrives. - A small JavaScript payload hydrates the Client Components only.
- The browser shows pixels almost immediately, interactivity follows shortly after.
- Subsequent navigations are client side, prefetched, and reuse the layout tree.
Two senior level consequences:
- Less JS in the browser is the headline win. A typical Next.js page ships a fraction of what a typical Vite SPA ships, because Server Components stay on the server.
-
Caching is the second big win, and the most confusing one. Next.js caches at the data level (fetch,
unstable_cache,use cache) and the route level. Get comfortable withrevalidateTagandrevalidatePathearly.
Tiny tips that will save you later
-
Default to Server Components. Add
"use client"only where you must. -
Use
<Image>,next/font, and the metadata API. Free Lighthouse points. -
Use
<Link>for internal navigation. Native<a>reloads the page. - Tag your caches. It pays off the first time you ship a "show fresh data" feature.
-
Keep secrets server side. A
process.env.SECRETaccessed from a"use client"file is shipped to the browser. -
Use Zod schemas in
lib/validators/to share types between client forms and server actions. - Read sessions on the server. Auth checks in client code are cosmetic, not security.
-
Run
next buildin CI. Type errors and config issues show up there, not in dev. -
Profile with the Vercel Analytics or
next/web-vitalsto track real user metrics.
Wrapping up
So that is the whole story. We were tired of stitching React, a router, an API server, an image pipeline, and a deployment script together by hand. We built a framework that does it all by default. The file system is the router. Server Components do data fetching. Server Actions do mutations. Caching is built in, with tag based invalidation. Images, fonts, and metadata are first class. Middleware runs on the edge.
We learned to default to the server, ship small client islands, cache aggressively, invalidate precisely, and let Next.js handle the boring parts so we can focus on the actual app.
Once that map is in your head, every modern React project starts to feel familiar. Next.js stops feeling like "React with extra steps" and starts feeling like the full stack toolkit you wished you had three years ago.
Happy shipping, and may your LCP always be green.
Top comments (0)