Skip to content

Next.js with TypeScript — Full-Stack React Guide

DodaTech Updated 2026-06-28 8 min read

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

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

  2. 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 for Params with the route segments.

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

  4. How do you enable ISR (Incremental Static Regeneration) in Next.js? Pass next: { revalidate: seconds } to fetch() or use revalidatePath() in Server Actions.

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

Does Next.js support TypeScript natively?

Yes. Next.js includes built-in TypeScript support since version 10. Creating a project with --typescript flag scaffolds everything automatically.

Should I use App Router or Pages Router?

App Router is the modern standard. Pages Router is legacy but still supported. For new projects, always use App Router.

How do I type search parameters in Next.js?

Use useSearchParams() from next/navigation in Client Components, or the searchParams prop in Server Component page props: { searchParams: { q?: string } }.

Can I use Zustand or Redux with Next.js TypeScript?

Yes. State management libraries work in Client Components. Create a provider wrapper with 'use client' and wrap your layout.

How does TypeScript help with Next.js middleware?

Middleware files (middleware.ts) are fully typed. You get type-safe access to NextRequest headers, cookies, and URL patterns.

What's the best way to share types between frontend and backend?

Create a types/ directory with shared interfaces. Both API routes and components import from the same source, ensuring consistency.

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