🔷 TypeScript with Next.js: The Complete Guide

TypeScript brings static typing to your Next.js application, catching bugs before they reach production and making large codebases far easier to navigate and refactor. This tutorial covers everything from initial setup to typing every corner of the App Router— pages, layouts, Server Components, Server Actions, and beyond.

Information

This guide assumes you're using Next.js 13+ with the App Router. Most concepts also apply to the Pages Router with minor differences.

📖 1. Introduction

Next.js ships with first-class TypeScript support out of the box. You don't need extra plugins or complex configuration — just add a tsconfig.json file, and Next.js takes care of the rest, including automatic type generation for routes and configs.

TypeScript in Next.js
Type-Safe Pages & Layouts
Type-Safe Data Fetching
Type-Safe Server Actions
Type-Safe API Routes

🤔 2. Why Use TypeScript?

TypeScript catches an entire class of bugs before your code ever runs, and it dramatically improves the developer experience through autocompletion and inline documentation.

  • Compile-time safety— catch typos, missing props, and mismatched types before deployment.
  • Better editor support— autocomplete, inline docs, and instant refactoring across your codebase.
  • Self-documenting code— types describe the shape of data without needing separate documentation.
  • Safer refactors— the compiler flags every place a change breaks something.

Tip

Even if your team doesn't write strict TypeScript everywhere, enabling it in a Next.js project costs very little and pays off quickly as the codebase grows.

âš™ī¸ 3. Setting Up TypeScript

If you're starting a new project, TypeScript can be enabled during scaffolding:

Creating a new TypeScript Next.js project

npx create-next-app@latest my-app --typescript

To add TypeScript to an existing JavaScript project, create an empty tsconfig.jsonfile and start the dev server — Next.js will automatically install the required dependencies and populate the config for you.

Adding TypeScript to an existing project

touch tsconfig.json
npm run dev
# Next.js detects tsconfig.json and installs:
# typescript, @types/react, @types/node, @types/react-dom

Note

Rename your files from .js/.jsx to .ts/.tsx as you convert them. Files containing JSX markup must use the .tsx extension.

đŸ—‚ī¸ 4. Project Structure

A typical typed Next.js project separates concerns into predictable directories, making types easy to locate and share.

Project Root
app
layout.tsx
page.tsx
tsconfig.json

Tip

Keep shared types in a dedicated types/ directory so they can be imported across pages, components, and API routes without duplication.

đŸ› ī¸ 5. TypeScript Configuration

Next.js generates a sensible default tsconfig.json, but tightening a few options improves type safety significantly.

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

Best Practice

Always enable strict: true. It bundles several checks — including strictNullChecks and noImplicitAny— that catch the majority of real-world bugs.

📄 6. Typing Pages

Page components in the App Router receive typed params and searchParams as props.

app/blog/[slug]/page.tsx

type PageProps = {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};

export default async function BlogPage({ params, searchParams }: PageProps) {
  const { slug } = await params;
  const { page } = await searchParams;

  return <article>Viewing post: {slug}</article>;
}

Important

As of recent Next.js versions, params and searchParams are Promises that must be awaited before use.

🧩 7. Typing Layouts

Layout components accept a typed children prop and, for dynamic segments, typed params as well.

app/dashboard/layout.tsx

import type { ReactNode } from "react";

type LayoutProps = {
  children: ReactNode;
  params: Promise<{ teamId: string }>;
};

export default async function DashboardLayout({ children, params }: LayoutProps) {
  const { teamId } = await params;

  return (
    <section>
      <h2>Team: {teamId}</h2>
      {children}
    </section>
  );
}

🧱 8. Typing Components

Regular reusable components should be typed with explicit props interfaces rather than relying on inference, which improves readability and editor tooltips.

components/Card.tsx

type CardProps = {
  title: string;
  description: string;
  footer?: React.ReactNode;
};

export function Card({ title, description, footer }: CardProps) {
  return (
    <div className="card">
      <h3>{title}</h3>
      <p>{description}</p>
      {footer && <div className="card-footer">{footer}</div>}
    </div>
  );
}

đŸŽ¯ 9. Typing Props

Choose between type and interface consistently across your codebase. Both work, but each has strengths worth knowing.

Props with type

type ButtonProps = {
  label: string;
  onClick: () => void;
  variant?: "primary" | "secondary";
};

export function Button({ label, onClick, variant = "primary" }: ButtonProps) {
  return <button className={variant} onClick={onClick}>{label}</button>;
}

Props with interface

interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: "primary" | "secondary";
}

export function Button({ label, onClick, variant = "primary" }: ButtonProps) {
  return <button className={variant} onClick={onClick}>{label}</button>;
}

Tip

Use type for unions, intersections, and mapped types. Use interface when you expect consumers to extend or merge declarations, such as in shared component libraries.

đŸ–Ĩī¸ 10. Typing Server Components

Server Components are async by default and can directly type the data returned from server-side calls like database queries or fetch.

app/products/page.tsx

