Type-Safe Next.js App Router: Server Actions, Params, and Error Handling That Actually Scale
App Router introduced async params, server actions, and new patterns for forms and mutations — and TypeScript support for all of it has sharp edges. Here are the patterns I use to keep things type-safe without fighting the framework.
App Router changed how data flows through a Next.js app. Server actions replaced API routes for most mutations. Params became async. searchParams arrived untyped. And TypeScript support for all of it has real sharp edges that aren't obvious until you hit them.
These are the patterns I use across production apps to keep server actions, params, and error handling type-safe without fighting the framework.
Type Your Server Action Returns as a Discriminated Union#
Server actions that return void or throw on failure make form handling awkward. The pattern I use everywhere is a discriminated union:
type ActionResult<T = void> =
| { success: true; data: T }
| { success: false; error: string };
Every server action returns this type, never throws:
"use server";
export async function createItem(
input: unknown
): Promise<ActionResult<{ id: string }>> {
const parsed = CreateItemSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: parsed.error.errors[0].message };
}
try {
const item = await db.items.create(parsed.data);
return { success: true, data: { id: item.id } };
} catch {
return { success: false, error: "Failed to create item." };
}
}
On the client, the result is always safe to destructure — no try/catch, no ambiguous state:
const result = await createItem(formData);
if (!result.success) {
setError(result.error);
return;
}
router.push(`/items/${result.data.id}`);
The discriminated union means TypeScript narrows the type for you. Inside the !result.success branch, result.error exists. Inside the success branch, result.data exists. No casting needed.
Validate Server Action Input with Zod, Not TypeScript Types#
TypeScript types are erased at runtime. A server action that accepts name: string in its signature doesn't actually enforce that at the boundary — name could be anything.
// Unsafe: TypeScript types don't protect you at runtime
export async function createItem({ name }: { name: string }) {
// name could be anything here
}
// Safe: Zod validates at runtime
const CreateItemSchema = z.object({
name: z.string().min(1).max(100),
});
export async function createItem(input: unknown): Promise<ActionResult> {
const parsed = CreateItemSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Invalid input." };
// parsed.data is now fully typed
}
Take input: unknown in every server action. Parse it with Zod before touching the data. This also gives you a single schema definition that serves as both the runtime validator and the inferred TypeScript type:
type CreateItemInput = z.infer<typeof CreateItemSchema>;
Share the schema between client and server — it lives in a lib/schemas file that both can import. The client uses it for form validation, the server uses it for runtime safety.
Async Params Are Not Optional#
In Next.js 15+, params and searchParams are Promises. This means any page or layout that destructures them synchronously will either type-error or fail silently.
// Broken: params is a Promise
export default function Page({ params }: { params: { id: string } }) {
// params.id is undefined at render time
}
// Correct
type Props = { params: Promise<{ id: string }> };
export default async function Page({ params }: Props) {
const { id } = await params;
// id is a string
}
For generateMetadata, the same pattern applies:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const item = await getItem(id);
return { title: item.name };
}
If you're migrating a Pages Router codebase, this is the most common source of silent bugs. Everything still renders — the params just come back as the raw Promise object instead of the resolved value.
SearchParams Are Unknown — Parse Them#
searchParams in App Router are typed as { [key: string]: string | string[] | undefined }. That's technically accurate, but it means every access requires a check before you can use the value safely.
type Props = { searchParams: Promise<{ [key: string]: string | string[] | undefined }> };
export default async function Page({ searchParams }: Props) {
const raw = await searchParams;
// raw.page is string | string[] | undefined
// You need to coerce it before using it
}
The pattern I use: a small helper that coerces a specific searchParam to its expected type:
function getStringParam(
params: { [key: string]: string | string[] | undefined },
key: string
): string | undefined {
const val = params[key];
return typeof val === "string" ? val : Array.isArray(val) ? val[0] : undefined;
}
function getNumberParam(
params: { [key: string]: string | string[] | undefined },
key: string,
fallback: number
): number {
const val = getStringParam(params, key);
const n = Number(val);
return Number.isFinite(n) ? n : fallback;
}
For complex searchParams (filters, sorts, pagination), parse them with Zod the same way as server action inputs:
const SearchSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
q: z.string().optional(),
});
const search = SearchSchema.parse(await searchParams);
// search.page is a number, search.q is string | undefined
z.coerce.number() handles the string→number conversion automatically. Invalid values fall back to the default.
Error Handling in Catch Blocks#
TypeScript 4.0+ types caught errors as unknown, not Error. Code that does catch (e) { return e.message } doesn't compile without a cast. The right pattern:
function toErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return "An unexpected error occurred.";
}
try {
await riskyOperation();
} catch (error) {
return { success: false, error: toErrorMessage(error) };
}
This replaces every catch (e: any) cast and every (e as Error).message in server actions and API routes. One utility, used everywhere.
useActionState with Correct Typing#
useActionState (formerly useFormState) takes an action function and an initial state. The types are tightly coupled — the action's return type has to match the state type.
"use client";
import { useActionState } from "react";
import { createItem } from "./actions";
type State = ActionResult<{ id: string }> | null;
const initialState: State = null;
export function CreateItemForm() {
const [state, action, isPending] = useActionState(createItem, initialState);
return (
<form action={action}>
{state && !state.success && (
<p className="error">{state.error}</p>
)}
<input name="name" />
<button disabled={isPending}>Create</button>
</form>
);
}
The action signature has to accept the previous state as its first argument:
export async function createItem(
_prevState: State,
formData: FormData
): Promise<State> {
const input = Object.fromEntries(formData);
// ...
}
The _prevState parameter is required by useActionState — you can ignore it in most actions, but the type has to be there for the binding to work.
How Do You Share Types Between Server and Client Components?#
Create a lib/types directory and export plain TypeScript types (no "use server" or "use client"). Both server and client components import from it freely.
The rule: never import server-only modules (Supabase server client, server-only secrets) into client components. TypeScript won't catch this by default — use the server-only package to get a build error when you try:
// lib/supabase/server.ts
import "server-only";
import { createServerClient } from "@supabase/ssr";
// ...
Now any attempt to import lib/supabase/server.ts from a client component throws a build error instead of silently sending secrets to the browser.
The Pattern That Ties It Together#
The end-to-end shape I use on every form in App Router:
lib/schemas/item.ts— Zod schema + inferred type, no server/client boundaryapp/actions/item.ts—"use server", acceptsunknown, returnsActionResult<T>app/components/ItemForm.tsx—"use client",useActionState, showsstate.errorinline
No API routes. No try/catch on the client. No any casts. TypeScript narrows the result automatically at every step.
Freelance
Need help with this?
I help startups and businesses ship web projects: migrations, new products, and performance fixes.
Get in touch →