Zod v4 with Next.js 15: Complete Schema Validation for Forms, APIs, and Server Actions

Validate everything. Trust nothing. Zod v4 is the fastest TypeScript schema validation library, and it pairs perfectly with Next.js 15. In this tutorial, you will build a production-ready contact management app with bulletproof validation across forms, APIs, Server Actions, and environment variables.
What You Will Learn
By the end of this tutorial, you will:
- Understand what changed in Zod v4 and why it matters
- Define reusable schemas for your entire Next.js application
- Validate Server Action inputs with Zod and
useActionState - Secure API Route Handlers with request body and query param validation
- Parse and validate environment variables at build time
- Handle validation errors with user-friendly messages
- Build type-safe forms that share schemas between client and server
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - TypeScript experience (types, generics, inference)
- Next.js 15 familiarity (App Router, Server Components, Server Actions)
- React 19 basics (
useActionState, form actions) - A code editor — VS Code or Cursor recommended
Why Zod v4?
Zod has been the go-to schema validation library for TypeScript since 2022. Version 4 is a ground-up rewrite that brings massive performance improvements and new features:
| Feature | Zod v3 | Zod v4 |
|---|---|---|
| Parse speed | Baseline | 2-7x faster |
| Bundle size | ~57 KB | ~13 KB (77% smaller) |
| Tree-shaking | Limited | Full ESM tree-shaking |
| Error messages | Basic | Rich, structured errors |
| JSON Schema | Third-party | Built-in z.toJSONSchema() |
| Template literals | No | z.templateLiteral() |
| Metadata | No | z.registry() for forms/docs |
The biggest wins are speed and size. Zod v4 parses schemas 2-7x faster than v3, which matters when you validate every API request and form submission. The 77% bundle reduction means less JavaScript shipped to the client.
Step 1: Project Setup
Create a new Next.js 15 project and install Zod v4:
npx create-next-app@latest zod-nextjs-demo --typescript --tailwind --eslint --app --src-dir --turbopack
cd zod-nextjs-demoInstall Zod v4:
npm install zod@^4Verify you have Zod v4:
npx tsx -e "import {z} from 'zod'; console.log(z.version)"
# Should output 4.x.xYour project structure will look like this:
src/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── contacts/
│ │ ├── page.tsx
│ │ ├── new/
│ │ │ └── page.tsx
│ │ └── [id]/
│ │ └── page.tsx
│ └── api/
│ └── contacts/
│ └── route.ts
├── lib/
│ ├── schemas.ts # All Zod schemas
│ ├── env.ts # Environment validation
│ └── actions.ts # Server Actions
└── components/
└── contact-form.tsx # Reusable form component
Step 2: Define Your Schemas
The core principle of Zod is define once, use everywhere. Create a central schemas file that both client and server import.
Create src/lib/schemas.ts:
import { z } from "zod";
// Base contact schema — reused across forms, APIs, and actions
export const contactSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(100, "Name must be under 100 characters")
.trim(),
email: z
.string()
.email("Please enter a valid email address")
.toLowerCase(),
phone: z
.string()
.regex(/^\+?[\d\s-()]{7,15}$/, "Please enter a valid phone number")
.optional()
.or(z.literal("")),
company: z
.string()
.max(200, "Company name is too long")
.optional(),
message: z
.string()
.min(10, "Message must be at least 10 characters")
.max(5000, "Message must be under 5000 characters"),
priority: z.enum(["low", "medium", "high", "urgent"], {
message: "Please select a valid priority level",
}),
});
// Infer TypeScript types from the schema
export type Contact = z.infer<typeof contactSchema>;
// Schema for updating (all fields optional except id)
export const contactUpdateSchema = contactSchema.partial().extend({
id: z.string().uuid("Invalid contact ID"),
});
export type ContactUpdate = z.infer<typeof contactUpdateSchema>;
// Schema for search/filter query params
export const contactQuerySchema = z.object({
q: z.string().optional().default(""),
priority: z.enum(["low", "medium", "high", "urgent"]).optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(["name", "email", "createdAt"]).default("createdAt"),
order: z.enum(["asc", "desc"]).default("desc"),
});
export type ContactQuery = z.infer<typeof contactQuerySchema>;Key Zod v4 Patterns Used
1. Custom error messages inline:
In Zod v4, you pass error messages directly as the second argument to validators:
z.string().min(2, "Name must be at least 2 characters")2. Coercion for query params:
z.coerce.number() automatically converts string query params like "5" to the number 5. This is essential for URL search parameters which are always strings.
3. Type inference with z.infer:
You never write TypeScript interfaces manually. Zod schemas ARE your types:
// This type is automatically:
// {
// name: string;
// email: string;
// phone?: string | undefined;
// company?: string | undefined;
// message: string;
// priority: "low" | "medium" | "high" | "urgent";
// }
export type Contact = z.infer<typeof contactSchema>;Step 3: Validate Environment Variables
One of the most impactful uses of Zod is validating environment variables at startup. Catch misconfigurations before your app serves a single request.
Create src/lib/env.ts:
import { z } from "zod";
const envSchema = z.object({
// Database
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
// Auth
AUTH_SECRET: z
.string()
.min(32, "AUTH_SECRET must be at least 32 characters"),
// App
NEXT_PUBLIC_APP_URL: z
.string()
.url()
.default("http://localhost:3000"),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
// Email (optional in development)
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().default(587),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
});
// Parse and validate — throws at startup if invalid
function validateEnv() {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("Invalid environment variables:");
console.error(result.error.format());
throw new Error("Invalid environment configuration");
}
return result.data;
}
export const env = validateEnv();
// Type-safe access: env.DATABASE_URL is string, env.SMTP_PORT is numberNow import env anywhere instead of using process.env directly:
import { env } from "@/lib/env";
// Type-safe, validated, with defaults applied
const dbUrl = env.DATABASE_URL; // string (guaranteed)
const port = env.SMTP_PORT; // number (coerced from string)Never import env.ts in client components. It accesses process.env which only exists on the server. For client-side env vars, use NEXT_PUBLIC_ prefixed variables directly.
Step 4: Server Actions with Zod Validation
Server Actions are the primary way to handle form submissions in Next.js 15. Zod makes them type-safe and secure.
Create src/lib/actions.ts:
"use server";
import { contactSchema, type Contact } from "./schemas";
// Action state type for useActionState
export type ActionState = {
success: boolean;
message: string;
errors?: Record<string, string[]>;
data?: Contact;
};
export async function createContact(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// Extract raw form data
const rawData = {
name: formData.get("name"),
email: formData.get("email"),
phone: formData.get("phone"),
company: formData.get("company"),
message: formData.get("message"),
priority: formData.get("priority"),
};
// Validate with Zod
const result = contactSchema.safeParse(rawData);
if (!result.success) {
// Convert Zod errors to a flat object for the form
const fieldErrors: Record<string, string[]> = {};
for (const issue of result.error.issues) {
const field = issue.path[0]?.toString() ?? "form";
if (!fieldErrors[field]) fieldErrors[field] = [];
fieldErrors[field].push(issue.message);
}
return {
success: false,
message: "Please fix the errors below.",
errors: fieldErrors,
};
}
// result.data is fully typed as Contact
const validatedData = result.data;
try {
// In a real app, save to database
console.log("Creating contact:", validatedData);
// Simulate database insert
await new Promise((resolve) => setTimeout(resolve, 500));
return {
success: true,
message: `Contact "${validatedData.name}" created successfully!`,
data: validatedData,
};
} catch (error) {
return {
success: false,
message: "Something went wrong. Please try again.",
};
}
}Why safeParse over parse?
parse()throws aZodErroron invalid input — great for API routes where you catch and return 400safeParse()returns{ success, data, error }— ideal for Server Actions where you need to return error state to the form
Step 5: Build the Validated Form
Create a form component that displays server-side validation errors with useActionState.
Create src/components/contact-form.tsx:
"use client";
import { useActionState } from "react";
import { createContact, type ActionState } from "@/lib/actions";
const initialState: ActionState = {
success: false,
message: "",
};
export function ContactForm() {
const [state, formAction, isPending] = useActionState(
createContact,
initialState
);
return (
<form action={formAction} className="space-y-6 max-w-lg">
{/* Status message */}
{state.message && (
<div
className={`p-4 rounded-lg ${
state.success
? "bg-green-50 text-green-800 border border-green-200"
: state.errors
? "bg-red-50 text-red-800 border border-red-200"
: ""
}`}
>
{state.message}
</div>
)}
{/* Name */}
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name *
</label>
<input
id="name"
name="name"
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
{state.errors?.name && (
<p className="mt-1 text-sm text-red-600">{state.errors.name[0]}</p>
)}
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email *
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
{state.errors?.email && (
<p className="mt-1 text-sm text-red-600">{state.errors.email[0]}</p>
)}
</div>
{/* Phone */}
<div>
<label htmlFor="phone" className="block text-sm font-medium mb-1">
Phone
</label>
<input
id="phone"
name="phone"
type="tel"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
{state.errors?.phone && (
<p className="mt-1 text-sm text-red-600">{state.errors.phone[0]}</p>
)}
</div>
{/* Company */}
<div>
<label htmlFor="company" className="block text-sm font-medium mb-1">
Company
</label>
<input
id="company"
name="company"
type="text"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
{state.errors?.company && (
<p className="mt-1 text-sm text-red-600">
{state.errors.company[0]}
</p>
)}
</div>
{/* Message */}
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message *
</label>
<textarea
id="message"
name="message"
rows={4}
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
{state.errors?.message && (
<p className="mt-1 text-sm text-red-600">
{state.errors.message[0]}
</p>
)}
</div>
{/* Priority */}
<div>
<label htmlFor="priority" className="block text-sm font-medium mb-1">
Priority *
</label>
<select
id="priority"
name="priority"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">Select priority</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
{state.errors?.priority && (
<p className="mt-1 text-sm text-red-600">
{state.errors.priority[0]}
</p>
)}
</div>
{/* Submit */}
<button
type="submit"
disabled={isPending}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending ? "Submitting..." : "Create Contact"}
</button>
</form>
);
}How This Works
- User fills out the form and clicks submit
formActionsends theFormDatato thecreateContactServer Action- Zod validates all fields on the server
- If validation fails, errors are returned to the component via
state.errors - Each field displays its specific error message
- If validation passes, the contact is created and a success message is shown
The isPending state from useActionState handles the loading state automatically — no manual useState needed.
Step 6: Validate API Route Handlers
For API routes, use Zod to validate request bodies, query parameters, and path parameters.
Create src/app/api/contacts/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { contactSchema, contactQuerySchema } from "@/lib/schemas";
// GET /api/contacts?q=john&priority=high&page=1&limit=10
export async function GET(request: NextRequest) {
const searchParams = Object.fromEntries(
request.nextUrl.searchParams.entries()
);
// Validate query parameters
const result = contactQuerySchema.safeParse(searchParams);
if (!result.success) {
return NextResponse.json(
{
error: "Invalid query parameters",
details: result.error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
})),
},
{ status: 400 }
);
}
const { q, priority, page, limit, sort, order } = result.data;
// In a real app, query your database with these validated params
console.log("Fetching contacts:", { q, priority, page, limit, sort, order });
return NextResponse.json({
contacts: [],
pagination: { page, limit, total: 0 },
});
}
// POST /api/contacts
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Invalid JSON body" },
{ status: 400 }
);
}
// Validate request body
const result = contactSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: "Validation failed",
details: result.error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
})),
},
{ status: 400 }
);
}
const contact = result.data;
// In a real app, insert into database
console.log("Creating contact via API:", contact);
return NextResponse.json(
{ id: crypto.randomUUID(), ...contact },
{ status: 201 }
);
}Reusable Validation Helper
If you have many API routes, extract a helper to reduce boilerplate:
// src/lib/validate.ts
import { z, type ZodType } from "zod";
import { NextResponse } from "next/server";
export function validateBody<T extends ZodType>(schema: T, data: unknown) {
const result = schema.safeParse(data);
if (!result.success) {
return {
success: false as const,
response: NextResponse.json(
{
error: "Validation failed",
details: result.error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
})),
},
{ status: 400 }
),
};
}
return {
success: true as const,
data: result.data as z.infer<T>,
};
}Now your API routes become much cleaner:
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body) {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const validation = validateBody(contactSchema, body);
if (!validation.success) return validation.response;
// validation.data is fully typed as Contact
const contact = validation.data;
// ... save to database
}Step 7: Advanced Zod v4 Patterns
Pattern 1: Discriminated Unions
Handle different form types with a single schema:
const notificationSchema = z.discriminatedUnion("channel", [
z.object({
channel: z.literal("email"),
email: z.string().email(),
subject: z.string().min(1),
}),
z.object({
channel: z.literal("sms"),
phone: z.string().regex(/^\+[\d]{10,15}$/),
}),
z.object({
channel: z.literal("push"),
deviceToken: z.string().min(1),
title: z.string().min(1),
}),
]);
// TypeScript knows the exact shape based on "channel"
type Notification = z.infer<typeof notificationSchema>;Pattern 2: Schema Composition with .pipe()
Transform and validate in stages:
// Parse a comma-separated string into a validated array
const tagsSchema = z
.string()
.transform((val) => val.split(",").map((s) => s.trim()))
.pipe(z.array(z.string().min(1).max(50)).min(1).max(10));
tagsSchema.parse("react, nextjs, typescript");
// Result: ["react", "nextjs", "typescript"]Pattern 3: Zod v4 JSON Schema Generation
Generate JSON Schema from your Zod schemas — perfect for API documentation or OpenAPI specs:
import { z } from "zod";
const userSchema = z.object({
name: z.string().describe("The user's full name"),
email: z.string().email().describe("Primary email address"),
age: z.number().int().min(18).describe("Must be 18 or older"),
});
// Built-in in Zod v4 — no third-party library needed
const jsonSchema = z.toJSONSchema(userSchema);
console.log(JSON.stringify(jsonSchema, null, 2));Output:
{
"type": "object",
"properties": {
"name": { "type": "string", "description": "The user's full name" },
"email": { "type": "string", "format": "email", "description": "Primary email address" },
"age": { "type": "integer", "minimum": 18, "description": "Must be 18 or older" }
},
"required": ["name", "email", "age"]
}Pattern 4: Custom Error Maps
Customize all error messages globally for your app:
z.config({
customError: (issue) => {
if (issue.code === "too_small" && issue.minimum === 1) {
return { message: "This field is required" };
}
if (issue.code === "invalid_type" && issue.expected === "string") {
return { message: "Please enter text" };
}
return { message: issue.message };
},
});Pattern 5: Form Metadata with z.registry()
Zod v4 introduces registries for attaching metadata to schemas — useful for auto-generating form UIs:
const formRegistry = z.registry<{
label: string;
placeholder?: string;
helpText?: string;
}>();
const nameField = z.string().min(2);
const emailField = z.string().email();
formRegistry.register(nameField, {
label: "Full Name",
placeholder: "John Doe",
helpText: "Enter your first and last name",
});
formRegistry.register(emailField, {
label: "Email Address",
placeholder: "john@example.com",
});
// Retrieve metadata for form rendering
const nameMeta = formRegistry.get(nameField);
// { label: "Full Name", placeholder: "John Doe", helpText: "..." }Step 8: Error Handling Best Practices
Flatten Errors for Forms
Zod v4 provides .flatten() for form-friendly error shapes:
const result = contactSchema.safeParse(badData);
if (!result.success) {
const flat = result.error.flatten();
// flat.formErrors — array of top-level errors
// flat.fieldErrors — { name: string[], email: string[], ... }
console.log(flat.fieldErrors);
// {
// name: ["Name must be at least 2 characters"],
// email: ["Please enter a valid email address"],
// }
}Format Errors for API Responses
Use .format() for nested error structures:
const formatted = result.error.format();
// {
// name: { _errors: ["Name must be at least 2 characters"] },
// email: { _errors: ["Please enter a valid email address"] },
// }Type-Safe Error Handling
function handleValidation<T extends z.ZodType>(
schema: T,
data: unknown
): { data: z.infer<T>; errors: null } | { data: null; errors: z.inferFlattenedErrors<T> } {
const result = schema.safeParse(data);
if (result.success) {
return { data: result.data, errors: null };
}
return { data: null, errors: result.error.flatten() };
}Step 9: Client-Side Validation (Optional Enhancement)
While server-side validation is the source of truth, you can add client-side validation for instant feedback. Since schemas are shared, validation rules stay in sync automatically.
"use client";
import { useState } from "react";
import { contactSchema } from "@/lib/schemas";
import type { z } from "zod";
export function useFormValidation() {
const [errors, setErrors] = useState<Record<string, string[]>>({});
function validateField(name: string, value: unknown) {
// Pick just the one field from the schema
const fieldSchema = contactSchema.shape[name as keyof typeof contactSchema.shape];
if (!fieldSchema) return;
const result = fieldSchema.safeParse(value);
setErrors((prev) => ({
...prev,
[name]: result.success ? [] : result.error.issues.map((i) => i.message),
}));
}
function clearErrors() {
setErrors({});
}
return { errors, validateField, clearErrors };
}Use it in your form for real-time validation on blur:
const { errors, validateField } = useFormValidation();
<input
name="email"
onBlur={(e) => validateField("email", e.target.value)}
/>
{errors.email?.length > 0 && (
<p className="text-red-600">{errors.email[0]}</p>
)}Step 10: Testing Your Schemas
Schemas are pure functions — they are the easiest part of your app to test.
// __tests__/schemas.test.ts
import { describe, it, expect } from "vitest";
import { contactSchema, contactQuerySchema } from "@/lib/schemas";
describe("contactSchema", () => {
const validContact = {
name: "Jane Smith",
email: "jane@example.com",
message: "This is a test message for the contact form.",
priority: "medium" as const,
};
it("accepts valid contact data", () => {
const result = contactSchema.safeParse(validContact);
expect(result.success).toBe(true);
});
it("rejects empty name", () => {
const result = contactSchema.safeParse({ ...validContact, name: "" });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain("at least 2");
}
});
it("rejects invalid email", () => {
const result = contactSchema.safeParse({
...validContact,
email: "not-an-email",
});
expect(result.success).toBe(false);
});
it("trims and lowercases email", () => {
const result = contactSchema.safeParse({
...validContact,
email: " JANE@Example.COM ",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.email).toBe("jane@example.com");
}
});
it("accepts optional phone", () => {
const result = contactSchema.safeParse({
...validContact,
phone: "+1 555-123-4567",
});
expect(result.success).toBe(true);
});
it("rejects invalid priority", () => {
const result = contactSchema.safeParse({
...validContact,
priority: "critical",
});
expect(result.success).toBe(false);
});
});
describe("contactQuerySchema", () => {
it("applies defaults for missing params", () => {
const result = contactQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.page).toBe(1);
expect(result.data.limit).toBe(20);
expect(result.data.sort).toBe("createdAt");
expect(result.data.order).toBe("desc");
}
});
it("coerces string numbers", () => {
const result = contactQuerySchema.safeParse({
page: "3",
limit: "50",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.page).toBe(3);
expect(result.data.limit).toBe(50);
}
});
it("rejects negative page numbers", () => {
const result = contactQuerySchema.safeParse({ page: "-1" });
expect(result.success).toBe(false);
});
});Run tests:
npx vitest run --reporter=verboseTroubleshooting
"Cannot find module 'zod'"
Make sure you installed Zod v4 specifically:
npm install zod@^4"Type 'ZodObject' is not assignable..."
Zod v4 has different type exports. If upgrading from v3, update your imports:
// v3 (old)
import { ZodType, ZodSchema } from "zod";
// v4 (new) — use z.ZodType directly
import { z } from "zod";
type Schema = z.ZodType;Form data returns null
FormData.get() returns string | File | null. Zod handles null by rejecting it as an invalid type, which gives proper error messages. No need for manual null checks.
Server Action type errors
Ensure your action signature matches what useActionState expects:
// Correct signature for useActionState
async function myAction(
prevState: ActionState, // previous state
formData: FormData // form data
): Promise<ActionState> // must return same state typeNext Steps
Now that you have mastered Zod v4 validation in Next.js, consider:
- Add database integration — Use Zod with Drizzle ORM for end-to-end type safety from form to database
- Build type-safe APIs — Combine Zod with tRPC for fully typed API layers
- Implement authentication — Validate auth forms with Better Auth
- Explore Zod v4 registries — Build auto-generated form UIs from schema metadata
- Generate OpenAPI specs — Use
z.toJSONSchema()to auto-document your APIs
Conclusion
Zod v4 is the validation layer every Next.js app needs. By defining schemas once and sharing them between client and server, you get:
- Type safety — TypeScript types derived from schemas, never out of sync
- Security — Every input validated before processing
- Developer experience — IntelliSense, autocompletion, and compile-time checks
- User experience — Clear, specific error messages on every field
- Performance — 2-7x faster parsing and 77% smaller bundle than Zod v3
The "define once, validate everywhere" pattern eliminates entire categories of bugs. Your forms, APIs, Server Actions, and environment variables all share the same source of truth. When you change a validation rule, it updates everywhere automatically.
Start with z.object() and safeParse(). That is all you need to make your Next.js app bulletproof.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production
Learn how to build a type-safe full-stack application using Drizzle ORM with Next.js 15. This hands-on tutorial covers schema design, migrations, Server Actions, CRUD operations, and deployment with PostgreSQL.

Build a Type-Safe GraphQL API with Next.js App Router, Yoga, and Pothos
Learn how to build a fully type-safe GraphQL API using Next.js 15 App Router, GraphQL Yoga, and Pothos schema builder. This hands-on tutorial covers schema design, queries, mutations, authentication middleware, and a React client with urql.

Build End-to-End Type-Safe APIs with tRPC and Next.js App Router
Learn how to build fully type-safe APIs with tRPC and Next.js 15 App Router. This hands-on tutorial covers router setup, procedures, middleware, React Query integration, and server-side calls — all without writing a single API schema.