Next.js is a framework built on top of React that adds server-side rendering, file-based routing, and built-in optimisations. It lets you build full web applications — not just the UI layer, but also the server-side logic — in a single project.
App Router — folder structure
Next.js 13+ uses the App Router. Every folder inside app/ becomes a route. A file named page.tsx is what visitors see at that URL.
app/
layout.tsx ← shared wrapper for all pages
page.tsx ← the home page (/)
about/
page.tsx ← /about
blog/
page.tsx ← /blog
[slug]/
page.tsx ← /blog/any-post-slugPage component
A page is a plain React component exported as the default from page.tsx. No special setup needed.
// app/about/page.tsx
export default function AboutPage() {
return (
<main>
<h1>About Us</h1>
<p>We build things for the web.</p>
</main>
);
}Layout
A layout wraps every page in its folder (and sub-folders). Use it for navigation, fonts, or any chrome that should stay on screen between page changes.
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<nav>My Site</nav>
{children}
</body>
</html>
);
}Server Components vs Client Components
By default, every component in the App Router is a Server Component — it runs on the server and sends plain HTML. To use browser APIs or React hooks like useState, mark the file as a Client Component with "use client" at the top.
// Server Component — no "use client", runs on the server
// app/posts/page.tsx
async function getPosts() {
const res = await fetch("https://api.example.com/posts");
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}// Client Component — "use client" at the top
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}import { useState } from "react";
import { createRoot } from "react-dom/client";
function Counter() {
const [count, setCount] = useState(0);
return (
<div style={{ fontFamily: "system-ui", padding: 8 }}>
<p style={{ marginBottom: 8 }}>
Client components use React hooks like <code>useState</code>.
</p>
<button
onClick={() => setCount(count + 1)}
style={{ padding: "6px 16px", borderRadius: 6, cursor: "pointer" }}
>
Clicked {count} time{count !== 1 ? "s" : ""}
</button>
</div>
);
}
createRoot(document.getElementById("root")).render(<Counter />);Data fetching in Server Components
Server Components can be async functions. Call fetch directly — Next.js automatically caches the result.
// app/user/page.tsx
async function getUser() {
const res = await fetch("https://api.example.com/user/1", {
next: { revalidate: 60 }, // re-fetch at most every 60 seconds
});
return res.json();
}
export default async function UserPage() {
const user = await getUser();
return <h1>Hello, {user.name}</h1>;
}Dynamic routes
Wrap a folder name in square brackets to make it a dynamic segment. The value is passed to the component as params.
// app/blog/[slug]/page.tsx
export default async function BlogPost({
params,
}: {
params: { slug: string };
}) {
const post = await fetch(`/api/posts/${params.slug}`).then(r => r.json());
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}Navigation with Link
Use the <Link> component instead of <a> for client-side navigation — it prefetches pages and avoids a full reload.
import Link from "next/link";
export default function Nav() {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/blog/hello-world">A Post</Link>
</nav>
);
}Programmatic navigation
Use the useRouter hook inside a Client Component to navigate in response to events (form submit, button click, etc.).
"use client";
import { useRouter } from "next/navigation";
export default function LoginButton() {
const router = useRouter();
function handleLogin() {
// ... do auth ...
router.push("/dashboard"); // navigate programmatically
}
return <button onClick={handleLogin}>Log in</button>;
}Metadata
Export a metadata object from any page.tsx or layout.tsx to set the page title and description. Next.js injects these into the <head> automatically.
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "My Blog",
description: "Articles about web development.",
};
export default function BlogPage() {
return <main>...</main>;
}For dynamic titles (based on data), export a generateMetadata function:
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await fetch(`/api/posts/${params.slug}`).then(r => r.json());
return { title: post.title };
}Route Handlers (API routes)
Create a file named route.ts inside app/ to define an API endpoint. Export a function named after the HTTP method.
// app/api/hello/route.ts
import { NextResponse } from "next/server";
export function GET() {
return NextResponse.json({ message: "Hello!" });
}
export async function POST(request: Request) {
const body = await request.json();
return NextResponse.json({ received: body });
}This endpoint is available at /api/hello.
Loading and error states
Place a loading.tsx file next to a page.tsx to show a skeleton while the page fetches data. Place error.tsx to show a message when something goes wrong.
// app/blog/loading.tsx
export default function Loading() {
return <p>Loading posts…</p>;
}// app/blog/error.tsx
"use client";
export default function Error({ reset }: { reset: () => void }) {
return (
<div>
<p>Something went wrong.</p>
<button onClick={reset}>Try again</button>
</div>
);
}Optimised Image
Use next/image instead of <img> — it resizes, compresses, and lazy-loads images automatically.
import Image from "next/image";
export default function Avatar() {
return (
<Image
src="/avatar.png"
alt="User avatar"
width={80}
height={80}
priority // load immediately (above the fold)
/>
);
}Environment variables
Store secrets in .env.local (not committed to git). Prefix a variable with NEXT_PUBLIC_ to make it available in the browser.
# .env.local
DATABASE_URL=postgres://localhost/mydb
NEXT_PUBLIC_SITE_URL=https://mysite.com// Server-only (safe for secrets)
const dbUrl = process.env.DATABASE_URL;
// Available in the browser (public, not secret)
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;