Back to Deep Dives
TypeScript 5Next.js 16Server Actions

TypeScript 5 with Next.js 16: Type-Safe Server Components, Actions, and Full-Stack Patterns

04/202615 min read
Share
TypeScript 5 with Next.js 16: Type-Safe Server Components, Actions, and Full-Stack Patterns

Why Type Safety Matters More Than Ever

Next.js 16's App Router fundamentally changed where and how your code runs. Server Components, Server Actions, and async page props introduce new boundaries—and new places where types can silently break down.

TypeScript 5 gives us powerful tools to close those gaps. But only if we use them correctly.

This article covers 8 practical patterns for achieving end-to-end type safety in a real Next.js 16 application—from page props to Server Actions to your data layer.

#1 Enable Strict Mode — Non-Negotiable

Most TypeScript projects leave significant type safety on the table by not enabling strict mode. In a Next.js app, this is especially costly.

The Problem

Without strict mode, TypeScript silently allows:

  • Implicit any types
  • Unchecked null/undefined access
  • Loose function parameter checking

Solution

Enable strict in your tsconfig.json:

tsconfig.json
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "moduleResolution": "bundler", "paths": { "@/*": ["./*"] } } }

Pro tip: noUncheckedIndexedAccess forces you to handle the case where an array index or object key returns undefined—a silent source of runtime crashes.

Result
  • Catches entire classes of bugs at compile time
  • Forces intentional handling of null/undefined
  • Makes refactoring safer

#2 Type Your Page Props Correctly

In Next.js 16, page params and searchParams are Promises. Ignoring this breaks the type system and causes runtime errors.

Common Mistake

app/blog/[slug]/page.tsx
// ❌ Wrong — params is a Promise in Next.js 16 interface Props { params: { slug: string }; } export default function Page({ params }: Props) { console.log(params.slug); // 💥 undefined at runtime }

Correct Pattern

app/blog/[slug]/page.tsx
// ✅ Correct — await the params Promise interface Props { params: Promise<{ slug: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } export default async function Page({ params, searchParams }: Props) { const { slug } = await params; const { page } = await searchParams; const data = await getPost(slug); return <BlogPost data={data} page={Number(page ?? 1)} />; }
Result
  • Matches the actual Next.js 16 runtime behavior
  • TypeScript error if you forget to await
  • Plays well with generateMetadata()

#3 Type-Safe Server Actions with Zod

Server Actions are TypeScript functions—but their inputs come from the browser. You must validate and type them at the boundary.

The Problem

A Server Action receives FormData or raw arguments from the client. TypeScript cannot verify what the browser actually sends.

app/actions.ts
'use server'; // ❌ Unsafe — trusting unvalidated input export async function createUser(data: { name: string; email: string }) { await db.user.create({ data }); // data could be anything at runtime }

Solution — Zod + Typed Return

