Skip to editor content
learningtypescript.orglesson 22 of 25

Next.js and TypeScript

Next.js is built with TypeScript in mind. The framework ships its own type definitions, and most patterns you encounter — page props, API route handlers, server actions, and data fetching — have natural TypeScript shapes you can leverage to catch bugs at compile time rather than in production.

This lesson won't teach you to run a Next.js server in the browser. Instead, it focuses on the TypeScript patterns that power Next.js applications: how to type component props, model API responses, build generic data fetchers, and handle form data safely. These patterns translate directly to real Next.js projects.

Typing Page Props

In Next.js App Router, a page component is just a function that receives params and searchParams as props. The challenge is knowing exactly what shape those props have, especially when routes are dynamic.

The standard approach is to define an explicit interface for your page's props before writing the component:

// Simulating Next.js page props for /blog/[slug]
interface PageProps {
  params: {
    slug: string;
  };
  searchParams: {
    page?: string;
    sort?: "asc" | "desc";
  };
}

function parsePage(raw: string | undefined): number {
  const n = parseInt(raw ?? "1", 10);
  return isNaN(n) || n < 1 ? 1 : n;
}

// Simulate the page component receiving typed props
function BlogPostPage(props: PageProps): string {
  const { slug } = props.params;
  const page = parsePage(props.searchParams.page);
  const sort = props.searchParams.sort ?? "asc";

  return `Rendering post "${slug}" — page ${page}, sorted ${sort}`;
}

const result = BlogPostPage({
  params: { slug: "intro-to-typescript" },
  searchParams: { page: "2", sort: "desc" },
});

console.log(result);

Notice that sort is typed as "asc" | "desc" | undefined — not just string. This means TypeScript will reject any value outside that union when you construct test data, and it forces you to handle the undefined case explicitly. Narrow types like this surface bugs that a string annotation would silently allow.

Typing API Responses

Most Next.js applications fetch data from APIs. The fetch is untyped by default — you get back any. The fix is a small generic wrapper that combines the request with an expected response shape:

// Generic fetch wrapper — mirrors what you'd use in a Next.js server component
async function apiFetch<T>(url: string): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status} ${response.statusText}`);
  }
  return response.json() as Promise<T>;
}

// Define the shape of the data you expect
interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

interface PostSummary {
  id: number;
  title: string;
}

// The return type is inferred from the generic argument
async function loadPost(id: number): Promise<PostSummary> {
  const post = await apiFetch<Post>(`https://jsonplaceholder.typicode.com/posts/${id}`);
  return { id: post.id, title: post.title };
}

// Demonstrate that TypeScript catches shape mismatches
const mockPost: Post = {
  id: 1,
  title: "Hello TypeScript",
  body: "TypeScript makes fetch calls safer.",
  userId: 42,
};

// Simulate the transformation without a network call
const summary: PostSummary = { id: mockPost.id, title: mockPost.title };
console.log(summary);

The apiFetch<T> function pins the return type to whatever interface you pass as T. If the actual API returns a differently shaped object, TypeScript won't catch that at runtime — but it will catch any code that treats the response incorrectly downstream.

Modeling Server Action Data

Next.js Server Actions receive form data and return a result. A common pattern is to define a typed result union that represents success or failure, avoiding thrown exceptions in favor of explicit return values:

// A discriminated union for action results
type ActionResult<T> =
  | { success: true; data: T }
  | { success: false; error: string };

interface UserProfile {
  id: string;
  name: string;
  email: string;
}

// Simulate parsing form data — mirrors what a real server action does
function parseUserForm(formData: Record<string, string>): ActionResult<UserProfile> {
  const { name, email } = formData;

  if (!name || name.trim().length < 2) {
    return { success: false, error: "Name must be at least 2 characters." };
  }

  if (!email || !email.includes("@")) {
    return { success: false, error: "A valid email address is required." };
  }

  return {
    success: true,
    data: {
      id: crypto.randomUUID(),
      name: name.trim(),
      email: email.toLowerCase(),
    },
  };
}

// TypeScript narrows the union based on the `success` flag
function handleResult(result: ActionResult<UserProfile>): void {
  if (!result.success) {
    console.error("Validation failed:", result.error);
    return;
  }
  console.log("User saved:", result.data.name, "<" + result.data.email + ">");
}

handleResult(parseUserForm({ name: "Ada Lovelace", email: "ada@example.com" }));
handleResult(parseUserForm({ name: "X", email: "not-an-email" }));

