Skip to content
Building Type-Safe APIs with Next.js 14 Server Actions
Engineering2024-11-108 min read

Building Type-Safe APIs with Next.js 14 Server Actions


Building Type-Safe APIs with Next.js 14 Server Actions


For three years, every Next.js project I built had the same structure: a `/pages/api` or `/app/api` folder full of route handlers, a `lib/api-client.ts` that typed the fetch calls, and a constant mental overhead of keeping the two in sync.


Server Actions in Next.js 14 eliminated most of that overhead. Here's why they matter, and how to use them correctly.


What Server Actions Actually Are


A Server Action is an async function marked with `"use server"` that executes on the server but can be called directly from client components. The Next.js compiler handles the network boundary — you write a function call, it becomes an HTTP request under the hood.


```ts

// app/actions/contact.ts

"use server"


import { z } from "zod"

import { Resend } from "resend"


const ContactSchema = z.object({

name: z.string().min(2).max(50),

email: z.string().email(),

message: z.string().min(10).max(2000),

})


export async function sendContactEmail(

input: z.infer<typeof ContactSchema>

): Promise<{ success: boolean; error?: string }> {

const result = ContactSchema.safeParse(input)


if (!result.success) {

return { success: false, error: "Invalid input" }

}


const resend = new Resend(process.env.RESEND_API_KEY)


try {

await resend.emails.send({

from: "portfolio@404ghost.dev",

to: "bharat3645@gmail.com",

subject: `Contact from ${result.data.name}`,

html: `<p>${result.data.message}</p>`,

})

return { success: true }

} catch {

return { success: false, error: "Failed to send" }

}

}

```


```tsx

// app/contact/page.tsx (Client Component)

"use client"


import { sendContactEmail } from "@/app/actions/contact"


export function ContactForm() {

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {

e.preventDefault()

const formData = new FormData(e.currentTarget)


const result = await sendContactEmail({

name: formData.get("name") as string,

email: formData.get("email") as string,

message: formData.get("message") as string,

})


if (result.success) toast.success("Message sent!")

else toast.error(result.error)

}


return <form onSubmit={handleSubmit}>{/* fields */}</form>

}

```


The type safety flows end-to-end. If you change the signature of `sendContactEmail`, TypeScript errors immediately at every call site — no OpenAPI spec, no codegen, no manual sync.


The Architecture Advantage


Traditional Next.js API routes create an artificial layer:


```

Client Component

↓ fetch("/api/contact", { method: "POST", body: ... })

Route Handler (/api/contact/route.ts)

↓ parse, validate, execute

Business Logic

```


Server Actions collapse this:


```

Client Component

↓ await sendContactEmail({ ... })

Business Logic (runs on server)

```


The intermediate layer — the route handler — is gone. You still get server execution, but without the indirection.


Progressive Enhancement


One underappreciated property of Server Actions: they work without JavaScript.


If you wire them to a `<form action={serverAction}>` rather than an `onSubmit` handler, the form submits via a standard HTTP POST even if JavaScript hasn't loaded yet. The action still executes on the server. This is progressive enhancement by default.


```tsx

// This works even with JS disabled

export function ContactForm() {

return (

<form action={sendContactEmail}>

<input name="name" required />

<input name="email" type="email" required />

<textarea name="message" required />

<button type="submit">Send</button>

</form>

)

}

```


Validation Pattern: Zod + Server Actions


The cleanest pattern I've found is Zod validation at the server action boundary. Never trust client input, even from your own forms.


```ts

import { z } from "zod"


type ActionResult<T> =

| { success: true; data: T }

| { success: false; error: string; fields?: Record<string, string[]> }


function createAction<TInput, TOutput>(

schema: z.ZodType<TInput>,

handler: (input: TInput) => Promise<TOutput>

) {

return async (input: unknown): Promise<ActionResult<TOutput>> => {

const result = schema.safeParse(input)


if (!result.success) {

return {

success: false,

error: "Validation failed",

fields: result.error.flatten().fieldErrors as Record<string, string[]>,

}

}


try {

const data = await handler(result.data)

return { success: true, data }

} catch (err) {

return { success: false, error: "Server error" }

}

}

}


// Usage

export const sendContactEmail = createAction(

ContactSchema,

async ({ name, email, message }) => {

// guaranteed-valid input

await resend.emails.send({ ... })

}

)

```


When Not to Use Server Actions


Server Actions aren't for everything. Avoid them for:


- **High-frequency requests** — polling, real-time updates, WebSocket alternatives. Use Route Handlers + SSE or WebSockets instead.

- **External API calls where you own the API** — if you're also the API consumer from mobile apps, keep Route Handlers for the shared endpoint.

- **File uploads to third-party storage** — presigned URLs + direct upload is still the right pattern.

- **Anything that needs request-level caching** — Route Handlers integrate better with Next.js `fetch` caching semantics.


For mutations from React components — contact forms, auth flows, data writes, user preferences — Server Actions are the cleanest primitive available in Next.js today.


---


*This pattern powers the contact form on this portfolio. The full implementation uses React Hook Form for client-side UX + Zod on the server action boundary + Resend for email delivery.*


© 2024 Bharat Singh Parihar