Back to CourseIntermediate13 min read
Next JS /

Using TypeScript with Next.js

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

Bishal Biswas

Self Learner

Published on: October 25, 2026
Intermediate Topic

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-app sets up for TypeScript
  • learn what tsconfig.json and next-env.d.ts are for
  • type pages, layouts and Route Handlers with the global PageProps, LayoutProps and RouteContext helpers
  • turn on typed routes, so a typo in a link is a build error
  • convert a Book Nook .js file 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 type disappears completely when the code is compiled.
  • metadata: Metadata labels the object, so the editor suggests every metadata field and complains about typos like descripton.
  • 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 import line pulls in the route types that Next.js generates (where PageProps and 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:

  1. Open a .tsx file.
  2. Open the command palette with Ctrl + Shift + P (Windows and Linux) or Cmd + Shift + P (macOS).
  3. 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 under app.
  • Next.js knows [id] is a dynamic segment, so params becomes a Promise of { id: string }.
  • The helpers are global: you use them without any import.
  • They are generated during next dev, next build, or with npx next typegen. If your editor says Cannot find name 'PageProps', start the dev server once (or run npx 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 page file is .ts or .tsx. While converting Book Nook, /books (still page.js) was missing from the generated list, so <Link href="/books"> was reported as an error until app/books/page.js became page.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 paths from your jsconfig.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": false for an existing project, to avoid a flood of errors. Change it to true when 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 Book describes the shape of a row in the books table.
  • Promise<Book[]> says “this async function resolves to a list of books”. Book | null means “a book, or null when there is none”.
  • lib/db.js can stay JavaScript for now. Its query returns 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-app uses TypeScript by default: .tsx files, tsconfig.json, next.config.ts and next-env.d.ts.
  • tsconfig.json holds the settings. Keep "strict": true and the paths alias in it.
  • next-env.d.ts is generated and git-ignored. Never edit it.
  • PageProps<'/route'>, LayoutProps<'/route'> and RouteContext<'/route'> are global helpers generated from your folders. They type params (as a Promise), searchParams and parallel route slots.
  • typedRoutes: true turns link typos into type errors, for routes whose pages are TypeScript.
  • Convert one file at a time: install the packages, rename, fix paths, add a Book type to the data layer, then type the pages.
  • Type errors fail next build. Use npx next typegen and npx tsc --noEmit to check without building.
script.js

// 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.