React with Next.js
Next.js is the most widely used React framework, and for many teams it is the default way to ship a production React app. Plain React (via Vite or Create React App) gives you a client-side library and leaves routing, server rendering, data fetching, bundling, and deployment up to you. Next.js packages all of that into one opinionated toolchain built on React Server Components, so you can render on the server, stream HTML, and call your database directly from a component without standing up a separate API.
Why a framework instead of plain React
A bare React app renders entirely in the browser: the user downloads a JavaScript bundle, React boots up, and only then does content appear. That hurts first paint, SEO, and anyone on a slow device. Next.js solves the parts React deliberately leaves out.
| Concern | Plain React (Vite SPA) | Next.js |
|---|---|---|
| Routing | Add a library (React Router) | File-based, built in |
| Server rendering / SSG | DIY or none | Built in per route |
| Data fetching | Client effects + spinners | async server components |
| Bundling / code splitting | Manual config | Automatic per route |
| API endpoints | Separate backend | Route handlers in the same app |
| Image / font optimization | Manual | Built-in components |
The App Router and file-based routing
Modern Next.js uses the App Router — a special app/ directory where the filesystem is your route table. A folder becomes a URL segment, and a page.jsx file inside it becomes the page that renders at that path. Special filenames give you layouts, loading states, and error boundaries without any configuration.
app/
layout.jsx -> wraps every route (html, shared nav)
page.jsx -> "/"
blog/
page.jsx -> "/blog"
[slug]/
page.jsx -> "/blog/:slug" (dynamic segment)
loading.jsx -> Suspense fallback for /blog
A layout.jsx persists across navigations and wraps its child routes, which is ideal for shared chrome like a header or sidebar.
// app/layout.jsx
export const metadata = { title: "DevCraftly" };
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<header>My Site</header>
<main>{children}</main>
</body>
</html>
);
}
Server and client components
In the App Router every component is a server component by default. Server components run only on the server, never ship their JavaScript to the browser, and can be async. To opt a component into the browser — so it can use useState, useEffect, refs, or event handlers — add the "use client" directive at the top of the file.
// app/blog/[slug]/page.jsx — a server component (no directive)
import LikeButton from "./LikeButton";
async function getPost(slug) {
const res = await fetch(`https://api.example.com/posts/${slug}`);
if (!res.ok) throw new Error("Post not found");
return res.json();
}
export default async function PostPage({ params }) {
const post = await getPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
);
}
// app/blog/[slug]/LikeButton.jsx — interactive island
"use client";
import { useState } from "react";
export default function LikeButton({ initialLikes }) {
const [likes, setLikes] = useState(initialLikes);
return (
<button onClick={() => setLikes((n) => n + 1)}>
❤ {likes}
</button>
);
}
The server component fetches data and renders static HTML; the small client component adds just the interactivity that needs the browser.
Tip: Keep
"use client"on the smallest leaf components. Marking a top-level layout as a client component pulls its entire subtree into the browser bundle and forfeits server rendering.
Data fetching and caching
Because server components are async, you fetch data inline — no useEffect, no loading flags, no client-side waterfall. The extended fetch in Next.js lets you control caching declaratively.
// Cached forever (static) — the default
const a = await fetch(url);
// Always fresh on every request (dynamic)
const b = await fetch(url, { cache: "no-store" });
// Revalidate at most once every 60 seconds (ISR)
const c = await fetch(url, { next: { revalidate: 60 } });
Server actions for mutations
Server actions are async functions marked "use server" that run on the server but can be invoked from the browser — Next.js wires up the network call for you. Pass one straight to a form’s action prop to handle submissions without writing a separate API route.
// app/blog/actions.js
"use server";
import { revalidatePath } from "next/cache";
export async function addComment(formData) {
const text = formData.get("text");
await db.comments.create({ data: { text } });
revalidatePath("/blog");
}
// app/blog/CommentForm.jsx
import { addComment } from "./actions";
export default function CommentForm() {
return (
<form action={addComment}>
<input name="text" required />
<button type="submit">Post</button>
</form>
);
}
Submitting the form runs addComment on the server, writes to the database, and revalidatePath refreshes the cached page — all without a client-side fetch.
Getting started
npx create-next-app@latest my-app
cd my-app
npm run dev
Output:
▲ Next.js 15
- Local: http://localhost:3000
✓ Ready in 1.2s
Best Practices
- Default to server components; reach for
"use client"only when you need state, effects, or event handlers. - Fetch data in
asyncserver components instead of client-side effects to avoid request waterfalls. - Choose your caching explicitly with
cache/next.revalidaterather than relying on defaults you do not understand. - Use server actions for mutations so forms work without bespoke API routes, and call
revalidatePath/revalidateTagto keep data fresh. - Keep secrets and database access in server components and actions — never expose them to client components.
- Add
loading.jsxanderror.jsxfiles to get Suspense fallbacks and error boundaries for free per route.