Skip to content

Routing and Navigation

Routing just means: “what page shows up when someone visits a URL?”

When someone goes to /about, your app needs to show the About page. When they go to /products/123, it needs to show product number 123. Next.js handles all of this for you - you don’t need any extra setup.

Next.js looks at your folder and file structure to figure out which page to show. You don’t write any routing settings - you just create folders and files, and Next.js does the rest automatically.

The rule is simple:

Every folder inside app/ becomes part of the URL. A page.tsx file inside that folder is the page that gets shown.

app/
├── page.tsx -> /
├── about/
│ └── page.tsx -> /about
└── contact/
└── page.tsx -> /contact

Static Routes

Fixed URLs like /about or /contact. The URL never changes.

Dynamic Routes

URLs with changing parts like /product/1 or /product/99. One file handles all of them.

Nested Routes

URLs with more than one changing part like /profile/john/photo/42. You just put folders inside folders.

Catch-all Routes

One file that handles any URL no matter how long, like /docs/a/b/c/d.

A static route is just a page with a fixed URL. Create a folder, add a page.tsx file inside it, and you’re done.

app/
├── page.tsx -> /
├── about/
│ └── page.tsx -> /about
└── blog/
└── page.tsx -> /blog

Here is what app/about/page.tsx might look like:

// app/about/page.tsx
export default function AboutPage() {
return (
<main>
<h1>About Us</h1>
<p>We make cool stuff.</p>
</main>
);
}

Visit http://localhost:3000/about and you will see this page.

Sometimes you don’t know the URL ahead of time. For example, if you have 1000 products, you can’t create 1000 separate files. Instead, you create one file that handles all of them.

To do this, wrap the folder name in square brackets: [id]

app/
└── product/
└── [id]/
└── page.tsx -> /product/1, /product/2, /product/99, ...

Inside the page, you can read that id from the URL using params. In Next.js 15 and above, params is a Promise, so you need to await it first, then destructure the value you need:

// app/product/[id]/page.tsx
type Props = { params: Promise<{ id: string }> };
export default async function ProductPage({ params }: Props) {
const { id } = await params;
return (
<main>
<h1>Product #{id}</h1>
<p>Showing details for product with ID: {id}</p>
</main>
);
}

Now if someone visits /product/42, they see “Product #42”. If they visit /product/99, they see “Product #99” - all from the same one file.

What if a URL has more than one changing part?

For example: /profile/john/photo/42

Here, both john (the username) and 42 (the photo ID) are dynamic. You just nest the folders inside each other:

app/
└── profile/
└── [userId]/
└── photo/
└── [photoId]/
└── page.tsx -> /profile/john/photo/42

Inside your page file, you await params and destructure both values at once:

// app/profile/[userId]/photo/[photoId]/page.tsx
type Props = { params: Promise<{ userId: string; photoId: string }> };
export default async function PhotoPage({ params }: Props) {
const { userId, photoId } = await params;
return (
<main>
<h2>Photo #{photoId}</h2>
<p>This photo belongs to user: {userId}</p>
</main>
);
}

Visiting /profile/john/photo/42 gives you:

  • userId -> "john"
  • photoId -> "42"

Visiting /profile/sara/photo/7 gives you:

  • userId -> "sara"
  • photoId -> "7"

One file. Any combination of user and photo ID.

A regular dynamic route like [id] only catches one part of the URL. What if you want to catch everything after a certain point, no matter how deep the URL goes?

That is what a catch-all route is for. You write [...slug] (with three dots before the name).

app/
└── docs/
└── [...slug]/
└── page.tsx

This one file handles all of these:

URLslug
/docs/intro["intro"]
/docs/react/hooks["react", "hooks"]
/docs/react/hooks/useEffect["react", "hooks", "useEffect"]
// app/docs/[...slug]/page.tsx
type Props = { params: Promise<{ slug: string[] }> };
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
// slug is an array containing all the URL segments
const path = slug.join(" / ");
return (
<main>
<h1>Docs</h1>
<p>You're reading: {path}</p>
</main>
);
}

Search params are the extra values you see in a URL after the ? sign. For example:

/products?category=shoes&sort=price

Here, category and sort are search params. They are not part of the URL path - they are just extra filters or options passed in the URL.

You read them in a page using the searchParams prop. Just like params, in Next.js 15 and above, searchParams is also a Promise, so you need to await it:

// app/products/page.tsx
type Props = { searchParams: Promise<{ category?: string; sort?: string }> };
export default async function ProductsPage({ searchParams }: Props) {
const { category, sort } = await searchParams;
return (
<main>
<h1>Products</h1>
<p>Category: {category}</p>
<p>Sort by: {sort}</p>
</main>
);
}

If someone visits /products?category=shoes&sort=price, they will see:

  • category -> "shoes"
  • sort -> "price"

If a search param is missing from the URL, its value will just be undefined.

In Next.js, you can navigate between pages using the <Link> component. This works like a regular HTML <a> tag, but it moves users between pages without doing a full page reload. The page feels much faster because only the content that changed gets updated.

// app/page.tsx
import Link from 'next/link';
export default function HomePage() {
return (
<main>
<h1>Welcome to My Site</h1>
<nav>
<ul>
<li><Link href="/about">About Us</Link></li>
<li><Link href="/products">Products</Link></li>
<li><Link href="/contact">Contact</Link></li>
</ul>
</nav>
</main>
);
}