This pattern makes error handling explicit. After the if (!result.success) check, TypeScript knows result.data exists and has the UserProfile shape — no optional chaining required.

Building a Typed Data Layer

Larger Next.js applications often have a data layer that separates fetching from rendering. TypeScript generics make it easy to build reusable utilities that stay type-safe regardless of the entity they operate on:

// A minimal typed repository pattern
interface Entity {
  id: number;
}

interface Repository<T extends Entity> {
  findById: (id: number) => T | undefined;
  findAll: () => T[];
  save: (item: T) => T;
}

function createRepository<T extends Entity>(initial: T[]): Repository<T> {
  const store = new Map<number, T>(initial.map((item) => [item.id, item]));

  return {
    findById: (id) => store.get(id),
    findAll: () => Array.from(store.values()),
    save: (item) => {
      store.set(item.id, item);
      return item;
    },
  };
}

interface Article {
  id: number;
  title: string;
  published: boolean;
}

const articles = createRepository<Article>([
  { id: 1, title: "Getting Started with Next.js", published: true },
  { id: 2, title: "TypeScript Best Practices", published: false },
]);

console.log(articles.findById(1));
console.log(articles.findAll().filter((a) => a.published).map((a) => a.title));

articles.save({ id: 3, title: "Server Actions Deep Dive", published: true });
console.log(articles.findAll().length); // 3

The constraint T extends Entity tells TypeScript that any type used with createRepository must have an id: number field. Everything else — the specific fields of Article, Post, or User — flows through automatically.

Try It Yourself

Build a typed search function that filters a list of products by an optional query string and an optional maximum price. The function should return a SearchResult type with the matched items and a count. If no filters are provided, return all products:

interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

interface SearchResult {
  items: Product[];
  count: number;
  query?: string;
  maxPrice?: number;
}

const catalog: Product[] = [
  { id: 1, name: "Wireless Keyboard", price: 79, category: "electronics" },
  { id: 2, name: "Desk Lamp", price: 45, category: "home" },
  { id: 3, name: "USB-C Hub", price: 39, category: "electronics" },
  { id: 4, name: "Notebook", price: 12, category: "stationery" },
  { id: 5, name: "Monitor Stand", price: 89, category: "home" },
];

function searchProducts(
  products: Product[],
  query?: string,
  maxPrice?: number
): SearchResult {
  let results = products;

  if (query) {
    const lower = query.toLowerCase();
    results = results.filter((p) => p.name.toLowerCase().includes(lower));
  }

  if (maxPrice !== undefined) {
    results = results.filter((p) => p.price <= maxPrice);
  }

  return { items: results, count: results.length, query, maxPrice };
}

const byQuery = searchProducts(catalog, "usb");
console.log("USB search:", byQuery.items.map((p) => p.name));

const byPrice = searchProducts(catalog, undefined, 50);
console.log("Under $50:", byPrice.items.map((p) => `${p.name} ($${p.price})`));

const combined = searchProducts(catalog, "desk", 60);
console.log("'Desk' under $60:", combined.count, "results");

Key Takeaways

  • Type your page props explicitly — define an interface for params and searchParams instead of relying on inference; narrow union types like "asc" | "desc" prevent invalid values before they reach your logic
  • Wrap fetch in a generic helperapiFetch<T> turns an untyped response into a fully typed value downstream, catching shape mismatches at the point of use
  • Use discriminated unions for action results{ success: true; data: T } | { success: false; error: string } makes error handling explicit and allows TypeScript to narrow safely after a check
  • Constrain generics with extendsT extends Entity lets you build reusable utilities that work with any matching shape without losing type information
  • Keep the data layer separate from rendering — typed repositories and service functions are easier to test, reuse across routes, and swap out without touching component code

Pro Tip: Next.js exports ready-made types like NextRequest, NextResponse, Metadata, and PageProps from "next" and "next/server". Import them instead of redefining equivalent shapes — they stay in sync with the framework across version upgrades and often carry additional constraints you'd otherwise miss.

Next Steps

You've typed Next.js pages and data layers, but the API boundary itself deserves special attention. Next, you'll learn how to build fully type-safe APIs — typed response shapes, generic fetch wrappers, discriminated result types, and runtime type guards that validate untrusted data.

Next lesson

Type-Safe APIs

Build type-safe APIs in TypeScript with generic fetch wrappers, discriminated union responses, and runtime type guards.

25 min