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
đ 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.
đ¤ 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
âī¸ 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 --typescriptTo 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-domNote
đī¸ 4. Project Structure
A typical typed Next.js project separates concerns into predictable directories, making types easy to locate and share.
Tip
đ ī¸ 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
đ 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
đ§Š 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
đĨī¸ 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} — ${product.price}</li>
))}
</ul>
);
}Note
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
đ 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
đˇī¸ 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
đ 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
đĄ 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
đ§Ŧ 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
đ§° 20. Utility Types
TypeScript's built-in utility types transform existing types without duplicating definitions.
| Utility Type | Purpose | Example |
|---|---|---|
| Partial<T> | Makes all properties optional | Partial<User> |
| Required<T> | Makes all properties required | Required<Config> |
| Pick<T, K> | Selects a subset of properties | Pick<User, "id" | "name"> |
| Omit<T, K> | Excludes specific properties | Omit<User, "password"> |
| Record<K, V> | Builds an object type from keys and values | Record<string, number> |
| ReturnType<T> | Extracts a function's return type | ReturnType<typeof getUser> |
Tip
đ¤ī¸ 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
đ¯ 23. Type Safety Best Practices
- Enable strict mode and never disable it for convenience.
- Avoid any â prefer unknown and narrow it with type guards.
- Validate external data (API responses, form input) at runtime with zod or similar.
- Centralize shared types in a types/ directory instead of duplicating them.
- Prefer discriminated unions over optional fields for mutually exclusive states.
- Run tsc --noEmit in CI to catch type errors before merge.
đĢ 24. Common Type Errors
| Error | Cause | Fix |
|---|---|---|
| Object is possibly 'undefined' | strictNullChecks catching an unguarded access | Add a null check or optional chaining (?.) |
| Type 'X' is not assignable to type 'Y' | Mismatched prop or return type | Align the shape of the data with the declared type |
| Property does not exist on type | Accessing a field TypeScript doesn't know about | Update the type definition or use a type guard |
| Cannot find module | Missing path alias or incorrect import path | Verify tsconfig.json paths and file location |
Warning
âąī¸ 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
â 26. Frequently Asked Questions
Question
Answer
Question
Answer
Question
Answer
đ 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.