type Product = {
  id: string;
  name: string;
  price: number;
};

async function getProducts(): Promise<Product[]> {
  const res = await fetch("https://api.example.com/products");
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name} &mdash; ${product.price}</li>
      ))}
    </ul>
  );
}

Note

Server Components never receive event handlers as props
since they render exclusively on the server and produce static markup.

đŸ’ģ 11. Typing Client Components

Client Components require the "use client" directive and commonly use typed useState and useEffect hooks.

components/Counter.tsx

"use client";

import { useState } from "react";

type CounterProps = {
  initialValue?: number;
};

export function Counter({ initialValue = 0 }: CounterProps) {
  const [count, setCount] = useState<number>(initialValue);

  return (
    <button onClick={() => setCount((prev) => prev + 1)}>
      Count: {count}
    </button>
  );
}

Tip

Explicitly type useState<T>() when the initial value doesn't fully describe the eventual shape of your state, such as useState<User | null>(null).

🔌 12. Typing Route Handlers

Route Handlers (formerly API Routes) receive a typed NextRequest and return a NextResponse.

app/api/users/route.ts

import { NextRequest, NextResponse } from "next/server";

type User = {
  id: string;
  name: string;
};

export async function GET(request: NextRequest) {
  const users: User[] = [{ id: "1", name: "Ada Lovelace" }];
  return NextResponse.json(users);
}

export async function POST(request: NextRequest) {
  const body: Partial<User> = await request.json();

  if (!body.name) {
    return NextResponse.json({ error: "Name is required" }, { status: 400 });
  }

  return NextResponse.json({ id: crypto.randomUUID(), name: body.name }, { status: 201 });
}

⚡ 13. Typing Server Actions

Server Actions are typed like regular async functions and are frequently paired with FormData for form submissions.

app/actions.ts

"use server";

type CreatePostResult = {
  success: boolean;
  error?: string;
};

export async function createPost(formData: FormData): Promise<CreatePostResult> {
  const title = formData.get("title");

  if (typeof title !== "string" || title.trim() === "") {
    return { success: false, error: "Title is required" };
  }

  // Persist the post to a database here.
  return { success: true };
}

Important

Values pulled from FormData.get() are typed as FormDataEntryValue | null, so always narrow them with a type check before use.

đŸˇī¸ 14. Typing Metadata

Next.js exports a Metadata type for static metadata and a generateMetadata function for dynamic, per-page metadata.

app/blog/[slug]/page.tsx

import type { Metadata } from "next";

type Props = {
  params: Promise<{ slug: string }>;
};

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;

  return {
    title: `Post: ${slug}`,
    description: "An article on our blog.",
  };
}

🌐 15. Typing Data Fetching

Wrap raw fetch calls in typed helper functions to avoid any leaking throughout your app.

lib/api.ts

type Post = {
  id: number;
  title: string;
  body: string;
};

export async function getPost(id: number): Promise<Post> {
  const res = await fetch(`https://api.example.com/posts/${id}`);

  if (!res.ok) {
    throw new Error(`Failed to fetch post ${id}`);
  }

  return res.json() as Promise<Post>;
}

Caution

response.json() always returns Promise<any> at the type level. Casting it doesn't validate the shape at runtime — use a schema validator like zod for untrusted external data.

📋 16. Typing Forms

Type form state carefully, especially for controlled inputs and validation errors.

components/SignupForm.tsx

"use client";

import { useState } from "react";

type FormState = {
  email: string;
  password: string;
};

type FormErrors = Partial<Record<keyof FormState, string>>;

export function SignupForm() {
  const [form, setForm] = useState<FormState>({ email: "", password: "" });
  const [errors, setErrors] = useState<FormErrors>({});

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
  }

  return (
    <form>
      <input name="email" value={form.email} onChange={handleChange} />
      {errors.email && <span>{errors.email}</span>}
    </form>
  );
}

🔑 17. Typing Environment Variables

By default, process.env variables are typed as string | undefined. Augmenting the global namespace makes them type-safe and autocompletable.

env.d.ts

declare namespace NodeJS {
  interface ProcessEnv {
    DATABASE_URL: string;
    NEXT_PUBLIC_API_URL: string;
    NODE_ENV: "development" | "production" | "test";
  }
}

Best Practice

For runtime validation in addition to compile-time types, use a library like zod or @t3-oss/env-nextjs to fail fast when a required variable is missing.

📡 18. Typing API Responses

Define shared response types so both the API route and the consuming client stay in sync.

types/api.ts

export type ApiSuccess<T> = {
  success: true;
  data: T;
};

export type ApiError = {
  success: false;
  error: string;
};

export type ApiResponse<T> = ApiSuccess<T> | ApiError;

Consuming a typed response

const result: ApiResponse<User> = await fetch("/api/user").then((r) => r.json());

if (result.success) {
  console.log(result.data.name);
} else {
  console.error(result.error);
}

Tip

This discriminated union pattern lets TypeScript automatically narrow result based on the success field, without manual type casting.

đŸ§Ŧ 19. Generics

Generics let you write flexible, reusable functions and components while preserving type safety across different data shapes.

components/List.tsx

type ListProps<T> = {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
};

export function List<T>({ items, renderItem }: ListProps<T>) {
  return <ul>{items.map((item, i) => <li key={i}>{renderItem(item)}</li>)}</ul>;
}

// Usage:
// <List items={users} renderItem={(user) => user.name} />

Example

Generics shine in data-fetching helpers too — a single fetchJSON<T>(url: string): Promise<T> function can safely type any endpoint's response.

🧰 20. Utility Types

TypeScript's built-in utility types transform existing types without duplicating definitions.

Utility TypePurposeExample
Partial<T>Makes all properties optionalPartial<User>
Required<T>Makes all properties requiredRequired<Config>
Pick<T, K>Selects a subset of propertiesPick<User, "id" | "name">
Omit<T, K>Excludes specific propertiesOmit<User, "password">
Record<K, V>Builds an object type from keys and valuesRecord<string, number>
ReturnType<T>Extracts a function's return typeReturnType<typeof getUser>

Tip

Combine utility types to avoid repeating yourself — for example, type UserPreview = Pick<User, "id" | "name"> derives a smaller type directly from your source of truth.

đŸ›¤ī¸ 21. Path Aliases

Path aliases eliminate long relative imports like ../../../components/Button in favor of clean, absolute-style paths.

tsconfig.json (paths)

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/components/*": ["components/*"],
      "@/lib/*": ["lib/*"],
      "@/types/*": ["types/*"]
    }
  }
}

Usage

import { Button } from "@/components/Button";
import type { User } from "@/types/user";

🔗 22. Module Resolution

Next.js uses the "bundler" module resolution strategy by default, which mirrors how modern bundlers actually resolve imports — more accurately than the older "node" strategy.

Information

If you see errors importing .json files or package subpath exports, confirm moduleResolution is set to "bundler" and resolveJsonModule is enabled in tsconfig.json.

đŸŽ¯ 23. Type Safety Best Practices

  1. Enable strict mode and never disable it for convenience.
  2. Avoid any — prefer unknown and narrow it with type guards.
  3. Validate external data (API responses, form input) at runtime with zod or similar.
  4. Centralize shared types in a types/ directory instead of duplicating them.
  5. Prefer discriminated unions over optional fields for mutually exclusive states.
  6. Run tsc --noEmit in CI to catch type errors before merge.

đŸšĢ 24. Common Type Errors

ErrorCauseFix
Object is possibly 'undefined'strictNullChecks catching an unguarded accessAdd a null check or optional chaining (?.)
Type 'X' is not assignable to type 'Y'Mismatched prop or return typeAlign the shape of the data with the declared type
Property does not exist on typeAccessing a field TypeScript doesn't know aboutUpdate the type definition or use a type guard
Cannot find moduleMissing path alias or incorrect import pathVerify tsconfig.json paths and file location

Warning

Avoid silencing errors with // @ts-ignore. Use // @ts-expect-errorinstead — it will flag itself as outdated once the underlying issue is actually fixed.

âąī¸ 25. Performance Considerations

TypeScript's type-checking runs separately from Next.js's build compiler (which uses SWC), so type errors don't necessarily slow down your dev server — but they can slow down tsc itself on large codebases.

  • Enable incremental: true in tsconfig.json to cache type-checking results between runs.
  • Use skipLibCheck: true to skip type-checking of third-party .d.ts files.
  • Split very large projects into multiple tsconfig.json files using project references.

Note

Next.js's build step reports type errors but compiles using SWC, which does not perform type-checking itself — run tsc --noEmit separately for full verification.

❓ 26. Frequently Asked Questions

Question

Do I need to type every single variable in my Next.js app?

Answer

No. TypeScript's inference handles most local variables automatically. Focus explicit typing on function signatures, component props, and API boundaries where inference can't reach.

Question

Can I use TypeScript with the Pages Router instead of the App Router?

Answer

Yes. Next.js exports GetServerSideProps, GetStaticProps, and NextPage types specifically for typing Pages Router files.

Question

Why does my params prop show a type error after upgrading Next.js?

Answer

Recent versions changed params and searchParams from plain objects to Promises. Update your types to Promise<{ slug: string }> and await them before use.

📌 27. Summary

TypeScript transforms a Next.js codebase from implicitly structured JavaScript into a self-verifying system, where the compiler catches mismatched props, malformed API responses, and missing environment variables before they ever reach a user.

Summary

  • Enable strictmode from day one — it's far easier than retrofitting it later.
  • Type every boundary: pages, layouts, Route Handlers, and Server Actions.
  • Use discriminated unions for API responses instead of loosely optional fields.
  • Centralize shared types and validate external data at runtime, not just at compile time.
  • Leverage generics and utility types to stay DRY without sacrificing safety.
>>"TypeScript doesn't just catch bugs — it documents intent, turning your codebase into a contract your future self will thank you for."