Next.js with TypeScript: App Router Patterns, Server Actions, and Type Safety
nextjstypescriptapp routerserver actionstype safetyframeworks

Next.js with TypeScript: App Router Patterns, Server Actions, and Type Safety

TTypeScript Website Editorial
2026-06-10
10 min read

A practical Next.js with TypeScript guide for App Router structure, Server Actions, validation, and durable type-safe patterns.

Next.js with TypeScript is at its best when the type system reflects how the App Router actually works: server-first rendering, typed route inputs, explicit data boundaries, and mutations handled close to the UI. This guide gives you a reusable structure for building and maintaining a modern Next.js codebase with TypeScript, with practical patterns for App Router files, Server Actions, shared domain types, validation, and component boundaries. The goal is not to freeze one version of framework advice, but to provide a set of patterns you can revisit as Next.js features and team conventions evolve.

Overview

This article provides a durable template for Next.js with TypeScript projects using the App Router. Rather than focusing on a single demo app, it shows how to organize types, server logic, client components, and validation so your code remains understandable during upgrades.

The key idea is simple: use TypeScript to describe boundaries, not just objects. In a Next.js app, the most important boundaries are:

  • the boundary between server components and client components
  • the boundary between validated input and untrusted input
  • the boundary between route parameters and domain identifiers
  • the boundary between data fetching and UI rendering
  • the boundary between form submission and mutation logic

If those boundaries are clear, the rest of the application becomes easier to reason about. If they are vague, you usually end up with brittle inferred types, repeated casting, and components that know too much about transport details.

For most teams, a good TypeScript strategy in Next.js comes down to a few steady rules:

  1. Keep domain types separate from framework types. A product, user, invoice, or feature flag type should not depend on a route file.
  2. Validate runtime input at the edge of the system. TypeScript cannot guarantee that route params, form fields, cookies, or external API responses are valid at runtime.
  3. Let server code own sensitive logic. Server Actions, route handlers, and server components should be the default home for mutations and privileged data access.
  4. Pass narrow props to client components. Treat client components as interactive leaves whenever possible.
  5. Use explicit return types on exported server utilities. This makes refactors safer and error states easier to model.

If you are also refining React component APIs, see React with TypeScript: Best Practices for Props, Hooks, and Component APIs. For teams tightening compiler settings, tsconfig.json Best Practices is a useful companion.

Template structure

This section gives you a practical project shape you can adapt. The exact folders matter less than the separation of concerns.

src/
  app/
    products/
      [productId]/
        page.tsx
        loading.tsx
        error.tsx
        actions.ts
    api/
      products/
        route.ts
  components/
    products/
      ProductDetails.tsx
      AddToCartButton.tsx
  lib/
    db/
      products.ts
    validation/
      product.ts
  types/
    product.ts
    action-state.ts

Here is what each layer should own.

1. Route files describe framework entry points

Files in app/ should focus on route concerns: params, search params, metadata, loading states, and composition. Try not to bury domain logic directly in page files.

type ProductPageProps = {
  params: { productId: string };
  searchParams?: { ref?: string };
};

export default async function ProductPage({ params }: ProductPageProps) {
  const product = await getProductById(params.productId);

  return <ProductDetails product={product} />;
}

Even if the framework can infer some of this, an explicit props type makes route expectations easier to scan.

2. Domain types live outside route files

Create reusable interfaces or type aliases in a neutral place.

export interface Product {
  id: string;
  slug: string;
  name: string;
  priceCents: number;
  inStock: boolean;
}

This keeps UI code, data code, and route code aligned around one domain model. It also prevents route files from becoming the accidental source of truth.

3. Server Actions should use typed input and typed results

A common mistake with server actions typescript patterns is returning loosely shaped objects or mixing thrown exceptions with ad hoc status messages. A more stable pattern is a typed result object.

export type ActionState<TData = void, TError = string> = {
  ok: boolean;
  data?: TData;
  error?: TError;
};
'use server';

import { productInputSchema } from '@/lib/validation/product';
import type { ActionState } from '@/types/action-state';
import type { Product } from '@/types/product';

export async function createProduct(
  _prevState: ActionState<Product>,
  formData: FormData
): Promise<ActionState<Product>> {
  const parsed = productInputSchema.safeParse({
    name: formData.get('name'),
    priceCents: Number(formData.get('priceCents')),
  });

  if (!parsed.success) {
    return { ok: false, error: 'Invalid product input' };
  }

  const product = await saveProduct(parsed.data);
  return { ok: true, data: product };
}

This approach works well because the client can render from a known shape, while the server remains responsible for validation and persistence.

4. Validation belongs at runtime boundaries

TypeScript helps before runtime; schema validation helps during runtime. Route params, request bodies, cookies, headers, and third-party API payloads should be treated as untrusted.

export const productInputSchema = z.object({
  name: z.string().min(1),
  priceCents: z.number().int().nonnegative(),
});

export type ProductInput = z.infer<typeof productInputSchema>;

This is one of the cleanest ways to connect static and runtime guarantees in a next.js type safety workflow.

5. Client components should receive narrow, serializable props

Do not pass large server-only objects into client components unless they truly need them. A button component should receive the smallest useful payload.

'use client';

type AddToCartButtonProps = {
  productId: string;
  disabled?: boolean;
};

export function AddToCartButton({ productId, disabled }: AddToCartButtonProps) {
  return <button disabled={disabled}>Add to cart</button>;
}

This makes client components easier to test and avoids accidental coupling to server data shapes.

6. Route handlers should model both success and failure

For API routes in the App Router, define request and response shapes instead of returning free-form JSON.

type ProductResponse =
  | { ok: true; product: Product }
  | { ok: false; error: string };

export async function GET(): Promise<Response> {
  const product = await getFeaturedProduct();

  const body: ProductResponse = product
    ? { ok: true, product }
    : { ok: false, error: 'Not found' };

  return Response.json(body);
}

Discriminated unions are especially useful here. If you need a refresher on narrowing patterns, the TypeScript Narrowing Guide covers them in depth.

How to customize

Use this section to adapt the template to your app rather than copying it mechanically. The right shape depends on how much data fetching, mutation, and interactivity you have.

Choose a boundary-first project structure

If your codebase is small, a route-local structure may be enough:

  • keep page-specific actions near the route
  • keep page-specific types near the feature
  • promote only stable domain types into shared folders

If your codebase is growing, separate by responsibility:

  • types/ for shared domain models
  • lib/validation/ for runtime schemas
  • lib/db/ or lib/services/ for server-side data access
  • components/ for reusable UI
  • app/ for route assembly

The important point is not folder naming. It is avoiding a situation where route files are doing validation, database access, transformation, and rendering all at once.

Model route params deliberately

App Router code often starts with params as plain strings. That is acceptable, but you can tighten the flow by transforming those strings into domain-specific identifiers as early as possible.

type ProductId = string & { readonly brand: unique symbol };

function toProductId(value: string): ProductId {
  return value as ProductId;
}

Branded types are optional, but they can help in larger systems where many IDs share the same primitive base type. Use them carefully; if they create too much friction, stick to plain strings plus validation.

Keep async server helpers explicit

In Next.js, data often flows through async functions, which makes implicit inference harder to inspect during refactors. Add explicit return types to exported helpers.

export async function getProductById(id: string): Promise<Product | null> {
  // implementation
}

This prevents accidental broadening and makes page-level null handling much clearer.

Use unions for UI states instead of many booleans

Complex pages often drift into prop shapes like:

{ loading?: boolean; error?: string; product?: Product; isEmpty?: boolean }

Prefer a union that describes valid states directly.

type ProductViewState =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'empty' }
  | { status: 'ready'; product: Product };

This pattern works particularly well for client-side interactivity layered on top of server-rendered data.

Treat Server Actions as application use cases

Server Actions are most maintainable when they represent a clear operation: create invoice, update profile, archive project, or resend invite. Avoid making them tiny transport wrappers around vague helpers.

A good Server Action usually has:

  • a narrow purpose
  • runtime validation
  • a typed return shape
  • clear authorization and side effects
  • minimal framework-specific leakage into domain code

If you find yourself reusing action logic outside forms or route components, move the core operation into a plain server utility and let the action call that utility.

Prefer derived types when they reduce duplication

In larger nextjs app router typescript apps, utility types can keep things aligned.

type ProductSummary = Pick<Product, 'id' | 'name' | 'slug' | 'priceCents'>;
type ProductUpdate = Partial<Pick<Product, 'name' | 'priceCents' | 'inStock'>>;

Use this selectively. If a type becomes hard to read, write it out explicitly. Brevity is not always clarity.

For a broader review of reusable generic patterns, see Generics in TypeScript: Practical Patterns for Functions, APIs, and Components.

Examples

These examples show how the template applies to common App Router scenarios.

Example 1: Typed page with validated route input

import { notFound } from 'next/navigation';

type PageProps = {
  params: { productId: string };
};

export default async function Page({ params }: PageProps) {
  const product = await getProductById(params.productId);

  if (!product) notFound();

  return <ProductDetails product={product} />;
}

This keeps the page component focused: read the route input, fetch the resource, handle absence, render UI.

Example 2: Client form using a typed Server Action result

'use client';

const initialState: ActionState<Product> = { ok: false };

export function ProductForm() {
  const [state, formAction] = useActionState(createProduct, initialState);

  return (
    <form action={formAction}>
      <input name="name" />
      <input name="priceCents" type="number" />
      <button type="submit">Save</button>
      {!state.ok && state.error ? <p>{state.error}</p> : null}
    </form>
  );
}

The important part is not the form API itself. It is that the UI can rely on a stable action state shape.

Example 3: Shared transformation between server and UI

export type ProductCardModel = {
  id: string;
  title: string;
  priceLabel: string;
  available: boolean;
};

export function toProductCardModel(product: Product): ProductCardModel {
  return {
    id: product.id,
    title: product.name,
    priceLabel: `$${(product.priceCents / 100).toFixed(2)}`,
    available: product.inStock,
  };
}

This is often more useful than passing raw database shapes directly into presentation components. It gives you a clean place to keep formatting and UI-specific naming.

Example 4: Typed error handling in route handlers

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

export async function POST(req: Request): Promise<Response> {
  const json = await req.json();
  const parsed = productInputSchema.safeParse(json);

  const result: ApiResult<Product> = parsed.success
    ? { ok: true, data: await saveProduct(parsed.data) }
    : { ok: false, error: 'Invalid request body' };

  return Response.json(result);
}

This mirrors the Server Action style and helps your frontend consume API responses consistently.

If type errors start to pile up during these refactors, the TypeScript Error Guide is a practical troubleshooting reference. For quick syntax reminders, keep the TypeScript Cheat Sheet close at hand.

When to update

Revisit your typescript nextjs guide conventions when the framework changes, but also when your application shape changes. The most useful update trigger is not a version number. It is friction.

Review your patterns when you notice any of the following:

  • page files are growing into mixed server/UI/business logic modules
  • Server Actions return inconsistent shapes across features
  • client components are receiving large, unstable prop objects
  • you are using as casts to get around route, form, or API typing issues
  • runtime validation exists in some paths but not others
  • framework upgrades change how you handle forms, caching, routing, or async component boundaries

A practical review checklist looks like this:

  1. Audit route files. Ensure pages and layouts mainly coordinate data and rendering rather than own domain logic.
  2. Audit action signatures. Standardize around one action result shape where possible.
  3. Audit runtime validation. Confirm that params, body payloads, and form inputs are validated at entry points.
  4. Audit prop surfaces. Reduce client component props to the minimum serializable shape they need.
  5. Audit exported helper types. Add explicit return types to important server utilities.
  6. Audit shared models. Split raw persistence models from UI view models when they are drifting apart.

If you want a durable rule set to carry forward, keep this final pattern in mind:

  • Server components fetch and compose.
  • Server Actions mutate and validate.
  • Client components interact.
  • Shared types describe the domain.
  • Runtime schemas protect the edges.

That structure is stable enough to survive ordinary framework churn, and flexible enough to evolve with your app. When Next.js changes a feature, you usually do not need to rethink everything. You just need to revisit the boundary where that feature touches your types.

Related Topics

#nextjs#typescript#app router#server actions#type safety#frameworks
T

TypeScript Website Editorial

Senior SEO Editor

Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.

2026-06-12T12:03:35.632Z