App Router in Next.js: Layout, Page and Route
1. Overview
App Router is Next.js’s default routing mechanism from version 13 onwards, gradually replacing Pages Router (pages/). It’s based on file conventions in the app/ directory: page.tsx creates routes, layout.tsx creates shared UI, and route.ts creates API endpoints. After reading this article, you’ll understand how to organize a Next.js project using App Router, write nested layouts, create dynamic routes and route handlers, and test these components.
2. Basic Directory Structure
App Router uses folder = URL segment. Each folder in app/ can contain special files that determine route behavior.
app/
├── layout.tsx # root layout, must include <html>, <body>
├── page.tsx # route "/"
├── about/
│ └── page.tsx # route "/about"
├── products/
│ ├── layout.tsx # layout for /products/*
│ ├── page.tsx # route "/products"
│ └── [id]/
│ └── page.tsx # route "/products/123"
└── api/
└── orders/
└── route.ts # API "/api/orders"
layout.tsxwraps allpage.tsxat the same level or deeper.page.tsxis the actual endpoint that renders content for a URL.route.tsdoesn’t render UI; it returns aResponselike a REST endpoint.
Note: Folder names not starting with a lowercase letter (e.g.,
Products) still work, but Next.js recommends using kebab-case or camelCase consistently to avoid confusion on case-sensitive file systems.
3. Page — defining routes
File page.tsx exports a React component as the main UI for a route. By default, it’s a Server Component.
// app/products/page.tsx
import { ProductService } from "@/services/product-service";
export default async function ProductsPage() {
const products = await ProductService.findAll();
return (
<main>
<h1>Product List</h1>
<ul>
{products.map((product) => (
<li key={product.id}>{product.name} - ${product.price}</li>
))}
</ul>
</main>
);
}
async function ProductsPageallows you toawaitdata directly in the component because this is a Server Component, with no need foruseEffectorgetServerSideProps.- Next.js renders HTML on the server and sends it to the browser with content already included — great for SEO and initial page load time.
- The route corresponds to the
/productsURL.
3.1. Dynamic routes with params
// app/products/[id]/page.tsx
import { ProductService } from "@/services/product-service";
import { notFound } from "next/navigation";
type ProductPageProps = {
params: { id: string };
};
export default async function ProductDetailPage({ params }: ProductPageProps) {
const product = await ProductService.findById(params.id);
if (!product) {
notFound();
}
return <h1>{product.name}</h1>;
}
- Folder
[id]creates a dynamic segment; Next.js passes the actual value toparams.id. notFound()renders the nearestnot-found.tsxfile instead of crashing the app — this is the standard 404 handling approach in App Router.
4. Layout — shared UI
Layout receives a children prop containing child routes and preserves state when navigating between pages sharing the same layout (e.g., a sidebar won’t re-render).
// app/products/layout.tsx
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="products-layout">
<aside>Product Filters</aside>
<section>{children}</section>
</div>
);
}
childrenhere is the content fromapp/products/page.tsxorapp/products/[id]/page.tsx.- When navigating from
/productsto/products/123, the<aside>doesn’t unmount/remount — this is the main advantage of layout over manually importing a shared component in each page.
4.1. Root layout is required
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Nón Lá Shop",
description: "Demo App Router store",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<header>Nón Lá Shop</header>
{children}
</body>
</html>
);
}
- The root layout must contain
<html>and<body>because it’s the highest-level layout wrapping the entire application. export const metadatais how you declare<title>and<meta>tags declaratively; Next.js automatically injects them into<head>— no manual DOM manipulation needed.
Note: If you forget to create
app/layout.tsx, Next.js will report a build error because every route requires at least a root layout.
5. Route Groups and Parallel Segments
Sometimes you want to group routes to organize code without including that group in the URL. Use route groups with parentheses (name).
app/
├── (marketing)/
│ ├── layout.tsx # layout for marketing pages
│ ├── page.tsx # route "/"
│ └── pricing/
│ └── page.tsx # route "/pricing"
└── (dashboard)/
├── layout.tsx # layout for dashboard
└── settings/
└── page.tsx # route "/settings"
(marketing)and(dashboard)don’t appear in the URL; they only help apply different layouts to different page groups./pricingand/settingsuse different layouts even though they’re in the sameapp/directory.
6. Route Handlers — build APIs in App Router
File route.ts replaces pages/api/*.ts from Pages Router, exporting functions named after HTTP methods.
// app/api/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";
export async function GET() {
const orders = await OrderService.findAll();
return NextResponse.json(orders);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const order = await OrderService.create(body);
return NextResponse.json(order, { status: 201 });
}
GETandPOSTare separate exports; Next.js automatically routes the request to the correct function based on the HTTP method.NextResponse.json(...)setsContent-Type: application/jsonautomatically; you don’t need toJSON.stringifymanually.- This endpoint is served at
/api/orders, completely independent of the UI — it doesn’t render React.
6.1. Dynamic route handler
// app/api/orders/[id]/route.ts
import { NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";
export async function GET(_: Request, { params }: { params: { id: string } }) {
const order = await OrderService.findById(params.id);
if (!order) {
return NextResponse.json({ message: "Order not found" }, { status: 404 });
}
return NextResponse.json(order);
}
- Like dynamic pages, folder
[id]allows you to get the value from the URL viaparams.id. - Return an explicit
status: 404instead of letting the client guess the error.
7. Special Files: loading, error, not-found
App Router uses React Suspense and Error Boundary internally via file conventions, so you don’t need to write them manually.
// app/products/loading.tsx
export default function ProductsLoading() {
return <p>Loading product list...</p>;
}
// app/products/error.tsx
"use client";
export default function ProductsError({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<p>Error: {error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
loading.tsxautomatically displays whilepage.tsxin the same segment isawaiting data — no need foruseState(isLoading).error.tsxmust be a Client Component ("use client") because it usesreset— a client-side function to re-render the errored segment.- Next.js automatically wraps the segment with
<Suspense>and an error boundary based on these files.
8. Metadata and nested layouts
Each segment can export its own metadata, and Next.js merges them from the root layout down to the page.
// app/products/[id]/page.tsx
import type { Metadata } from "next";
import { ProductService } from "@/services/product-service";
type Props = { params: { id: string } };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const product = await ProductService.findById(params.id);
return { title: product ? `${product.name} - Nón Lá Shop` : "Not found" };
}
export default async function ProductDetailPage({ params }: Props) {
const product = await ProductService.findById(params.id);
return <h1>{product?.name}</h1>;
}
generateMetadatais a dynamic function that lets you set the<title>based on actual product data — useful for SEO on detail pages.- Child page metadata overrides fields with the same name from parent layout; other fields remain unchanged.
9. Testing page components
With asynchronous Server Components, the simplest way to test is to call the component function directly and check the returned JSX, or use React Testing Library after rendering.
{
"devDependencies": {
"vitest": "^2.0.0",
"testing-library-react": "^16.0.0"
}
}
// services/product-service.test.ts
import { describe, it, expect, vi } from "vitest";
import { ProductService } from "@/services/product-service";
import ProductsPage from "@/app/products/page";
import { render, screen } from "@testing-library/react";
vi.mock("@/services/product-service", () => ({
ProductService: {
findAll: vi.fn(),
},
}));
describe("ProductsPage", () => {
it("displays product list from service", async () => {
(ProductService.findAll as any).mockResolvedValue([
{ id: "1", name: "Hat A", price: 50000 },
]);
const jsx = await ProductsPage();
render(jsx);
expect(screen.getByText("Hat A - $50000")).toBeInTheDocument();
});
});
ProductsPage()is called as a plain async function because it’s just a function returning JSX — no special test framework needed for Server Components.vi.mockreplacesProductService.findAllwith a stub returning fake data, so tests don’t depend on a real database.render(jsx)from Testing Library checks that the rendered JSX has the expected content in the DOM.
10. Common mistakes and when not to use
- Forgetting
"use client"when using hooks likeuseState/useEffect: Server Components don’t support these hooks; Next.js will report a build error clearly. - Putting sensitive data-fetching logic (API keys) in Client Components: the code gets bundled to the browser. Always fetch data in Server Components or Route Handlers instead.
- Overusing route groups just for organization: if there’s no real need for different layouts, route groups just clutter the directory tree.
- Mixing
pages/andapp/for the same route: the two routers can’t handle the same URL; Next.js prioritizesapp/, and routes inpages/get ignored — easy to mix up during migration.
Note: If your project is running well with Pages Router and doesn’t need Server Components, Streaming, or Server Actions, migrating to App Router immediately isn’t always necessary. Evaluate the benefits before switching all your routing.
11. Conclusion
App Router organizes a Next.js application through three main file types: page.tsx defines routes, layout.tsx shares UI across multiple routes, and route.ts builds APIs without rendering HTML. Combined with conventions like loading.tsx, error.tsx, and not-found.tsx, you can build smooth loading and error experiences without writing boundaries manually. When designing your directory structure, prioritize grouping by actual shared layouts, avoid unnecessary route groups, and always check the Server/Client Component boundary to prevent leaking sensitive logic to the browser.