Next.js with TypeScript — Full-Stack React Guide
In this tutorial, you will learn about Next.js with TypeScript. We cover key concepts, practical examples, and best practices to help you master this topic.
TypeScript and Next.js together give you end-to-end type safety from database queries to React components, catching type mismatches across the server-client boundary before deployment.
What You'll Learn
- Setting up a Next.js TypeScript project
- App Router and typed routes
- Server Components vs Client Components
- Typed API routes
- Data fetching patterns
- Type-safe form actions
Why It Matters
Next.js bridges frontend and backend in a single framework. Without TypeScript, API route responses can drift from the types expected by frontend components. TypeScript ensures that what the server sends matches what the client expects — eliminating an entire class of runtime errors.
Real-World Use
The Doda Browser settings dashboard uses Next.js with TypeScript to manage user preferences across millions of installations. Typed API routes guarantee that browser settings (theme, privacy, extensions) are serialized and consumed with zero type mismatches.
Learning Path
flowchart LR A[Express APIs] --> B[Next.js] B --> C[NestJS] B --> D[You Are Here] C --> E[Database Access] D --> F[Testing]
Creating a Next.js TypeScript Project
Next.js includes TypeScript support out of the box:
npx create-next-app@latest my-app --typescript --tailwind --app
The --app flag enables the App Router (recommended for new projects). The scaffolded project includes a tsconfig.json with recommended settings.
Project Structure
my-app/
src/
app/
layout.tsx
page.tsx
api/
users/
route.ts
components/
Button.tsx
lib/
db.ts
types.ts
App Router and Typed Routes
The App Router uses file-system routing. Each folder maps to a URL segment:
// src/app/page.tsx — the home page
import Link from 'next/link';
export default function Home() {
return (
<main>
<h1>Welcome to Next.js + TypeScript</h1>
<Link href="/users">View Users</Link>
</main>
);
}
// src/app/users/page.tsx
interface User {
id: string;
name: string;
email: string;
}
async function getUsers(): Promise<User[]> {
const res = await fetch('https://jsonplaceholder.typicode.com/users');
return res.json();
}
export default async function UsersPage() {
const users = await getUsers();
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} — {user.email}
</li>
))}
</ul>
);
}
The async function UsersPage() pattern is a Server Component — it runs on the server, fetches data directly, and sends only HTML to the client. TypeScript ensures the fetched data matches the User interface.
Server Components vs Client Components
Server Components render on the server and never send JavaScript to the client. Client Components add interactivity:
// src/app/counter.tsx — Client Component
'use client';
import { useState } from 'react';
interface CounterProps {
initialValue?: number;
}
export default function Counter({ initialValue = 0 }: CounterProps) {
const [count, setCount] = useState(initialValue);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
The 'use client' directive tells Next.js to treat this as a Client Component. TypeScript validates the props both on the server (initial render) and client (hydration).
Key insight: Only Client Components can use hooks, event handlers, and browser APIs. Server Components can access databases, file systems, and secrets directly.
Typed API Routes
Next.js App Router defines API routes using route.ts files:
// src/app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface User {
id: string;
name: string;
email: string;
}
const users: User[] = [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
];
export async function GET() {
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
// Validate the request body
if (!body.name || !body.email) {
return NextResponse.json(
{ error: 'Name and email are required' },
{ status: 400 }
);
}
const newUser: User = {
id: String(users.length + 1),
name: body.name,
email: body.email,
};
users.push(newUser);
return NextResponse.json(newUser, { status: 201 });
}
NextResponse.json() infers the response type from the payload. If you return inconsistent shapes, TypeScript helps catch the issue.
Dynamic Route Handlers
// src/app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface Params {
params: { id: string };
}
export async function GET(_request: NextRequest, { params }: Params) {
const user = users.find((u) => u.id === params.id);
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json(user);
}
export async function DELETE(_request: NextRequest, { params }: Params) {
const index = users.findIndex((u) => u.id === params.id);
if (index === -1) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
users.splice(index, 1);
return NextResponse.json({ message: 'Deleted' });
}
Type-Safe Data Fetching
Fetch data on the server and type the response:
// src/lib/types.ts
export interface Post {
userId: number;
id: number;
title: string;
body: string;
}
// src/lib/api.ts
export async function getPosts(): Promise<Post[]> {
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
next: { revalidate: 3600 }, // ISR: revalidate every hour
});
if (!res.ok) {
throw new Error(`Failed to fetch posts: ${res.status}`);
}
return res.json();
}
// src/app/posts/page.tsx
import { getPosts } from '@/lib/api';
import type { Post } from '@/lib/types';
export default async function PostsPage() {
const posts = await getPosts();
return (
<div>
<h1>Posts</h1>
{posts.map((post: Post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.body}</p>
</article>
))}
</div>
);
}
Type-Safe Server Actions
Server Actions let you run server code directly from Client Components:
// src/app/users/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
interface CreateUserInput {
name: string;
email: string;
}
export async function createUser(formData: FormData) {
const input: CreateUserInput = {
name: formData.get('name') as string,
email: formData.get('email') as string,
};
if (!input.name || !input.email) {
return { error: 'Name and email are required' };
}
// Save to database
const user = { id: String(Date.now()), ...input };
revalidatePath('/users');
return { success: true, user };
}
// src/app/users/new/page.tsx
import { createUser } from './actions';
export default function NewUserPage() {
return (
<form action={createUser}>
<input type="text" name="name" required />
<input type="email" name="email" required />
<button type="submit">Create User</button>
</form>
);
}
Common Mistakes
1. Forgetting 'use client' for interactive components
Server Components cannot use hooks. If you use useState, useEffect, or event handlers without 'use client', Next.js throws a build error.
2. Mixing server and client imports incorrectly
Server-only modules (like fs, crypto) cannot be imported by Client Components. Use the server-only package to enforce boundaries.
3. Not handling API route errors
Every API route should handle its error cases. Unhandled rejections return generic 500 responses with no useful information.
4. Over-fetching in Client Components
Client Components trigger Waterfall requests. Prefer fetching data in Server Components and passing it as props.
5. Ignoring the next: { revalidate } option
Without revalidation, fetch caches permanently or never caches depending on the default. Configure ISR explicitly.
6. Using any for route params
Dynamic route params are typed — use the Params pattern instead of casting.
7. Not validating form data in Server Actions
Never trust form data. Always validate and sanitize server-side, even if you validated on the client.
Practice Questions
What's the difference between Server Components and Client Components? Server Components run on the server, have no client JS, and can access databases directly. Client Components hydrate on the client and support interactivity.
How do you type dynamic route params in the App Router? The second argument to the handler function is
{ params: { id: string } }. Define an interface forParamswith the route segments.What does
'use server'do in a Server Action? It marks a function as executable on the server. The function can be called from Client Components as a direct server call.How do you enable ISR (Incremental Static Regeneration) in Next.js? Pass
next: { revalidate: seconds }tofetch()or userevalidatePath()in Server Actions.Can you use TypeScript generics with Next.js API routes? Yes. The
NextResponse.json()accepts a generic:NextResponse.json<User>(user)for better type inference.
Challenge
Build a full-stack blog application with Next.js: typed API routes for CRUD posts, a Server Component listing page, a Client Component for creating posts, and Server Actions for form handling.
FAQ
Mini Project
Build a movie database with Next.js and TypeScript:
- Server Component — list movies fetched from a public API
- API Routes — search movies, save favorites
- Client Component — search form with debounced input
- Server Action — add/remove favorites
- Dynamic Routes — individual movie page at
/movies/[id] - ISR — revalidate movie data every hour
Use shared types for Movie, SearchResult, and Favorite interfaces.
What's Next
You've mastered Next.js with TypeScript. Now learn structured backend architecture with {{< ref "46-nestjs" >}}, or connect your app to a database with {{< ref "47-database-access" >}}.
To ensure your full-stack app is reliable, explore {{< ref "48-testing" >}} for testing strategies.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro