Next.js TypeScript Developer Experience (DX)

Building a Type-Safe API Layer in Next.js Without tRPC

Static Signal
A glowing circuit conduit connecting server and client, representing a shared type-safe API layer

You open a new Next.js project and immediately someone in your Slack says “just use tRPC.” So you add it. Then you add the adapter, the router, the context, the middleware, and the client provider. Three hours later your hello world endpoint works and you’ve touched eight files.

tRPC is genuinely good software. For teams building large applications with dozens of procedures, the end-to-end type safety and automatic inference are worth every byte. But for most projects — a handful of API routes, a small team, maybe a single developer — it’s architectural overhead that costs more than it saves.

The good news: you can get most of what tRPC offers using tools you already have. Route handlers, Zod, and TypeScript’s type inference are enough to build an API layer where request and response types are validated at runtime and shared between server and client at compile time. No extra dependencies. No new mental model.


What tRPC Actually Gives You

Before building a replacement, be honest about what you’re skipping.

tRPC’s core value proposition has two parts. First, it infers your response types automatically from your procedure return values — you write a function that returns data, and the client knows the shape of that data without any manual type definitions. Second, it uses a single HTTP transport layer, so you’re not writing fetch('/api/whatever') calls; you’re calling typed functions directly.

The pattern in this post gives you the first benefit almost entirely. You’ll define types once, share them between server and client, and get compile-time errors when they drift out of sync. What you won’t get is the automatic inference from return values — you’ll define your types explicitly with Zod schemas. For most projects, that’s not a regression. It’s a feature: explicit schemas double as runtime validation and documentation.

What you also won’t get is tRPC’s query/mutation client, which handles caching, background refetching, and optimistic updates when combined with React Query. If you need that, you want tRPC plus React Query, full stop. The pattern below doesn’t compete with that.


The Foundation: Shared Zod Schemas

The key to making this work is defining your data shapes in one place and importing them on both sides of the wire.

Create a lib/schemas/ directory (or types/ if you prefer — the name doesn’t matter, the import path does). Each file defines the Zod schema for a resource and exports the inferred TypeScript type alongside it:

// lib/schemas/user.ts
import { z } from "zod"

export const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "member", "viewer"]),
  createdAt: z.string().datetime(),
})

export type User = z.infer<typeof UserSchema>

export const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "member", "viewer"]).default("member"),
})

export type CreateUserInput = z.infer<typeof CreateUserSchema>

Separate your input schemas from your response schemas. UserSchema is what the API returns. CreateUserSchema is what the client sends. These will diverge — responses include server-generated fields like id and createdAt that inputs don’t have. Conflating them causes subtle type errors that take a while to track down.


Typed Route Handlers

Next.js route handlers in the App Router are regular functions. TypeScript doesn’t enforce request or response shapes by default — you can return anything from Response.json() and it’ll compile. Zod closes that gap.

// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server"
import { CreateUserSchema, UserSchema } from "@/lib/schemas/user"
import { db } from "@/lib/db"

export async function POST(req: NextRequest) {
  const body = await req.json()
  const parsed = CreateUserSchema.safeParse(body)

  if (!parsed.success) {
    return NextResponse.json(
      { error: "Invalid request", issues: parsed.error.issues },
      { status: 400 }
    )
  }

  const user = await db.user.create({ data: parsed.data })

  // Validate the response shape before sending it
  const validated = UserSchema.parse(user)
  return NextResponse.json(validated, { status: 201 })
}

Two things worth noting here. safeParse on the input means a malformed request returns a 400 with structured error details instead of crashing. parse on the output means you’ll get a runtime error during development if your database returns something that doesn’t match your schema — which is how you catch it before it ships.

You can tighten this further by writing a small handler wrapper that eliminates the boilerplate:

// lib/api.ts
import { z } from "zod"
import { NextRequest, NextResponse } from "next/server"

export function apiHandler<TInput, TOutput>(config: {
  input: z.ZodType<TInput>
  output: z.ZodType<TOutput>
  handler: (data: TInput, req: NextRequest) => Promise<TOutput>
}) {
  return async (req: NextRequest) => {
    const body = await req.json().catch(() => ({}))
    const parsed = config.input.safeParse(body)

    if (!parsed.success) {
      return NextResponse.json(
        { error: "Invalid request", issues: parsed.error.issues },
        { status: 400 }
      )
    }

    try {
      const result = await config.handler(parsed.data, req)
      const validated = config.output.parse(result)
      return NextResponse.json(validated)
    } catch (err) {
      console.error(err)
      return NextResponse.json({ error: "Internal server error" }, { status: 500 })
    }
  }
}

Now your route file is just the schema and the business logic:

// app/api/users/route.ts
import { apiHandler } from "@/lib/api"
import { CreateUserSchema, UserSchema } from "@/lib/schemas/user"
import { db } from "@/lib/db"

export const POST = apiHandler({
  input: CreateUserSchema,
  output: UserSchema,
  handler: async (data) => {
    return db.user.create({ data })
  },
})

Clean. Everything that can go wrong — bad input, bad output, unhandled errors — is handled in one place.


The Client-Side Fetch Wrapper

The other half of the pattern is a typed fetch utility that validates API responses on the client. This is where you close the loop: the same schema you used to validate the response on the server validates it again on the client, and TypeScript infers the return type so you don’t have to cast anything.

// lib/fetch.ts
import { z } from "zod"

export async function apiFetch<T>(
  schema: z.ZodType<T>,
  input: RequestInfo,
  init?: RequestInit
): Promise<T> {
  const res = await fetch(input, {
    headers: { "Content-Type": "application/json" },
    ...init,
  })

  if (!res.ok) {
    const error = await res.json().catch(() => ({ error: "Unknown error" }))
    throw new Error(error.error ?? `Request failed with status ${res.status}`)
  }

  const data = await res.json()
  return schema.parse(data)
}

Usage from a component or server action:

import { apiFetch } from "@/lib/fetch"
import { CreateUserInput, UserSchema } from "@/lib/schemas/user"

async function createUser(input: CreateUserInput) {
  return apiFetch(UserSchema, "/api/users", {
    method: "POST",
    body: JSON.stringify(input),
  })
}

The return type of createUser is Promise<User> — inferred from UserSchema, not manually annotated. Change the schema and the type changes everywhere it’s used. That’s the part that feels like tRPC.


A Complete Example

Putting it together: a /api/posts endpoint that creates a post and returns it, with a client function that knows exactly what it’ll get back.

// lib/schemas/post.ts
import { z } from "zod"

export const PostSchema = z.object({
  id: z.string().uuid(),
  title: z.string(),
  slug: z.string(),
  content: z.string(),
  authorId: z.string().uuid(),
  publishedAt: z.string().datetime().nullable(),
  createdAt: z.string().datetime(),
})

export type Post = z.infer<typeof PostSchema>

export const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  authorId: z.string().uuid(),
})

export type CreatePostInput = z.infer<typeof CreatePostSchema>
// app/api/posts/route.ts
import { apiHandler } from "@/lib/api"
import { CreatePostSchema, PostSchema } from "@/lib/schemas/post"
import { db } from "@/lib/db"
import { slugify } from "@/lib/utils"

export const POST = apiHandler({
  input: CreatePostSchema,
  output: PostSchema,
  handler: async (data) => {
    return db.post.create({
      data: {
        ...data,
        slug: slugify(data.title),
        publishedAt: null,
      },
    })
  },
})
// lib/api/posts.ts
import { apiFetch } from "@/lib/fetch"
import { CreatePostInput, PostSchema } from "@/lib/schemas/post"

export function createPost(input: CreatePostInput) {
  return apiFetch(PostSchema, "/api/posts", {
    method: "POST",
    body: JSON.stringify(input),
  })
}

The component that calls createPost gets a fully typed Post object back. Add a field to PostSchema and it shows up in the component. Remove a field and TypeScript tells you everywhere you were relying on it. No codegen. No build step. No extra runtime.


Where This Pattern Breaks Down

Be honest about the edges.

No automatic inference from return values. With tRPC, you can return a value from a procedure and the client type is inferred from that return value — no schema required. Here, you’re writing schemas explicitly. That’s more work, but it also means your validation is explicit and auditable. Whether that’s a tradeoff or a feature depends on your team.

No built-in query client. If you want caching, background refetching, loading states, or optimistic mutations, you’ll need to add React Query (or SWR) yourself and wire it to these fetch functions. That’s not hard, but it’s not automatic. tRPC’s React Query integration handles this for you with less boilerplate.

Schema drift is your responsibility. Nothing enforces that your route handler and your client fetch function use the same schema. If you create a new endpoint and forget to validate the response client-side, TypeScript won’t catch it. The discipline here is convention: always import your schemas from the shared location, always pass them to apiFetch. In a team setting, a linting rule that flags raw fetch calls to /api/ can enforce this.

File-based routing means more files. tRPC colocates all your procedures in one router. Route handlers mean one file per endpoint. For small APIs this is fine; for APIs with 50+ endpoints it starts to feel sprawling.


The Decision

Use this pattern if you have a small-to-medium API with a handful of routes, you want type safety without adding a dependency, and you don’t need tRPC’s query client integration. Most Next.js projects — portfolio sites, SaaS side projects, internal tools, small startups — fit this profile.

Use tRPC if you’re building something with many procedures that will grow, you want the DX of calling server functions directly from the client, or you’re integrating with React Query and want the mutation/cache layer for free. tRPC earns its setup cost at that scale.

The mistake is treating tRPC as a prerequisite for type safety instead of a specific tool for a specific set of requirements. You have everything you need in your existing stack. Zod, TypeScript, and a little convention go a long way.

- Static Signal is published by Neuron Web Development.