Next.js Server Actions removed an entire API layer from my app
← Back
April 2, 2026React7 min read

Next.js Server Actions removed an entire API layer from my app

Published April 2, 20267 min read

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.

typescript
// 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:

tsx
// 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.

typescript
// 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');
}
tsx
// 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:

tsx
'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:

tsx
'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:

typescript
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.

Share this
← All Posts7 min read