When a user clicks one of these links, Next.js loads the new page without refreshing the whole browser window. This makes the app feel fast and smooth.

Sometimes you need to send the user to a different page after something happens - like after they submit a form or click a button. You can do this with the useRouter hook from next/navigation.

Because useRouter is a React hook, it only works in Client Components. You must add 'use client' at the top of the file.

// app/page.tsx
'use client';
import { useRouter } from 'next/navigation';
export default function HomePage() {
const router = useRouter();
const goToProducts = () => {
router.push('/products');
};
return (
<main>
<h1>Welcome to My Site</h1>
<button onClick={goToProducts}>Go to Products</button>
</main>
);
}

By calling router.push('/products'), the user gets sent to the Products page when they click the button - without a full page reload.

When a page is loading data, you want to show something to the user right away - like a spinner or a skeleton screen - so they know the app is working. Next.js makes this easy using streaming, which means the server starts sending the page to the browser as soon as parts of it are ready, instead of waiting for everything to finish first.

There are two main ways to show loading UI in Next.js:

  • Using loading.tsx - A special file you create next to your page. Next.js shows it automatically while the page is loading.
  • Using React Suspense - You wrap specific parts of your page in a <Suspense> boundary and provide a fallback that shows while that part is loading.

loading.tsx is a special file in Next.js. When a user visits a page that is still loading data, Next.js will show the content of loading.tsx right away, and then swap it out for the real page once it is ready.

Behind the scenes, Next.js wraps your page in a React Suspense boundary automatically. This allows the page shell to be sent to the browser immediately, and the data to stream in as it becomes available.

app/
└── products/
├── page.tsx -> /products
└── loading.tsx -> Loading state for /products
// app/products/loading.tsx
export default function Loading() {
return (
<main>
<h1>Loading Products...</h1>
<p>Please wait while we fetch the products for you.</p>
</main>
);
}

When a user visits /products, they will see this loading message right away. Once the products are ready, Next.js will replace it with the real page automatically.

React’s Suspense lets you wrap individual parts of your page so only that part shows a loading state, while the rest of the page shows normally. This is useful when different sections of a page load independently.

// app/products/page.tsx
import { Suspense } from 'react';
export default function ProductsPage() {
return (
<main>
<h1>Products</h1>
<Suspense fallback={<p>Loading products...</p>}>
<ProductList />
</Suspense>
</main>
);
}

While ProductList is loading its data, the fallback (<p>Loading products...</p>) is shown in its place. The rest of the page - like the heading - is visible right away.

Error pages are shown when something goes wrong. Next.js lets you create custom error pages using special files in your app/ folder.

There are three types of error files:

  • not-found.tsx - shown when a page does not exist (404 error)
  • error.tsx - shown when something crashes on the server (500 error)
  • global-error.tsx - shown when an error happens inside the root layout

This is the most common error page. It shows up when a user visits a URL that does not exist. You can create a not-found.tsx file at the root of app/ to customize what the 404 page looks like.

app/
├── products/
| └── not-found.tsx -> Custom 404 page for the /products section (optional)
└── not-found.tsx -> Custom 404 page for the whole app

A not-found.tsx at the root level is used for all 404 errors across the app. A not-found.tsx inside a specific folder (like products/) is only used for 404 errors in that section.

// app/not-found.tsx
export default function NotFound() {
return (
<main>
<h1>Page Not Found</h1>
<p>The page you are looking for does not exist.</p>
</main>
);
}

The error.tsx file is shown when something goes wrong while loading a page - for example, if a database call fails. It must be a Client Component because it uses the reset function, which runs on the client side.

app/
├── products/
| └── error.tsx -> Error page for the /products section (optional)
└── error.tsx -> Error page for the whole app

An error.tsx at the root level catches errors across the whole app. An error.tsx inside a specific folder only catches errors in that section.

// app/error.tsx
'use client'; // This must be a Client Component
interface ErrorPageProps {
error: Error;
reset: () => void;
}
export default function ErrorPage({ error, reset }: ErrorPageProps) {
return (
<main>
<h1>Server Error</h1>
<p>Something went wrong on our end. Please try again later.</p>
<button onClick={reset}>Try Again</button>
</main>
);
}

This component receives two props:

  • error - the error object that tells you what went wrong.
  • reset - a function you can call to try re-rendering the page again.

The regular error.tsx file cannot catch errors that happen inside the root layout.tsx. For those cases, you can use global-error.tsx. It is a special file that catches errors anywhere in the app, including the root layout.

Because it replaces the entire page when it shows, it needs to include its own <html> and <body> tags.

app/
└── global-error.tsx -> Catches errors in the root layout and anywhere else
// app/global-error.tsx
'use client'; // This must be a Client Component
interface GlobalErrorPageProps {
error: Error;
reset: () => void;
}
export default function GlobalErrorPage({ error, reset }: GlobalErrorPageProps) {
return (
<html>
<body>
<main>
<h1>Unexpected Error</h1>
<p>An unexpected error occurred. Please try again later.</p>
<button onClick={reset}>Try Again</button>
</main>
</body>
</html>
);
}