Use TypeScript in Next.js 16. Understand tsconfig.json and next-env.d.ts, type pages and layouts with PageProps and LayoutProps, enable typed routes and convert .js files.

Bishal Biswas
Self Learner
Why TypeScript
This course writes Book Nook in JavaScript, so you can focus on Next.js itself. But most Next.js projects you will meet at work, and the default create-next-app setup, use TypeScript.
TypeScript is JavaScript plus types: little labels that say what kind of value something is. Your editor checks the labels while you type, and catches mistakes before you even run the code:
function formatPrice(price: number) {
return `$${price.toFixed(2)}`;
}
formatPrice('12.99');
// Error: Argument of type 'string' is not assignable to parameter of type 'number'.
In plain JavaScript that bug only shows up when a customer opens the page and it crashes. Think of types as the labels on a parcel: the post office can refuse a wrong parcel at the counter, instead of you finding out when it arrives broken.
Next.js has TypeScript built in. You do not need extra build tools: files ending in .ts and .tsx just work. In this lesson you will:
- see what
create-next-appsets up for TypeScript - learn what
tsconfig.jsonandnext-env.d.tsare for - type pages, layouts and Route Handlers with the global
PageProps,LayoutPropsandRouteContexthelpers - turn on typed routes, so a typo in a link is a build error
- convert a Book Nook
.jsfile to.tsx, step by step
This is the only TypeScript lesson. After it, the course continues in JavaScript.
Step 1: a new TypeScript project
TypeScript is the default answer in create-next-app. With the recommended settings, or with the --ts flag, you get a TypeScript project:
npx create-next-app@latest book-nook-ts --ts --tailwind --eslint --app
The differences from the JavaScript project you know:
book-nook-ts/
├── app/
│ ├── layout.tsx .tsx instead of .js
│ └── page.tsx
├── next.config.ts the config is TypeScript too
├── next-env.d.ts Next.js type references (do not edit)
└── tsconfig.json TypeScript settings, replaces jsconfig.json
package.json gets four more dev dependencies: typescript, @types/node, @types/react and @types/react-dom. The @types packages hold type information for libraries that are written in JavaScript.
The root layout looks almost like yours, with two small additions:
// app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
import type { Metadata } from "next"brings in only a type.import typedisappears completely when the code is compiled.metadata: Metadatalabels the object, so the editor suggests every metadata field and complains about typos likedescripton.LayoutProps<"/">is a helper that Next.js generates for your routes. You will look at it closely in Step 4.
Step 2: tsconfig.json
tsconfig.json tells TypeScript how to check your code. Here is the one create-next-app writes:
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
You do not need to understand every line. The ones worth knowing:
| Option | What it means |
|---|---|
"strict": true |
The full set of safety checks. For example, a value that might be null must be checked before you use it. Keep it on. |
"allowJs": true |
.js files are allowed next to .ts files, so you can convert a project one file at a time. |
"noEmit": true |
TypeScript only checks the code. Next.js (with Turbopack) does the actual compiling. |
"plugins": [{ "name": "next" }] |
Turns on the Next.js editor plugin (Step 3). |
"paths" |
The @/ import alias. It used to live in jsconfig.json. |
"include" |
Which files to check. .next/types holds the route types Next.js generates for you. |
Step 3: next-env.d.ts and the editor plugin
A file ending in .d.ts contains only type declarations, no code. next-env.d.ts connects your project to Next.js’s own types:
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
- The first line loads the Next.js types.
- The second one teaches TypeScript that importing an image file (like
import cover from './cover.png') is fine. - The
importline pulls in the route types that Next.js generates (wherePagePropsand friends come from).
Next.js rewrites this file every time you run next dev, next build or next typegen, so never edit it. It is listed in the .gitignore that create-next-app makes, so it is not committed. If you need your own global types, put them in a separate file such as types.d.ts and add it to include.
Turn on the Next.js plugin in VS Code
The "name": "next" plugin adds Next.js-specific hints in the editor: it warns about invalid route config values, shows docs for options, and checks that hooks like useState are only used in Client Components. To make VS Code use it:
- Open a
.tsxfile. - Open the command palette with Ctrl + Shift + P (Windows and Linux) or Cmd + Shift + P (macOS).
- Run TypeScript: Select TypeScript Version and pick Use Workspace Version.
Step 4: typing pages and layouts with PageProps and LayoutProps
In Next.js 16, params and searchParams are Promises (you have been writing await params all course). Typing that by hand is long-winded:
// app/books/[id]/page.tsx (the long way)
export default async function BookPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// ...
}
Next.js generates global helper types from your folders, so you can write this instead:
// app/books/[id]/page.tsx
export default async function BookPage(props: PageProps<'/books/[id]'>) {
const { id } = await props.params; // id is a string
const query = await props.searchParams;
// ...
}
PageProps<'/books/[id]'>takes the route as text, written exactly like the folder path underapp.- Next.js knows
[id]is a dynamic segment, soparamsbecomes a Promise of{ id: string }. - The helpers are global: you use them without any
import. - They are generated during
next dev,next build, or withnpx next typegen. If your editor saysCannot find name 'PageProps', start the dev server once (or runnpx next typegen).
If you misspell a param, TypeScript catches it at once:
const { bookId } = await props.params;
app/books/[id]/page.tsx(24,11): error TS2339: Property 'bookId' does not exist on type '{ id: string; }'.
Layouts and slots
LayoutProps works the same way for layouts, and it even knows about the parallel route slots from Parallel Routes and Intercepting Routes:
// app/books/layout.tsx
export default function BooksLayout({ children, modal }: LayoutProps<'/books'>) {
return (
<>
{children}
{modal}
</>
);
}
Because the folder app/books/@modal exists, LayoutProps<'/books'> includes a modal prop. Rename the folder and the layout shows a type error.
Route Handlers
Route Handlers get RouteContext:
// app/api/books/[id]/route.ts
import { getBook } from '@/lib/books';
export async function GET(request: Request, ctx: RouteContext<'/api/books/[id]'>) {
const { id } = await ctx.params;
const book = await getBook(id);
if (!book) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
return Response.json(book);
}
generateMetadata and Client Components
generateMetadata takes the same props as the page, and returns Metadata:
// app/books/[id]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata({ params }: PageProps<'/books/[id]'>): Promise<Metadata> {
const { id } = await params;
const book = await getBook(id);
return { title: book ? book.title : 'Book not found' };
}
Your own components describe their props with a normal TypeScript type:
// components/AddToCartButton.tsx
'use client';
type AddToCartButtonProps = {
bookId: string;
title: string;
};
export default function AddToCartButton({ bookId, title }: AddToCartButtonProps) {
return (
<button onClick={() => console.log('add', bookId)}>
Add {title} to cart
</button>
);
}
Now <AddToCartButton bookId={42} /> is an error in two ways: 42 is not a string, and title is missing.
Step 5: typed routes
Links are strings, and strings are easy to mistype. With typed routes on, Next.js checks every href against your real routes. Turn it on in the config:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
typedRoutes: true,
};
export default nextConfig;
Now a typo in a link fails the type check:
<Link href="/bookz">Back to all books</Link>
app/books/[id]/page.tsx(32,13): error TS2322: Type '"/bookz"' is not assignable to type 'UrlObject | RouteImpl<"/bookz">'.
Dynamic links like href={`/books/${book.id}`} are checked too. The same goes for router.push() and router.replace() from next/navigation. If you build a path from pieces TypeScript cannot follow, cast it with as Route (import type Route from 'next').
Note: In a project that is half JavaScript, typed routes only know about routes whose
pagefile is.tsor.tsx. While converting Book Nook,/books(stillpage.js) was missing from the generated list, so<Link href="/books">was reported as an error untilapp/books/page.jsbecamepage.tsx. Turn typed routes on once your pages are converted.
Step 6: convert a Book Nook file to TypeScript
You do not need to convert a whole project at once. Thanks to allowJs, .js and .tsx files live happily side by side. Here is how to move an existing JavaScript project over.
6.1 Install the packages
npm install --save-dev typescript @types/node @types/react @types/react-dom
(If you skip this, Next.js notices your first .ts file and installs what is missing for you, but doing it yourself keeps you in control of the versions.)
6.2 Rename one file and start the server
Start with the data layer. Rename lib/books.js to lib/books.ts, then run npm run dev. Next.js sees TypeScript for the first time and writes a tsconfig.json and next-env.d.ts for you.
6.3 Fix the generated tsconfig.json
Two things in the generated file need your attention:
- It does not copy the
pathsfrom yourjsconfig.json. Every@/import breaks:
Error: Module not found: Can't resolve '@/components/Modal'
Copy the "paths" block from jsconfig.json into compilerOptions in tsconfig.json, then delete jsconfig.json. Having both confuses tools.
- It sets
"strict": falsefor an existing project, to avoid a flood of errors. Change it totruewhen you are ready. New code should always be strict.
6.4 Add types to the data layer
Describe a book once, and use that type everywhere:
// lib/books.ts
import 'server-only';
import { cacheLife, cacheTag } from 'next/cache';
import { db } from './db';
export type Book = {
id: string;
title: string;
author: string;
price: number;
genre: string;
cover: string;
description: string;
};
export async function getBooks(): Promise<Book[]> {
'use cache';
cacheLife('hours');
cacheTag('books');
return db.query('SELECT * FROM books ORDER BY title');
}
export async function getBook(id: string): Promise<Book | null> {
'use cache';
cacheLife('days');
cacheTag('books', `book-${id}`);
const rows = await db.query('SELECT * FROM books WHERE id = $1', [id]);
return rows[0] ?? null;
}
export type Bookdescribes the shape of a row in thebookstable.Promise<Book[]>says “this async function resolves to a list of books”.Book | nullmeans “a book, ornullwhen there is none”.lib/db.jscan stay JavaScript for now. Itsqueryreturns rows TypeScript knows nothing about, so the return types you write here are what gives the rest of the app its safety.
6.5 Convert the book page
Rename app/books/[id]/page.js to page.tsx and add the types:
// app/books/[id]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getBook, getBooks } from '@/lib/books';
export async function generateStaticParams() {
const books = await getBooks();
return books.map((book) => ({ id: book.id }));
}
export async function generateMetadata({ params }: PageProps<'/books/[id]'>): Promise<Metadata> {
const { id } = await params;
const book = await getBook(id);
return { title: book ? book.title : 'Book not found' };
}
export default async function BookPage(props: PageProps<'/books/[id]'>) {
const { id } = await props.params;
const book = await getBook(id);
if (!book) notFound();
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="text-3xl font-bold">{book.title}</h1>
<p className="text-zinc-600">by {book.author}</p>
<p className="mt-4 font-semibold">${book.price.toFixed(2)}</p>
</main>
);
}
Hover over book after the if line in VS Code. Its type is Book, not Book | null: TypeScript understands that notFound() never returns, so after that line book cannot be null. Delete the if line and book.title turns red with “‘book’ is possibly ‘null’”. That is strict mode protecting you from the classic “Cannot read properties of null” crash.
Keep going file by file: components next, then the remaining pages. Run npm run build after each step.
Step 7: type errors stop the build
next build runs a type check after compiling. Any error stops it:
Running TypeScript ...
app/books/[id]/page.tsx(32,13): error TS2322: Type '"/bookz"' is not assignable to type 'UrlObject | RouteImpl<"/bookz">'.
Failed to type check.
That is a feature: broken code never reaches your users. To check types without building (handy before you commit, or in CI), generate the route types and run the TypeScript compiler yourself:
npx next typegen
npx tsc --noEmit
There is a typescript.ignoreBuildErrors option in the config that skips this check. Treat it as an emergency switch only: if you use it, run tsc --noEmit somewhere else, or type errors will ship.
Common mistakes
Cannot find name ‘PageProps’
The helpers are generated, not installed. Run npm run dev once, or npx next typegen, and make sure tsconfig.json includes ".next/types/**/*.ts" and "next-env.d.ts".
All @/ imports broke after adding TypeScript
The generated tsconfig.json has no paths. Copy them from jsconfig.json and delete jsconfig.json.
Editing next-env.d.ts
Your changes vanish the next time the dev server starts. Put your own declarations in a different .d.ts file.
Typing params as a plain object
// Wrong in Next.js 16: params is a Promise
export default function Page({ params }: { params: { id: string } }) {}
Use PageProps<'/books/[id]'>, which already has the Promise in it, and await the params.
Typed routes complain about a page that exists
If that route’s page file is still .js, typed routes do not know about it yet. Convert the page to .tsx, or leave typedRoutes off until the conversion is finished.
Quick recap
create-next-appuses TypeScript by default:.tsxfiles,tsconfig.json,next.config.tsandnext-env.d.ts.tsconfig.jsonholds the settings. Keep"strict": trueand thepathsalias in it.next-env.d.tsis generated and git-ignored. Never edit it.PageProps<'/route'>,LayoutProps<'/route'>andRouteContext<'/route'>are global helpers generated from your folders. They typeparams(as a Promise),searchParamsand parallel route slots.typedRoutes: trueturns link typos into type errors, for routes whose pages are TypeScript.- Convert one file at a time: install the packages, rename, fix
paths, add aBooktype to the data layer, then type the pages. - Type errors fail
next build. Usenpx next typegenandnpx tsc --noEmitto check without building.
// app/books/[id]/page.tsx: the book page, converted to TypeScript
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getBook, getBooks } from '@/lib/books'; // returns Promise<Book | null>
export async function generateStaticParams() {
const books = await getBooks();
return books.map((book) => ({ id: book.id }));
}
// PageProps is generated from the folders: params is Promise<{ id: string }>
export async function generateMetadata({ params }: PageProps<'/books/[id]'>): Promise<Metadata> {
const { id } = await params;
const book = await getBook(id);
return { title: book ? book.title : 'Book not found' };
}
export default async function BookPage(props: PageProps<'/books/[id]'>) {
const { id } = await props.params;
const book = await getBook(id);
if (!book) notFound(); // after this line TypeScript knows book is a Book
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="text-3xl font-bold">{book.title}</h1>
<p className="text-zinc-600">by {book.author}</p>
<p className="mt-4 font-semibold">${book.price.toFixed(2)}</p>
</main>
);
}
// --- next file: lib/books.ts (the shape every page relies on)
// export type Book = {
// id: string; title: string; author: string; price: number;
// genre: string; cover: string; description: string;
// };
// export async function getBook(id: string): Promise<Book | null> { ... }
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