Next.js course Β· Module 9: Integrations and Advanced Features
Typed Routes - Safe links with TypeScript
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
Link with validation
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> // OKTypeScript 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'); // ErrorThe 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 with Typed Routes
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.
2. External links
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> // OKOnly 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 buildYou 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 devCheck 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 typesNext.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 <Link href="{route.path}">
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. Typed Routes in Next.js provide:
Hands-on tasks in the game
- Horizontal ordering
Arrange the elements in the correct order: export const β fetchCache β =