TL;DR
Apply TypeScript to Next.js patterns including typed page props, API responses, server actions, and generic data fetching.
Key concepts
- Next.js TypeScript
- typed page props Next.js
- TypeScript server actions
- Next.js API types
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. Since Next.js 15 both props are Promises — you await them inside an async page component. (Next.js 15 still allowed synchronous access as a temporary migration path; Next.js 16 removed it entirely.)
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]. Since Next.js 15, params and
// searchParams are Promises, so the page component is async and awaits them.
interface PageProps {
params: Promise<{
slug: string;
}>;
searchParams: Promise<{
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
async function BlogPostPage(props: PageProps): Promise<string> {
const { slug } = await props.params;
const query = await props.searchParams;
const page = parsePage(query.page);
const sort = query.sort ?? "asc";
return `Rendering post "${slug}" — page ${page}, sorted ${sort}`;
}
BlogPostPage({
params: Promise.resolve({ slug: "intro-to-typescript" }),
searchParams: Promise.resolve({ page: "2", sort: "desc" }),
}).then((result) => 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. This fence is no-run — the sandbox has no network, so a real fetch cannot execute here — but it is the exact shape you'd use in a Next.js server component:
// Generic fetch wrapper — mirrors what you'd use in a Next.js server component.
// no-run: the sandbox has no internet, so the fetch call cannot execute.
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 };
}
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.
The transformation the fetch feeds — mapping a full Post to a PostSummary — is pure and runs fine without a network. This runnable fence exercises exactly that shaping logic:
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
interface PostSummary {
id: number;
title: string;
}
// The pure shaping step that would run on whatever apiFetch<Post> returned
function toSummary(post: Post): PostSummary {
return { id: post.id, title: post.title };
}
const mockPost: Post = {
id: 1,
title: "Hello TypeScript",
body: "TypeScript makes fetch calls safer.",
userId: 42,
};
console.log(toSummary(mockPost));
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 — ActionResult is the same Result family from 09-error-handling, wearing the success/data field names that 23-type-safe-apis establishes as the API convention:
// 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.
Predict
ActionResult<T> is a discriminated union: only the success member has data. This handle reads result.data WITHOUT first checking result.success, then is called with a failure result. The code is checked with tsc --strict AND run by the tsx runner. Predict BOTH: what does tsc report, and what does the runner do?
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string };
function handle(result: ActionResult<{ name: string }>): string {
return `Hello ${result.data.name}`;
}
console.log(handle({ success: false, error: "bad input" }));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.
Recall
Without scrolling up: createRepository<T extends Entity> uses Repository<T> with methods like findById: (id: number) => T | undefined. That T extends Entity constraint is the generic bound you first met in 07-generics. What does constraining T to Entity buy the repository, and why can't you drop the constraint and use a plain <T>?
Try It Yourself
One last build — the final task of the track. It is the pure core of a Next.js Server Action: validate untrusted form data and return a typed ActionResult union (the pattern from Modeling Server Action Data above), then consume that union by narrowing on success. No framework, no network — just the typed logic that a server action wraps. Each function is stubbed with only its signature. Run it as-is to see the first failure, implement that, then work down until it prints All checks passed.
Build
Finish the build. Three functions are stubbed with only their signatures; the checks below them fail until each returns the right value. Spec (from Modeling Server Action Data above): parseProfile(form) returns an ActionResult of Profile — reject a name shorter than 2 trimmed characters with a failure result carrying error 'Name must be at least 2 characters.', reject an email lacking an at-sign with a failure result carrying error 'A valid email is required.' (check the name first), otherwise return a success result whose data holds the trimmed name and lowercased email. summarize(result) narrows on result.success: a success renders 'Saved name email' with the email wrapped in angle brackets, a failure renders 'Error: ' plus the error. successCount(results) counts how many results succeeded. Checks run top to bottom, so the first failure is TODO 1 — implement it first, then work down.
import assert from "node:assert";
// The pure core of a Next.js Server Action: validate untrusted form data into a
// typed result union, then consume it by narrowing. Domain locked — do not edit.
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string };
interface Profile {
name: string;
email: string;
}
// TODO 1
function parseProfile(form: Record<string, string>): ActionResult<Profile> {
return { success: false, error: "not implemented" };
}
// TODO 2
function summarize(result: ActionResult<Profile>): string {
return "";
}
// TODO 3
function successCount(results: ActionResult<Profile>[]): number {
return 0;
}
// --- Build checks: these must all pass. Do not edit below this line. ---
const ok = parseProfile({ name: "Ada", email: "ADA@Example.com" });
assert.deepStrictEqual(ok, { success: true, data: { name: "Ada", email: "ada@example.com" } }, "TODO 1: a valid form yields success with a lowercased email");
const badName = parseProfile({ name: "A", email: "ada@example.com" });
assert.deepStrictEqual(badName, { success: false, error: "Name must be at least 2 characters." }, "TODO 1: a short name yields the name error");
const badEmail = parseProfile({ name: "Ada", email: "nope" });
assert.deepStrictEqual(badEmail, { success: false, error: "A valid email is required." }, "TODO 1: a bad email yields the email error");
assert.strictEqual(summarize(ok), "Saved Ada <ada@example.com>", "TODO 2: success summary uses the profile");
assert.strictEqual(summarize(badName), "Error: Name must be at least 2 characters.", "TODO 2: failure summary uses the error");
assert.strictEqual(successCount([ok, badName, badEmail]), 1, "TODO 3: successCount counts only the successes");
console.log("All checks passed.");
console.log(summarize(ok));
console.log("successes:", successCount([ok, badName, badEmail]));Expected output: All checks passed.
Saved Ada <ada@example.com>
successes: 1
Once it passes, try two variations and predict each before running:
- Loosen the name check to
< 1. InparseProfile, changename.trim().length < 2toname.trim().length < 1. Predict which check fails first before running. Now a one-character name like"A"passes validation, soparseProfile({ name: "A", ... })returns a success result instead of the name error, and TODO 1's second check fails first —deepStrictEqualreports a{ success: true, data: ... }where{ success: false, error: "Name must be at least 2 characters." }was expected. An off-by-one on the validation boundary silently lets bad input through — exactly what a server action must not do. - Drop the angle brackets in
summarize. In the success branch, remove the angle brackets that wrap the email so it renders bare. Predict which check fails first before running. The failure summary still matches, but the success line loses the brackets around the email, so it no longer equals the expectedSaved Adafollowed by the bracketed email, and TODO 2's first check fails first with anAssertionErroron the mismatched strings. A byte-exact expected output catches the missing delimiters immediately.
Arrange the code
Reassemble the pure core of a server action: it takes raw form fields, normalizes them, wraps them in a success result, formats a saved-line, and logs it. The lines are shuffled; each const consumes the binding above it, so only one order runs top-to-bottom and prints Saved Ada <ada@x.co>.
const line = `Saved ${result.data.name} <${result.data.email}>`;console.log(line);const result = { success: true, data: clean };const clean = { name: raw.name.trim(), email: raw.email.toLowerCase() };const raw = { name: "Ada", email: "ADA@X.CO" };
Key Takeaways
- Type your page props explicitly — define an interface for
paramsandsearchParams(bothPromises since Next.js 15, soawaitthem in anasyncpage) instead of relying on inference; narrow union types like"asc" | "desc"prevent invalid values before they reach your logic - Wrap
fetchin a generic helper —apiFetch<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
extends—T extends Entitylets 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
NextRequestandNextResponsefrom"next/server", andMetadatafrom"next". 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. (PagePropsisn't imported at all — Next.js generates it per route under.next/types, soparamsandsearchParamsare typed for you automatically.)
Course Complete!
Congratulations — this is the final lesson, and you've completed the Learning TypeScript curriculum. You started with the type system's basics — variables, functions, control flow, data structures — then built up the core: interfaces, enums, narrowing, classes, generics, utility types, and error handling. From there you moved into engineering the language in practice — async, modules, tsconfig, Zod validation, type-safe APIs, and debugging — and pulled it all together in the type-safe learning-tracker capstone. This extension tail took you further still: mapped types, the advanced-pattern toolkit, testing, standard decorators, React, and now Next.js. You have a solid, practical command of TypeScript end to end — and, just as important, a feel for the one thing that makes TypeScript its own discipline: thinking in types, where the compiler catches a whole class of bugs before the code ever runs.
What to do next:
- Build a project using TypeScript — a type-safe API or a React app is a great start
- Explore the TypeScript Playground to experiment further
- Check out the TypeScript Handbook for reference
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.