In the previous tutorial, we learned about TypeScript with Node.js and Express. Now let’s learn about TypeScript with Next.js — the most popular React framework for building full-stack applications.

By the end of this tutorial, you will know how to use TypeScript with the App Router, Server Components, Server Actions, Route Handlers, and typed data fetching.

Setting Up Next.js with TypeScript

npx create-next-app@latest my-app --typescript
cd my-app
npm run dev

Next.js has built-in TypeScript support. It generates a tsconfig.json automatically and compiles your code without any extra setup.

App Router Basics

Next.js uses a file-based router. Each folder in the app/ directory becomes a route.

Page Components

Every page.tsx file defines a route:

// app/page.tsx — the home page at /
export default function HomePage() {
  return <h1>Welcome to My App</h1>;
}
// app/about/page.tsx — the about page at /about
export default function AboutPage() {
  return <h1>About Us</h1>;
}

Layout Components

layout.tsx wraps pages with shared UI:

// app/layout.tsx — root layout
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

Typing Route Parameters

Dynamic Routes

Create a dynamic route with a folder name in brackets:

// app/users/[id]/page.tsx
interface PageProps {
  params: Promise<{ id: string }>;
}

export default async function UserPage({ params }: PageProps) {
  const { id } = await params;
  return <h1>User {id}</h1>;
}

In Next.js 15+, params is a Promise that you must await.

Multiple Parameters

// app/blog/[category]/[slug]/page.tsx
interface PageProps {
  params: Promise<{ category: string; slug: string }>;
}

export default async function BlogPost({ params }: PageProps) {
  const { category, slug } = await params;
  return <h1>{category}: {slug}</h1>;
}

Search Parameters

// app/search/page.tsx
// searchParams values can be string, string[] (repeated params), or undefined
interface PageProps {
  searchParams: Promise<{
    q?: string | string[];
    page?: string | string[];
  }>;
}

export default async function SearchPage({ searchParams }: PageProps) {
  const params = await searchParams;
  // Use the first value if the param is repeated (e.g. ?q=a&q=b)
  const q = Array.isArray(params.q) ? params.q[0] : (params.q ?? "");
  const page = Array.isArray(params.page) ? params.page[0] : (params.page ?? "1");
  return (
    <div>
      <h1>Search results for: {q}</h1>
      <p>Page: {page}</p>
    </div>
  );
}

Server Components vs Client Components

Server Components (Default)

By default, all components in the App Router are Server Components. They run on the server and can directly access databases, APIs, and file systems:

// app/users/page.tsx — Server Component (no "use client")
interface User {
  id: number;
  name: string;
  email: string;
}

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

export default async function UsersPage() {
  const users = await getUsers(); // Runs on the server

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name} ({user.email})</li>
      ))}
    </ul>
  );
}

Client Components

Add "use client" at the top of the file for interactive components:

"use client";

import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

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

Type Differences

Server Components can be async functions. Client Components cannot:

// Server Component — async is fine
export default async function ServerPage() {
  const data = await fetchData();
  return <div>{data.title}</div>;
}

// Client Component — cannot be async
// (In a real file, "use client" goes at the top, before imports)
"use client";
import { useState } from "react";

export default function ClientPage() {
  const [data, setData] = useState<{ title: string } | null>(null);
  // Must use useEffect or a data fetching library
  return <div>{data?.title}</div>;
}

Route Handlers (API Routes)

Route Handlers replace API routes from the Pages Router. They live in route.ts files:

// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";

interface User {
  id: number;
  name: string;
  email: string;
}

// GET /api/users
export async function GET() {
  const users: User[] = [
    { id: 1, name: "Alex", email: "alex@example.com" },
    { id: 2, name: "Sam", email: "sam@example.com" },
  ];

  return NextResponse.json(users);
}

// POST /api/users
export async function POST(request: NextRequest) {
  const body = await request.json() as { name: string; email: string };

  const newUser: User = {
    id: Date.now(),
    name: body.name,
    email: body.email,
  };

  return NextResponse.json(newUser, { status: 201 });
}

Dynamic Route Handlers

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";

interface RouteParams {
  params: Promise<{ id: string }>;
}

export async function GET(request: NextRequest, { params }: RouteParams) {
  const { id } = await params;
  // Fetch user by id
  return NextResponse.json({ id, name: "Alex" });
}

export async function DELETE(request: NextRequest, { params }: RouteParams) {
  const { id } = await params;
  // Delete user
  return new NextResponse(null, { status: 204 });
}

Server Actions

Server Actions let you run server-side code directly from client components. They replace many API routes:

// app/actions.ts
"use server";

interface FormState {
  message: string;
  success: boolean;
}

export async function createNote(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  if (!title || !content) {
    return { message: "Title and content are required", success: false };
  }

  // Save to database
  await saveToDatabase({ title, content });

  return { message: "Note created", success: true };
}

Using Server Actions in Forms

"use client";

import { useActionState } from "react";
import { createNote } from "./actions";

export default function NoteForm() {
  const [state, formAction, isPending] = useActionState(createNote, {
    message: "",
    success: false,
  });

  return (
    <form action={formAction}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />
      <button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create Note"}
      </button>
      {state.message && (
        <p className={state.success ? "success" : "error"}>
          {state.message}
        </p>
      )}
    </form>
  );
}

Metadata and SEO

Next.js uses typed metadata exports for SEO:

// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "My App",
  description: "A TypeScript + Next.js application",
  openGraph: {
    title: "My App",
    description: "A TypeScript + Next.js application",
    type: "website",
  },
};

Dynamic Metadata

// app/users/[id]/page.tsx
import type { Metadata } from "next";

interface PageProps {
  params: Promise<{ id: string }>;
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { id } = await params;
  const user = await getUser(id);

  return {
    title: user.name,
    description: `Profile of ${user.name}`,
  };
}

Loading and Error States

Loading UI

// app/users/loading.tsx
export default function Loading() {
  return <div>Loading users...</div>;
}

Next.js shows this automatically while the page data loads.

Error Handling

"use client";

// app/users/error.tsx
interface ErrorProps {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function ErrorPage({ error, reset }: ErrorProps) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Environment Variables

Next.js handles environment variables with type safety:

// next.config.ts
import type { NextConfig } from "next";

const config: NextConfig = {
  // Next.js configuration
};

export default config;

For typed environment variables, create a validation file:

// src/env.ts
const requiredEnvVars = ["DATABASE_URL", "API_KEY"] as const;

type EnvVars = Record<(typeof requiredEnvVars)[number], string>;

function validateEnv(): EnvVars {
  const env: Partial<EnvVars> = {};
  for (const key of requiredEnvVars) {
    const value = process.env[key];
    if (!value) {
      throw new Error(`Missing environment variable: ${key}`);
    }
    env[key] = value;
  }
  return env as EnvVars;
}

export const env = validateEnv();

Variables prefixed with NEXT_PUBLIC_ are available in the browser. All other variables are server-only.

What’s Next?

In the next tutorial, we will learn about testing TypeScript with Vitest — how to write typed tests, mock functions, and test your types themselves.