Next.js course Β· Module 9: Integrations and Advanced Features

Typed Routes - Safe links with TypeScript

9 min read
In this lesson12

In the navigation systems of Metropolis Quantum 2150 one wrong address sends a transport to a dock that does not exist. In an application it looks harmless: a typo in href, TypeScript stays silent, and after a click the user lands on a 404 page. Matrix, the route guardian of the Metropolis, therefore demands that every path is checked before launch. Typed Routes make this possible - a Next.js feature that provides type safety for the paths in your application.

Problem: Errors in paths

Without Typed Routes paths are plain strings, so the compiler accepts any text:

1// Easy to typo - no validation in TypeScript
2<Link href="/prodcuts">Products</Link>  // Typo!
3<Link href="/users/123/setings">Settings</Link>  // Another typo!
4
5// No parameter checking
6redirect('/products/' + productId);  // What if productId is undefined?

All three lines compile, and the errors only surface for the user.

Solution: Typed Routes

Typed Routes automatically generates types for all paths in the application based on the structure of the app directory.

Enabling Typed Routes

Since Next.js 15.5 the typedRoutes option is stable and you set it at the top level of the config. Previously it was hidden in the experimental object:

1// next.config.js
2module.exports = {
3  typedRoutes: true,
4};

After enabling it and running next dev, next build or next typegen, Next.js generates a file with the definitions of all routes in the .next/types directory. The project must use TypeScript, and tsconfig.json must include .next/types/**/*.ts.

Basic usage

The Link component from next/link now accepts only existing paths in href:

1import Link from 'next/link';
2
3function Navigation() {
4  return (
5    <nav>
6      {/* Correct paths - TypeScript OK */}
7      <Link href="/">Home</Link>
8      <Link href="/products">Products</Link>
9      <Link href="/about">About</Link>
10
11      {/* Invalid path - TypeScript Error! */}
12      <Link href="/prodcuts">Products</Link>
13      {/* Type '"prodcuts"' is not assignable to type 'Route' */}
14    </nav>
15  );
16}

The /prodcuts typo becomes a compile error with a message that the text does not match the Route type. Correct links work exactly as before.

Dynamic segments

Dynamic segments such as [id] accept any value in their place, but the rest of the path must match:

1// For the structure: app/products/[id]/page.tsx
2<Link href="/products/123">Product 123</Link>  // OK
3
4// For the structure: app/users/[userId]/posts/[postId]/page.tsx
5<Link href="/users/456/posts/789">Post</Link>  // OK

TypeScript checks the shape of the address, not whether product 123 exists in the database.

useRouter with types

In the App Router the router methods from next/navigation are typed too: push, replace and prefetch:

1'use client';
2
3import { useRouter } from 'next/navigation';
4
5function ProductActions({ productId }: { productId: string }) {
6  const router = useRouter();
7
8  const handleEdit = () => {
9    // TypeScript validates the path
10    router.push(`/products/${productId}/edit`);
11  };
12
13  const handleDelete = () => {
14    // Correct
15    router.push('/products');
16  };
17
18  const handleBroken = () => {
19    // TypeScript Error if /prodcts does not exist
20    router.push('/prodcts');
21  };
22
23  return (
24    <div>
25      <button onClick={handleEdit}>Edit</button>
26      <button onClick={handleDelete}>Delete</button>
27    </div>
28  );
29}

The template /products/${productId}/edit is accepted because it matches the route pattern, while /prodcts is rejected.

redirect() with types

It is worth keeping the paths you pass to redirect() under the same discipline:

1import { redirect } from 'next/navigation';
2
3async function ProtectedPage() {
4  const session = await getSession();
5
6  if (!session) {
7    // TypeScript validates the path
8    redirect('/login');
9  }
10
11  if (!session.isVerified) {
12    // Correct
13    redirect('/verify-email');
14  }
15
16  return <Dashboard />;
17}

The Next.js docs explicitly list typing for Link and the router methods, so with redirect() the safest choice is to use Route-typed constants, which you will meet in a moment.

The Route helper

Next.js generates a Route type imported from the next package, which you can use in your own functions:

1import type { Route } from 'next';
2
3// Function accepting only valid paths
4function navigateTo(path: Route) {
5  window.location.href = path;
6}
7
8navigateTo('/products');  // OK
9navigateTo('/prodcuts');  // Error

The function accepts only the address of an existing page, so the error appears right at the call site.

Type for dynamic paths

When you build an address from a non-literal string, TypeScript does not know its value, so you need the as Route assertion:

1import type { Route } from 'next';
2
3// Function generating a product link
4function getProductUrl(id: string): Route {
5  return `/products/${id}` as Route;
6}
7
8// Usage
9<Link href={getProductUrl('123')}>Product</Link>

