Learn file based routing, layouts, loading and error files, and how the App Router puts Server Components to work.

Bishal Biswas
Self Learner
Routing by folders
In the App Router, the file system is the routing table.
app/
layout.jsx -> wraps everything
page.jsx -> /
about/
page.jsx -> /about
products/
page.jsx -> /products
[id]/
page.jsx -> /products/:id
(marketing)/
pricing/
page.jsx -> /pricing
Four rules cover most of it:
page.jsxmakes a route publicly reachable. A folder without one is not a route.[id]is a dynamic segment.(name)in brackets groups folders without adding a URL segment.layout.jsxwraps that folder and everything below it.
Server Components by default
Every component in app/ is a Server Component unless you say otherwise.
export default async function ProductsPage() {
const products = await db.products.findMany();
return (
<ul>
{products.map((p) => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
Async, direct database access, no loading state, and none of this code ships to the browser. This is the default, not the special case.
Layouts
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Header />
{children}
<Footer />
</body>
</html>
);
}
export default function DashboardLayout({ children }) {
return (
<div className="dashboard">
<DashboardNav />
<main>{children}</main>
</div>
);
}
Layouts nest, and crucially they do not re-render on navigation. Moving between /dashboard/settings and /dashboard/billing keeps the nav mounted, so its scroll position and open menus survive.
The root layout must include <html> and <body>, and it is the only one that does.
The special files
Each folder can have several conventional files.
app/products/
layout.jsx shared shell
page.jsx the route
loading.jsx shown while the page loads
error.jsx shown when it throws
not-found.jsx shown for a 404
loading.jsx is the neat one. Creating it automatically wraps the page in a Suspense boundary:
export default function Loading() {
return <ProductListSkeleton />;
}
No <Suspense> to write. The file’s existence is the boundary.
error.jsx must be a Client Component, because it needs state to offer a retry:
'use client';
export default function Error({ error, reset }) {
return (
<div role="alert">
<p>Something went wrong loading products.</p>
<button onClick={reset}>Try again</button>
</div>
);
}
Dynamic segments
export default async function ProductPage({ params }) {
const { id } = await params;
const product = await db.products.findUnique({ where: { id } });
if (!product) notFound();
return <h1>{product.name}</h1>;
}
Note that params is a promise and must be awaited. This changed in Next.js 15 and catches people upgrading.
Search parameters work the same way:
export default async function Page({ searchParams }) {
const { category, page } = await searchParams;
return <ProductList category={category} page={Number(page ?? 1)} />;
}
notFound() throws, which renders the nearest not-found.jsx.
Metadata
export const metadata = {
title: 'Products',
description: 'Browse our full range'
};
export default function ProductsPage() {
return <h1>Products</h1>;
}
For dynamic pages, generate it:
export async function generateMetadata({ params }) {
const { id } = await params;
const product = await db.products.findUnique({ where: { id } });
return {
title: product.name,
description: product.summary,
openGraph: { images: [product.image] }
};
}
This runs on the server, so social scrapers and crawlers see the real values.
Navigation
import Link from 'next/link';
function Nav() {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/products">Products</Link>
</nav>
);
}
next/link prefetches the route when the link enters the viewport, so navigation is usually instant.
For the active state and programmatic navigation you need a Client Component:
'use client';
import { usePathname, useRouter } from 'next/navigation';
export function NavLink({ href, children }) {
const pathname = usePathname();
const isActive = pathname === href;
return (
<Link href={href} className={isActive ? 'active' : ''} aria-current={isActive ? 'page' : undefined}>
{children}
</Link>
);
}
Note the import is next/navigation, not the older next/router from the Pages Router.
Parallel data loading
Sequential awaits create a waterfall:
export default async function Page({ params }) {
const { id } = await params;
const product = await getProduct(id);
const reviews = await getReviews(id);
const related = await getRelated(id);
}
Three round trips in a row. If they are independent, start them together:
export default async function Page({ params }) {
const { id } = await params;
const [product, reviews, related] = await Promise.all([
getProduct(id),
getReviews(id),
getRelated(id)
]);
}
Or stream them separately so each appears as it lands:
export default async function Page({ params }) {
const { id } = await params;
return (
<div>
<Suspense fallback={<ProductSkeleton />}>
<Product id={id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews id={id} />
</Suspense>
</div>
);
}
App Router or Pages Router?
The Pages Router is the older system using pages/ and getServerSideProps. It still works and is still supported, but it does not have Server Components, layouts or streaming.
New projects use the App Router. If you meet getStaticProps or getServerSideProps in a tutorial, it is the older system.
Quick recap
- Folders are routes, and
page.jsxmakes one public. - Everything is a Server Component unless marked
'use client'. - Layouts nest and survive navigation, keeping their state.
loading.jsxcreates a Suspense boundary automatically.error.jsxmust be a Client Component and gets aresetfunction.paramsandsearchParamsare promises, so await them.generateMetadataruns on the server, so crawlers see real values.- Use
Promise.allor separate Suspense boundaries to avoid waterfalls.
// ---------- app/layout.jsx : the root layout ----------
// Only the root layout includes <html> and <body>.
// Layouts do NOT re-render on navigation, so nav state survives.
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Header />
{children}
<Footer />
</body>
</html>
);
}
// ---------- app/products/page.jsx : a Server Component ----------
// Async, queries the database directly, ships no JavaScript
import { Suspense } from 'react';
export const metadata = { title: 'Products' };
export default async function ProductsPage({ searchParams }) {
// params and searchParams are PROMISES in Next.js 15 and later
const { category } = await searchParams;
const products = await db.products.findMany({ where: { category } });
return <ul>{products.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}
// ---------- app/products/loading.jsx ----------
// Just creating this file wraps the page in a Suspense boundary
// export default function Loading() { return <ProductListSkeleton />; }
// ---------- app/products/error.jsx ----------
// Must be a client component, because reset needs interactivity
// 'use client';
// export default function Error({ error, reset }) {
// return (
// <div role="alert">
// <p>Something went wrong.</p>
// <button onClick={reset}>Try again</button>
// </div>
// );
// }
// ---------- app/products/[id]/page.jsx ----------
import { notFound } from 'next/navigation';
export async function generateMetadata({ params }) {
const { id } = await params;
const product = await getProduct(id);
// Runs on the SERVER, so crawlers and social scrapers see real values
return { title: product.name, description: product.summary };
}
export default async function ProductPage({ params }) {
const { id } = await params;
// WATERFALL: three awaits in a row is three round trips.
// const product = await getProduct(id);
// const reviews = await getReviews(id);
// Start independent requests together instead:
const [product, reviews] = await Promise.all([
getProduct(id),
getReviews(id)
]);
if (!product) notFound(); // renders the nearest not-found.jsx
return (
<div>
<h1>{product.name}</h1>
{/* Or stream each section so it appears as its data lands */}
<Suspense fallback={<p>Loading related...</p>}>
<RelatedProducts id={id} />
</Suspense>
</div>
);
}
// ---------- NavLink.jsx : needs the client for usePathname ----------
// 'use client';
// import Link from 'next/link';
// import { usePathname } from 'next/navigation'; NOT next/router
// export function NavLink({ href, children }) {
// const isActive = usePathname() === href;
// return <Link href={href} aria-current={isActive ? 'page' : undefined}>
// {children}
// </Link>;
// }
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready