Next.js Server Actions removed an entire API layer from my app
I deleted three files last month. Not because the code was bad — it was fine. I deleted them because Server Actions made them unnecessary. The three files were API routes: app/api/profile/route.ts, app/api/notifications/route.ts, and app/api/settings/route.ts. The components that called them now call server functions directly. The before and after changed how I think about Next.js architecture.
What the API layer looked like before
The classic pattern: a form in a client component calls fetch to an API route, which validates the input, runs the business logic, and returns JSON.
// app/api/profile/route.ts (DELETED)
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { db } from '@/lib/db';
import { z } from 'zod';
const UpdateProfileSchema = z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
});
export async function PATCH(req: NextRequest) {
const session = await getServerSession();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const parsed = UpdateProfileSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const user = await db.user.update({
where: { id: session.user.id },
data: parsed.data,
});
return NextResponse.json(user);
}
And the client component calling it:
// components/ProfileForm.tsx (before)
'use client';
import { useState } from 'react';
export function ProfileForm({ user }: { user: User }) {
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
const formData = new FormData(e.currentTarget as HTMLFormElement);
await fetch('/api/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.get('name'),
bio: formData.get('bio'),
}),
});
setSaving(false);
}
return (
<form onSubmit={handleSubmit}>
<input name="name" defaultValue={user.name} />
<textarea name="bio" defaultValue={user.bio} />
<button type="submit" disabled={saving}>Save</button>
</form>
);
}
The Server Actions version
With Server Actions, the API route disappears. The mutation logic moves into a function marked 'use server', and the form calls it directly.
// app/actions/profile.ts
'use server';
import { revalidatePath } from 'next/cache';
import { getServerSession } from 'next-auth';
import { db } from '@/lib/db';
import { z } from 'zod';
const UpdateProfileSchema = z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
});
export async function updateProfile(formData: FormData) {
const session = await getServerSession();
if (!session?.user?.id) throw new Error('Unauthorized');
const parsed = UpdateProfileSchema.safeParse({
name: formData.get('name'),
bio: formData.get('bio') || undefined,
});
if (!parsed.success) throw new Error('Invalid input');
await db.user.update({
where: { id: session.user.id },
data: parsed.data,
});
revalidatePath('/profile');
}
// components/ProfileForm.tsx (after)
import { updateProfile } from '@/app/actions/profile';
export function ProfileForm({ user }: { user: User }) {
return (
<form action={updateProfile}>
<input name="name" defaultValue={user.name} />
<textarea name="bio" defaultValue={user.bio} />
<button type="submit">Save</button>
</form>
);
}
The component is now a server component. No 'use client', no useState, no fetch. The form's action prop accepts a server action directly — Next.js serialises the form data and calls the function on the server.
Pending state with useFormStatus
The objection: "But I need a loading spinner." That still works, with a small client component wrapper:
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Save'}
</button>
);
}
// Back in the server component form:
export function ProfileForm({ user }: { user: User }) {
return (
<form action={updateProfile}>
<input name="name" defaultValue={user.name} />
<textarea name="bio" defaultValue={user.bio} />
<SubmitButton />
</form>
);
}
One tiny client component for the button — everything else stays on the server.
Error handling and optimistic updates
For more complex forms that need error feedback, use useActionState:
'use client';
import { useActionState } from 'react';
import { updateProfile } from '@/app/actions/profile';
export function ProfileForm({ user }: { user: User }) {
const [error, formAction] = useActionState(updateProfile, null);
return (
<form action={formAction}>
<input name="name" defaultValue={user.name} />
{error && <p className="error">{error}</p>}
<button type="submit">Save</button>
</form>
);
}
And the action returns the error instead of throwing:
export async function updateProfile(
_prevState: string | null,
formData: FormData
): Promise<string | null> {
const session = await getServerSession();
if (!session?.user?.id) return 'You must be logged in';
const parsed = UpdateProfileSchema.safeParse({
name: formData.get('name'),
bio: formData.get('bio') || undefined,
});
if (!parsed.success) return 'Please check your input';
await db.user.update({
where: { id: session.user.id },
data: parsed.data,
});
revalidatePath('/profile');
return null; // success
}
When to keep API routes
Server Actions are not a replacement for every API route. Keep API routes when:
- External clients need to call your endpoints (mobile apps, third-party integrations, webhooks)
- Streaming responses — Server Actions do not support streaming
- File uploads larger than what FormData handles comfortably
- GET requests — Server Actions are POST-only under the hood
For internal Next.js mutations triggered by forms and buttons? Server Actions cut the boilerplate in half and remove an entire mental model (request/response cycle) that you do not need when the client and server are the same application.
What actually changed
Three fewer files to maintain. No serialisation/deserialisation logic. No manual Content-Type headers. No res.json(). The action is just a function — testable with a direct call, typeable end-to-end, and colocatable with the component that uses it.
The architectural shift is subtle but real: the boundary between "client code" and "server code" stops being a file boundary and becomes a function boundary. That is a better model for most Next.js apps, and once you see it, the old pattern starts to feel like unnecessary overhead.