An assertion is a promise you make to the compiler, so wrap it in a single helper function instead of scattering it across the app.

Working with search parameters

Query parameters are not part of the route definition, so you can add them as an object or in the string:

1import Link from 'next/link';
2
3function ProductFilters() {
4  return (
5    <div>
6      {/* Path with query params */}
7      <Link
8        href={{
9          pathname: '/products',
10          query: { category: 'electronics', sort: 'price' },
11        }}
12      >
13        Electronics (sort by price)
14      </Link>
15
16      {/* Or as a string */}
17      <Link href="/products?category=electronics&sort=price">
18        Electronics
19      </Link>
20    </div>
21  );
22}

In both cases the /products path is checked, while the category and sort parameters stay free.

Practical example - E-commerce navigation

In a real application you keep links in one place. The NavigationItem interface requires the href field to be of type Route:

1// types/navigation.ts
2import type { Route } from 'next';
3
4export interface NavigationItem {
5  label: string;
6  href: Route;
7  icon?: React.ReactNode;
8}
9
10export const mainNavigation: NavigationItem[] = [
11  { label: 'Home', href: '/' },
12  { label: 'Products', href: '/products' },
13  { label: 'Categories', href: '/categories' },
14  { label: 'About', href: '/about' },
15  { label: 'Contact', href: '/contact' },
16];
17
18export const userNavigation: NavigationItem[] = [
19  { label: 'Profile', href: '/account/profile' },
20  { label: 'Orders', href: '/account/orders' },
21  { label: 'Settings', href: '/account/settings' },
22];

Removing the /contact page from the app directory immediately flags an error in this array.

The navigation component only renders this array:

1// components/MainNav.tsx
2import Link from 'next/link';
3import { mainNavigation } from '@/types/navigation';
4
5export function MainNav() {
6  return (
7    <nav className="flex gap-6">
8      {mainNavigation.map((item) => (
9        <Link
10          key={item.href}
11          href={item.href}  // TypeScript knows this is a valid path
12          className="hover:text-blue-500"
13        >
14          {item.label}
15        </Link>
16      ))}
17    </nav>
18  );
19}

The component contains no hand-typed address, so there is no room for a typo.

Breadcrumbs have an optional href, because the last item is the current page, which is not a link:

1// components/Breadcrumbs.tsx
2import Link from 'next/link';
3import type { Route } from 'next';
4
5interface BreadcrumbItem {
6  label: string;
7  href?: Route;
8}
9
10interface BreadcrumbsProps {
11  items: BreadcrumbItem[];
12}
13
14export function Breadcrumbs({ items }: BreadcrumbsProps) {
15  return (
16    <nav aria-label="Breadcrumb" className="flex items-center gap-2 text-sm">
17      {items.map((item, index) => (
18        <span key={index} className="flex items-center gap-2">
19          {index > 0 && <span className="text-gray-400">/</span>}
20          {item.href ? (
21            <Link href={item.href} className="text-blue-600 hover:underline">
22              {item.label}
23            </Link>
24          ) : (
25            <span className="text-gray-600">{item.label}</span>
26          )}
27        </span>
28      ))}
29    </nav>
30  );
31}
32
33// Usage
34<Breadcrumbs
35  items={[
36    { label: 'Home', href: '/' },
37    { label: 'Products', href: '/products' },
38    { label: 'Electronics', href: '/products?category=electronics' },
39    { label: 'Smartphone X' },  // Last item without a link
40  ]}
41/>

The href?: Route type lets you skip the link, but if you do provide one, it must be valid.

Limitations of Typed Routes

1. Dynamic segments

TypeScript checks the route pattern, but not the value inserted in place of the segment:

1// TypeScript cannot validate the value of a dynamic segment
2const id = getUserInput();
3<Link href={`/products/${id}`}>Product</Link>  // Accepts any string
4
5// You can use a type assertion if you're sure
6<Link href={`/products/${id}` as Route}>Product</Link>

Validate user input separately, because the type will not tell you whether such a product exists.

Addresses outside the application are not of type Route:

1// External URLs are not Route
2<Link href="https://google.com">Google</Link>  // Error
3
4// Use a plain <a> for external links
5<a href="https://google.com">Google</a>

For external links use a plain a tag, ideally with rel="noopener noreferrer" when opening in a new tab.

3. Catch-all routes

A catch-all segment [...slug] accepts any number of parts after the prefix:

1// For app/docs/[...slug]/page.tsx
2// TypeScript checks the prefix but not the full path
3<Link href="/docs/getting-started/installation">Docs</Link>  // OK

Only the /docs/ prefix is checked, and whether a given docs page exists only shows up at runtime.

Segment config is checked too