app/actions.ts
'use server'; import { z } from 'zod'; const CreateUserSchema = z.object({ name: z.string().min(2).max(100), email: z.string().email(), }); type ActionResult<T> = | { success: true; data: T } | { success: false; error: string }; export async function createUser( input: unknown ): Promise<ActionResult<{ id: string }>> { const parsed = CreateUserSchema.safeParse(input); if (!parsed.success) { return { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' }; } const user = await db.user.create({ data: parsed.data }); return { success: true, data: { id: user.id } }; }

Usage in a Client Component

components/UserForm.tsx
'use client'; import { createUser } from '@/app/actions'; export function UserForm() { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); const form = new FormData(e.currentTarget); const result = await createUser({ name: form.get('name'), email: form.get('email'), }); if (!result.success) { console.error(result.error); // fully typed } else { console.log('Created:', result.data.id); // fully typed } } return <form onSubmit={handleSubmit}>...</form>; }
Result
  • Runtime validation at the server boundary
  • Typed success/error response shapes
  • No unvalidated input reaches your database

#4 End-to-End Type-Safe Data Fetching

Data flows from your database through Server Components to Client Components. Each hop is a potential type gap.

The Pattern — Typed Fetch Utilities

Create a central typed fetch layer. This ensures every data boundary is explicit and safe:

lib/api.ts
import { z } from 'zod'; export async function typedFetch<T>( url: string, schema: z.ZodType<T>, options?: RequestInit ): Promise<T> { const res = await fetch(url, options); if (!res.ok) { throw new Error(`API error: ${res.status}`); } const json = await res.json(); return schema.parse(json); // throws if shape is wrong }
lib/posts.ts
import { z } from 'zod'; import { typedFetch } from './api'; export const PostSchema = z.object({ id: z.string(), title: z.string(), body: z.string(), publishedAt: z.string().datetime(), }); export type Post = z.infer<typeof PostSchema>; export async function getPost(id: string): Promise<Post> { return typedFetch(`/api/posts/${id}`, PostSchema, { next: { revalidate: 3600 }, }); }
Result
  • Schema mismatch caught at runtime
  • Types derived from the single source of truth (Zod schema)
  • Works seamlessly with Next.js built-in fetch caching

#5 TypeScript 5 Const Type Parameters

TypeScript 5 introduced const type parameters—perfect for building type-safe utilities that preserve literal types.

The Problem Without const

// ❌ Without const — loses literal type information function createRoute<T extends string>(path: T) { return `/app/${path}`; } const route = createRoute('dashboard'); // ^^ type is string, not '/app/dashboard'

Solution — const Type Parameter

// ✅ With const — preserves literal types function createRoute<const T extends string>(path: T) { return `/app/${path}` as `/app/${T}`; } const route = createRoute('dashboard'); // ^^ type is '/app/dashboard' ✓ // Real-world use: typed navigation helper const routes = { home: createRoute(''), dashboard: createRoute('dashboard'), settings: createRoute('settings'), } as const; type AppRoute = typeof routes[keyof typeof routes]; // '/app/' | '/app/dashboard' | '/app/settings'
Result
  • Literal types preserved through function boundaries
  • Enables building type-safe route registries
  • No runtime cost — pure TypeScript

#6 Typed API Route Handlers

Next.js 16 Route Handlers (app/api) are not type-safe by default. Request params and response shapes need explicit typing.

Naive Approach

app/api/posts/[id]/route.ts
// ❌ No type safety on params or response export async function GET(request: Request, { params }: any) { const post = await getPost(params.id); return Response.json(post); }

Typed Route Handler

app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { PostSchema, getPost } from '@/lib/posts'; interface RouteContext { params: Promise<{ id: string }>; } export async function GET( _req: NextRequest, { params }: RouteContext ): Promise<NextResponse<z.infer<typeof PostSchema> | { error: string }>> { const { id } = await params; const post = await getPost(id); if (!post) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } return NextResponse.json(post); }
Result
  • Return type enforced by TypeScript
  • Params correctly typed as a Promise
  • Response shape shared with client consumers

#7 Share Types Between Server and Client

One of the most common causes of type drift is defining the same shape in multiple places. Centralize your types.

Anti-Pattern — Duplicated Types

  • types/api.ts defines UserResponse
  • components/UserCard.tsx re-defines its own User interface
  • app/actions.ts defines yet another UserPayload
  • They drift apart over time and cause silent bugs

Solution — Single Source of Truth

lib/types/user.ts
import { z } from 'zod'; // Define once — derive everywhere export const UserSchema = z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email(), role: z.enum(['admin', 'editor', 'viewer']), createdAt: z.string().datetime(), }); export type User = z.infer<typeof UserSchema>; // Derived types for specific use cases export type PublicUser = Pick<User, 'id' | 'name' | 'role'>; export type UserRole = User['role'];
components/UserCard.tsx
'use client'; // ✅ Import, never re-define import type { PublicUser } from '@/lib/types/user'; interface UserCardProps { user: PublicUser; } export function UserCard({ user }: UserCardProps) { return ( <div> <h2>{user.name}</h2> <span>{user.role}</span> </div> ); }
Result
  • One place to update when the shape changes
  • Zod schema doubles as runtime validation
  • Derived types keep components lean

#8 Type-Safe Environment Variables

process.env values are always string | undefined at the TypeScript level. Accessing them unsafely is a silent runtime failure waiting to happen.

The Problem

lib/sendEmail.ts
// ❌ No guarantee RESEND_API_KEY exists const client = new Resend(process.env.RESEND_API_KEY); // ^^ string | undefined

Solution — Validated Env Module

lib/env.ts
import { z } from 'zod'; const envSchema = z.object({ DATABASE_URL: z.string().url(), RESEND_API_KEY: z.string().min(1), BASE_URL: z.string().url(), NODE_ENV: z.enum(['development', 'test', 'production']), }); // Throws at startup if any variable is missing or invalid export const env = envSchema.parse(process.env);
lib/sendEmail.ts
import { env } from '@/lib/env'; // ✅ env.RESEND_API_KEY is guaranteed to be a string const client = new Resend(env.RESEND_API_KEY);

Pro tip: Import env at the top of your app entry point so misconfigured environments fail loudly at startup, not silently in production.

Result
  • Missing env vars throw at startup, not at runtime
  • All env values are correctly typed as string (not string | undefined)
  • Works great with .env.local validation in CI

Key Takeaways

  • 1Enable strict mode + noUncheckedIndexedAccess—they catch entire bug classes for free
  • 2Next.js 16 page params and searchParams are Promises—always await them
  • 3Validate Server Action inputs with Zod at the server boundary, never trust the client
  • 4Use a typed fetch utility to catch API shape mismatches at runtime
  • 5TypeScript 5 const type parameters preserve literal types through function calls
  • 6Type your Route Handlers explicitly—request params are Promises in Next.js 16
  • 7Define types once with Zod; derive all variants from the single source of truth
  • 8Validate environment variables at startup with a Zod schema—never access process.env directly

Final Thoughts

TypeScript's value isn't just catching typos—it's making architectural boundaries explicit and enforceable. In a Next.js 16 app, those boundaries are everywhere: server vs. client, validated vs. unvalidated input, known vs. unknown environment.

The 8 patterns in this article give you a systematic way to harden each of those boundaries. Applied together, they eliminate an entire category of runtime errors—not by being more careful, but by making the wrong thing unrepresentable in the type system.

TL;DR
  • strict: true in tsconfig.json is the foundation—everything else builds on it
  • Await params and searchParams in Next.js 16 page components
  • Zod validates Server Action inputs and API responses at runtime
  • Centralize types; derive client types from server schemas
  • Validate env vars at startup so misconfiguration fails fast
  • TypeScript 5 const type parameters let you build fully typed route and config registries