The built-in Next.js TypeScript plugin also watches segment config exports. Such an export consists of the words export const, the option name, the = sign and the value:

1// app/products/page.tsx
2export const fetchCache = 'force-cache';

The plugin warns you if you type a value that does not exist, such as 'force-cash'. Remember that in Next.js 16 with Cache Components enabled the fetchCache option goes away in favor of the 'use cache' directive.

Migrating an existing project

Step 1: Enable typedRoutes

Migration starts with the same config change as in a new project:

1// next.config.js
2module.exports = {
3  typedRoutes: true,
4};

Nothing breaks yet after saving the file, because the route types have not been generated.

Step 2: Run build or dev

Types are created when you start the dev server or run a build:

1npm run dev
2# or
3npm run build

You can also use next typegen, which generates the types without starting a server.

Step 3: Fix TypeScript errors

Now check types across the whole project. The type-check script is usually tsc --noEmit added in package.json:

1# Check for errors
2npm run type-check
3
4# Common issues:
5# - Typos in paths
6# - Non-existent pages
7# - External links inside <Link>

Every reported error is a real link that would have led nowhere.

Debugging

If types do not work correctly, delete the generated files and let Next.js create them again:

1# Remove generated types and regenerate
2rm -rf .next/types
3npm run dev

Check the generated file in the .next/types directory. In older versions it was called link.d.ts, newer releases may use a different name:

1// .next/types/link.d.ts
2// This file contains all the generated Route types

Next.js also generates the global PageProps, LayoutProps and RouteContext helpers there, which type page parameters by path. Do not edit these files by hand, because they will be overwritten.

Summary

Typed Routes in Next.js provide:

  • Type safety - path errors caught at compile time
  • Autocomplete - the IDE suggests available paths
  • Refactoring - changing the routing structure automatically reveals errors
  • Documentation - the types document the available paths

My advice: enable typedRoutes at the start of a project and keep all navigation links in Route-typed arrays. In the next lesson you will learn next-intl, where paths also get a language prefix.

Remember: in Metropolis Quantum every course is checked before launch, and Typed Routes turn a broken link into a compile error.

Code for this lesson: App.tsx
1// Demo: Typed Routes - type-safe links with TypeScript
2// Next.js experimental typedRoutes provides type-safety for Link and router
3import React, { useState } from 'react';
4
5// Configuration in next.config.js:
6// /** @type {import('next').NextConfig} */
7// const nextConfig = {
8//   experimental: {
9//     typedRoutes: true,
10//   },
11// };
12//
13// After enabling, Next.js generates types in .next/types:
14// - Link href is type-checked
15// - router.push() requires a valid path
16// - Compile errors for non-existent routes
17
18interface Route {
19  path: string;
20  params?: Record<string, string>;
21  description: string;
22  valid: boolean;
23  component: string;
24}
25
26const routes: Route[] = [
27  { path: '/', description: 'Home page', valid: true, component: 'app/page.tsx' },
28  { path: '/products', description: 'Products listing', valid: true, component: 'app/products/page.tsx' },
29  { path: '/products/neural-link', description: 'Product detail (slug)', valid: true, component: 'app/products/[slug]/page.tsx', params: { slug: 'neural-link' } },
30  { path: '/dashboard', description: 'User dashboard', valid: true, component: 'app/dashboard/page.tsx' },
31  { path: '/dashboard/settings', description: 'Dashboard settings', valid: true, component: 'app/dashboard/settings/page.tsx' },
32  { path: '/api/health', description: 'Health check endpoint', valid: true, component: 'app/api/health/route.ts' },
33  { path: '/nonexistent', description: 'This route does not exist!', valid: false, component: '???' },
34  { path: '/dashbord', description: 'Typo in route name!', valid: false, component: '???' },
35];
36
37function TypedLinkExample({ route }: { route: Route }) {
38  return (
39    <div style={{
40      background: route.valid ? 'rgba(100,255,218,0.05)' : 'rgba(244,67,54,0.05)',
41      border: `1px solid ${route.valid ? 'rgba(100,255,218,0.15)' : 'rgba(244,67,54,0.2)'}`,
42      borderRadius: '8px', padding: '14px', marginBottom: '8px',
43    }}>
44      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
45        <div>
46          <code style={{
47            color: route.valid ? '#64ffda' : '#f44336',
48            fontSize: '0.9rem',
49          }}>
50            &lt;Link href="{route.path}"&gt;
51          </code>
52          {route.params && (
53            <span style={{ color: '#ff9800', fontSize: '0.75rem', marginLeft: '8px' }}>
54              params: {JSON.stringify(route.params)}
55            </span>
56          )}
57        </div>
58        <span style={{
59          background: route.valid ? 'rgba(76,175,80,0.15)' : 'rgba(244,67,54,0.15)',
60          color: route.valid ? '#4caf50' : '#f44336',
61          padding: '2px 10px', borderRadius: '10px', fontSize: '0.7rem',
62        }}>
63          {route.valid ? 'Type OK' : 'Type Error'}
64        </span>
65      </div>
66      <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '6px' }}>
67        <span style={{ color: '#78909c', fontSize: '0.8rem' }}>{route.description}</span>
68        <code style={{ color: '#78909c', fontSize: '0.7rem' }}>{route.component}</code>
69      </div>
70      {!route.valid && (
71        <div style={{
72          marginTop: '8px', padding: '8px',
73          background: 'rgba(244,67,54,0.08)', borderRadius: '4px',
74          fontFamily: 'monospace', fontSize: '0.75rem', color: '#f44336',
75        }}>
76          Type error: Type '"{route.path}"' is not assignable to type 'Route'.
77        </div>
78      )}
79    </div>
80  );
81}
82
83function CodeComparison() {
84  return (
85    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginTop: '20px' }}>
86      <div style={{
87        background: 'rgba(244,67,54,0.05)', border: '1px solid rgba(244,67,54,0.15)',
88        borderRadius: '10px', padding: '16px',
89      }}>
90        <h4 style={{ color: '#f44336', marginBottom: '8px', fontSize: '0.85rem' }}>
91          Without Typed Routes
92        </h4>
93        <pre style={{ margin: 0, color: '#b0bec5', fontSize: '0.75rem', lineHeight: 1.6 }}>
94{
95`// No verification at compile-time
96<Link href="/dashbord">  // Typo!
97<Link href="/old-page">  // Does not exist!
98
99// Error discovered only at runtime
100// 404 page, wrong navigation...`}
101        </pre>
102      </div>
103      <div style={{
104        background: 'rgba(76,175,80,0.05)', border: '1px solid rgba(76,175,80,0.15)',
105        borderRadius: '10px', padding: '16px',
106      }}>
107        <h4 style={{ color: '#4caf50', marginBottom: '8px', fontSize: '0.85rem' }}>
108          With Typed Routes
109        </h4>
110        <pre style={{ margin: 0, color: '#b0bec5', fontSize: '0.75rem', lineHeight: 1.6 }}>
111{
112`// TypeScript checks at compile-time
113<Link href="/dashbord">  // TS Error!
114// Type '"/dashbord"' is not
115// assignable to type 'Route'
116
117<Link href="/dashboard"> // OK!
118router.push("/products") // OK!`}
119        </pre>
120      </div>
121    </div>
122  );
123}
124
125export default function TypedRoutesDemo() {
126  const [showAll, setShowAll] = useState(true);
127
128  const displayed = showAll ? routes : routes.filter(r => !r.valid);
129
130  return (
131    <div style={{
132      background: '#0f0f23', minHeight: '100vh', padding: '24px',
133      color: '#fff', fontFamily: 'system-ui, sans-serif',
134    }}>
135      <h1 style={{ color: '#64ffda', marginBottom: '4px' }}>Typed Routes</h1>
136      <p style={{ color: '#b0bec5', marginBottom: '20px' }}>
137        Type-safe links with TypeScript - errors caught at compile-time
138      </p>
139
140      <div style={{
141        background: 'rgba(0,0,0,0.3)', borderRadius: '8px',
142        padding: '12px', marginBottom: '20px',
143      }}>
144        <code style={{ color: '#78909c', fontSize: '0.8rem' }}>
145          // next.config.js
146        </code>
147        <br />
148        <code style={{ color: '#64ffda', fontSize: '0.8rem' }}>
149          experimental: {'{'} typedRoutes: true {'}'}
150        </code>
151      </div>
152
153      <div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
154        <button onClick={() => setShowAll(true)} style={{
155          background: showAll ? '#64ffda' : 'transparent',
156          color: showAll ? '#0f0f23' : '#b0bec5',
157          border: '1px solid rgba(100,255,218,0.3)',
158          padding: '6px 14px', borderRadius: '20px', cursor: 'pointer',
159        }}>All Routes</button>
160        <button onClick={() => setShowAll(false)} style={{
161          background: !showAll ? '#f44336' : 'transparent',
162          color: !showAll ? '#fff' : '#b0bec5',
163          border: '1px solid rgba(255,255,255,0.1)',
164          padding: '6px 14px', borderRadius: '20px', cursor: 'pointer',
165        }}>Type Errors Only</button>
166      </div>
167
168      {displayed.map(r => (
169        <TypedLinkExample key={r.path} route={r} />
170      ))}
171
172      <CodeComparison />
173    </div>
174  );
175}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Typed Routes in Next.js provide:

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the elements in the correct order: export const β†’ fetchCache β†’ =

Useful articles