IAMUVIN

Next.js & React

Next.js Server Actions and Forms: The Complete Guide

Uvin Vindula·February 3, 2025·12 min read
Share

TL;DR

Server Actions have replaced API routes for every form and mutation in my Next.js projects. I use them for contact forms on iamuvin.com, order submissions on EuroParts Lanka, and data mutations across every client build. This guide covers the full architecture: creating your first Server Action, validating input with Zod, showing loading states with useFormStatus, handling errors gracefully, implementing optimistic updates with useOptimistic, uploading files, and knowing when to reach for API routes instead. Every pattern here comes from production code. If you are still writing /api/contact route handlers for form submissions, this article will change how you build forms in Next.js.


What Server Actions Are

Server Actions are asynchronous functions that run on the server. You define them with the "use server" directive and call them directly from your components. No fetch calls. No API route files. No manual request/response handling.

Here is the mental model that made them click for me: a Server Action is a function that lives on the server but gets called like a regular function from the client. Next.js handles the network request, serialization, and response behind the scenes.

tsx
"use server";

export async function createContact(formData: FormData) {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const message = formData.get("message") as string;

  await db.contact.create({
    data: { name, email, message },
  });
}

That is a complete Server Action. No route handler, no NextResponse.json(), no HTTP method checks. Just a function that runs on the server when called.

There are two ways to define them:

  1. Inline in a Server Component. Add "use server" at the top of the function body. I rarely use this because it mixes concerns.
  2. In a dedicated file. Add "use server" at the top of the file. This is what I do on every project. It keeps actions organized and reusable.

I keep all my Server Actions in an actions/ directory at the project root or inside lib/actions/. Every action file exports one or more related functions. The contact form actions go in actions/contact.ts. The order actions go in actions/orders.ts. Clean separation.

The key constraint to understand: Server Actions can only receive serializable arguments. FormData, strings, numbers, plain objects, arrays. No class instances. No functions. No DOM elements. This is because the data crosses the network boundary, even though the code makes it look like a direct function call.


Basic Form with Server Action

Let me show you the exact pattern I use for the contact form on iamuvin.com. This is the simplest version before we add validation, loading states, and error handling.

tsx
// actions/contact.ts
"use server";

import { db } from "@/lib/db";

export async function submitContactForm(formData: FormData) {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const message = formData.get("message") as string;

  await db.contact.create({
    data: {
      name,
      email,
      message,
      createdAt: new Date(),
    },
  });
}
tsx
// app/contact/page.tsx
import { submitContactForm } from "@/actions/contact";

export default function ContactPage() {
  return (
    <form action={submitContactForm}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" type="text" required />

      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />

      <button type="submit">Send Message</button>
    </form>
  );
}

Notice what is not here: no useState, no onSubmit handler, no fetch call, no e.preventDefault(). The form's action prop points directly at the Server Action. When the user clicks submit, Next.js serializes the form data, sends it to the server, executes the function, and revalidates the page.

This works without any client-side JavaScript. Progressive enhancement out of the box. The form submits even if JS fails to load. That matters for contact forms where you absolutely cannot afford to lose submissions.

For EuroParts Lanka, the contact form follows this exact pattern but with additional fields for vehicle make, model, and the part they need. The Server Action writes to the database and triggers an email notification. Same structure, just more fields.


Validation with Zod

Never trust client-side validation alone. HTML required attributes and type="email" are good for UX, but they are trivial to bypass. Every Server Action needs server-side validation.

I use Zod on every project. It gives me type inference and runtime validation in one package.

tsx
// actions/contact.ts
"use server";

import { z } from "zod";
import { db } from "@/lib/db";

const contactSchema = z.object({
  name: z
    .string()
    .min(2, "Name must be at least 2 characters")
    .max(100, "Name is too long"),
  email: z
    .string()
    .email("Please enter a valid email address"),
  message: z
    .string()
    .min(10, "Message must be at least 10 characters")
    .max(5000, "Message is too long"),
});

export type ContactFormState = {
  success: boolean;
  message: string;
  errors?: {
    name?: string[];
    email?: string[];
    message?: string[];
  };
};

export async function submitContactForm(
  prevState: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  const rawData = {
    name: formData.get("name") as string,
    email: formData.get("email") as string,
    message: formData.get("message") as string,
  };

  const validated = contactSchema.safeParse(rawData);

  if (!validated.success) {
    return {
      success: false,
      message: "Please fix the errors below.",
      errors: validated.error.flatten().fieldErrors,
    };
  }

  await db.contact.create({
    data: validated.data,
  });

  return {
    success: true,
    message: "Thank you! I will get back to you soon.",
  };
}

The function signature changes when you use useActionState (formerly useFormState). It receives the previous state as the first argument and FormData as the second. This lets you return validation errors back to the form.

Here is the client component that consumes those errors:

tsx
// components/contact-form.tsx
"use client";

import { useActionState } from "react";
import { submitContactForm, type ContactFormState } from "@/actions/contact";

const initialState: ContactFormState = {
  success: false,
  message: "",
};

export function ContactForm() {
  const [state, formAction, isPending] = useActionState(
    submitContactForm,
    initialState
  );

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          type="text"
          required
          aria-describedby={state.errors?.name ? "name-error" : undefined}
        />
        {state.errors?.name && (
          <p id="name-error" className="text-sm text-red-500">
            {state.errors.name[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          aria-describedby={state.errors?.email ? "email-error" : undefined}
        />
        {state.errors?.email && (
          <p id="email-error" className="text-sm text-red-500">
            {state.errors.email[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          name="message"
          required
          aria-describedby={
            state.errors?.message ? "message-error" : undefined
          }
        />
        {state.errors?.message && (
          <p id="message-error" className="text-sm text-red-500">
            {state.errors.message[0]}
          </p>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? "Sending..." : "Send Message"}
      </button>

      {state.message && (
        <p className={state.success ? "text-green-500" : "text-red-500"}>
          {state.message}
        </p>
      )}
    </form>
  );
}

A few things I want to highlight:

  • `aria-describedby` links errors to inputs. Screen readers announce the error when the user focuses the input. Accessibility is not optional.
  • `isPending` from `useActionState` gives you the loading state for free. No separate state variable needed.
  • Zod's `flatten()` method structures errors by field name. This maps perfectly to form fields.

This is the exact validation pattern I use on the EuroParts Lanka order inquiry form, just with a larger schema that includes vehicle year, make, model, and part description fields.


Loading States with useFormStatus

useActionState gives you isPending, but sometimes you need loading state deeper in the component tree. That is where useFormStatus comes in.

The rule: useFormStatus must be called from a component that is a child of a <form>. It cannot be called in the same component that renders the form.

tsx
// components/submit-button.tsx
"use client";

import { useFormStatus } from "react-dom";

interface SubmitButtonProps {
  label?: string;
  loadingLabel?: string;
}

export function SubmitButton({
  label = "Submit",
  loadingLabel = "Submitting...",
}: SubmitButtonProps) {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className={`
        px-6 py-3 rounded-lg font-medium transition-all duration-200
        ${pending
          ? "bg-gray-400 cursor-not-allowed opacity-60"
          : "bg-[#F7931A] hover:bg-[#E07B0A] text-white cursor-pointer"
        }
      `}
    >
      {pending ? (
        <span className="flex items-center gap-2">
          <svg
            className="animate-spin h-4 w-4"
            viewBox="0 0 24 24"
            fill="none"
          >
            <circle
              cx="12"
              cy="12"
              r="10"
              stroke="currentColor"
              strokeWidth="4"
              className="opacity-25"
            />
            <path
              fill="currentColor"
              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
              className="opacity-75"
            />
          </svg>
          {loadingLabel}
        </span>
      ) : (
        label
      )}
    </button>
  );
}

Now use it inside any form:

tsx
<form action={formAction}>
  {/* ...fields... */}
  <SubmitButton label="Send Message" loadingLabel="Sending..." />
</form>

I reuse this SubmitButton component across every project. It is the same component on iamuvin.com's contact page, EuroParts Lanka's order forms, and every client project. One component, consistent loading UX everywhere.

The useFormStatus hook also exposes data (the FormData being submitted), method, and action. I rarely need those, but data is useful when you want to show the user what they submitted while the action is processing.


Error Handling

Things go wrong. Database connections fail. Email services time out. Rate limits get hit. Your Server Actions need to handle all of these gracefully.

Here is my standard error handling pattern:

tsx
// actions/contact.ts
"use server";

import { z } from "zod";
import { db } from "@/lib/db";
import { sendEmail } from "@/lib/email";

const contactSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  message: z.string().min(10).max(5000),
});

export type ActionResult = {
  success: boolean;
  message: string;
  errors?: Record<string, string[]>;
};

export async function submitContactForm(
  prevState: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const validated = contactSchema.safeParse({
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  });

  if (!validated.success) {
    return {
      success: false,
      message: "Please fix the errors below.",
      errors: validated.error.flatten().fieldErrors,
    };
  }

  try {
    await db.contact.create({
      data: validated.data,
    });

    await sendEmail({
      to: "contact@uvin.lk",
      subject: `New contact from ${validated.data.name}`,
      body: validated.data.message,
    });

    return {
      success: true,
      message: "Message sent successfully!",
    };
  } catch (error) {
    console.error("Contact form submission failed:", error);

    return {
      success: false,
      message:
        "Something went wrong. Please try again or email me directly at contact@uvin.lk.",
    };
  }
}

Critical rules I follow:

  1. Never throw from a Server Action called via `useActionState`. Return a structured error object instead. Thrown errors bubble up to the nearest error boundary, which replaces your entire form with an error message. That is terrible UX for a validation error.
  2. Always log the actual error server-side. The user gets a friendly message. Your logs get the stack trace.
  3. Provide a fallback contact method. If my form breaks, the user can still reach me via email. Never leave them stranded.
  4. Validate before doing anything expensive. Check the Zod schema before hitting the database or sending emails. Fail fast.

For actions that should genuinely throw (like unauthorized access), I use a different pattern:

tsx
"use server";

import { auth } from "@/lib/auth";

export async function deleteOrder(orderId: string) {
  const session = await auth();

  if (!session?.user) {
    throw new Error("Unauthorized");
  }

  await db.order.delete({
    where: { id: orderId, userId: session.user.id },
  });

  revalidatePath("/orders");
}

Throwing here is correct because an unauthorized delete attempt is not a form validation issue. It is a security boundary. The error boundary catches it, and the user sees a generic error page instead of a broken form.


Optimistic Updates with useOptimistic

Optimistic updates make your UI feel instant. The idea is simple: update the UI immediately before the server confirms, then reconcile when the response comes back.

Here is a real pattern from a project where users can add items to a favorites list:

tsx
// components/favorites-list.tsx
"use client";

import { useOptimistic } from "react";
import { toggleFavorite } from "@/actions/favorites";

interface FavoriteItem {
  id: string;
  name: string;
  isFavorited: boolean;
}

interface FavoritesListProps {
  items: FavoriteItem[];
}

export function FavoritesList({ items }: FavoritesListProps) {
  const [optimisticItems, addOptimistic] = useOptimistic(
    items,
    (currentItems: FavoriteItem[], toggledId: string) =>
      currentItems.map((item) =>
        item.id === toggledId
          ? { ...item, isFavorited: !item.isFavorited }
          : item
      )
  );

  async function handleToggle(itemId: string) {
    addOptimistic(itemId);
    await toggleFavorite(itemId);
  }

  return (
    <ul>
      {optimisticItems.map((item) => (
        <li key={item.id} className="flex items-center justify-between py-2">
          <span>{item.name}</span>
          <form action={() => handleToggle(item.id)}>
            <button type="submit">
              {item.isFavorited ? "Unfavorite" : "Favorite"}
            </button>
          </form>
        </li>
      ))}
    </ul>
  );
}
tsx
// actions/favorites.ts
"use server";

import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";

export async function toggleFavorite(itemId: string) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");

  const existing = await db.favorite.findUnique({
    where: {
      userId_itemId: {
        userId: session.user.id,
        itemId,
      },
    },
  });

  if (existing) {
    await db.favorite.delete({
      where: { id: existing.id },
    });
  } else {
    await db.favorite.create({
      data: {
        userId: session.user.id,
        itemId,
      },
    });
  }

  revalidatePath("/favorites");
}

When the user clicks "Favorite," the heart fills instantly. The server request happens in the background. If it fails, React automatically rolls back the optimistic state to the real server state.

I use optimistic updates for any action where:

  • The success rate is above 99 percent (network issues aside).
  • The visual feedback matters for perceived performance.
  • The rollback is not confusing to the user.

Toggling favorites, liking content, marking items as read: all good candidates. Submitting a payment, deleting an account, placing an order: not candidates. Those need confirmed server responses before updating the UI.


File Uploads

File uploads with Server Actions are straightforward because FormData natively supports File objects. Here is the pattern I use for uploading profile images:

tsx
// actions/upload.ts
"use server";

import { writeFile } from "fs/promises";
import { join } from "path";
import { randomUUID } from "crypto";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];

export type UploadResult = {
  success: boolean;
  message: string;
  url?: string;
};

export async function uploadAvatar(
  prevState: UploadResult,
  formData: FormData
): Promise<UploadResult> {
  const session = await auth();
  if (!session?.user) {
    return { success: false, message: "Please sign in to upload." };
  }

  const file = formData.get("avatar") as File | null;

  if (!file || file.size === 0) {
    return { success: false, message: "Please select a file." };
  }

  if (!ALLOWED_TYPES.includes(file.type)) {
    return {
      success: false,
      message: "Only JPEG, PNG, and WebP images are allowed.",
    };
  }

  if (file.size > MAX_FILE_SIZE) {
    return { success: false, message: "File must be under 5MB." };
  }

  const extension = file.name.split(".").pop();
  const fileName = `${randomUUID()}.${extension}`;
  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  const uploadDir = join(process.cwd(), "public", "uploads", "avatars");
  const filePath = join(uploadDir, fileName);
  await writeFile(filePath, buffer);

  const url = `/uploads/avatars/${fileName}`;

  await db.user.update({
    where: { id: session.user.id },
    data: { avatar: url },
  });

  revalidatePath("/profile");

  return {
    success: true,
    message: "Avatar updated!",
    url,
  };
}
tsx
// components/avatar-upload.tsx
"use client";

import { useActionState, useRef } from "react";
import { uploadAvatar, type UploadResult } from "@/actions/upload";

const initialState: UploadResult = {
  success: false,
  message: "",
};

export function AvatarUpload() {
  const [state, formAction, isPending] = useActionState(
    uploadAvatar,
    initialState
  );
  const formRef = useRef<HTMLFormElement>(null);

  return (
    <form ref={formRef} action={formAction}>
      <input
        type="file"
        name="avatar"
        accept="image/jpeg,image/png,image/webp"
      />

      <button type="submit" disabled={isPending}>
        {isPending ? "Uploading..." : "Upload Avatar"}
      </button>

      {state.message && (
        <p className={state.success ? "text-green-500" : "text-red-500"}>
          {state.message}
        </p>
      )}
    </form>
  );
}

In production, I rarely write to the local filesystem. I upload to Supabase Storage or Cloudflare R2 and store the URL. The validation pattern stays the same. Validate type and size on the server, generate a unique filename, upload, save the URL to the database.

One thing to watch for: Next.js has a default body size limit for Server Actions. For file uploads, you may need to increase it in next.config.ts:

tsx
// next.config.ts
import type { NextConfig } from "next";

const config: NextConfig = {
  experimental: {
    serverActions: {
      bodySizeLimit: "10mb",
    },
  },
};

export default config;

Server Actions vs API Routes: When to Use Each

This is the question I get asked most. Here is my decision framework after using both extensively in production.

Use Server Actions when:

  • You are handling form submissions (contact forms, order forms, profile updates).
  • You are mutating data from a React component (create, update, delete operations).
  • You want progressive enhancement (form works without JS).
  • The caller is your own Next.js application.
  • You need revalidatePath or revalidateTag after the mutation.

Use API Routes when:

  • External services need to call your endpoint (webhooks from Stripe, GitHub, etc.).
  • You are building a public API that other applications consume.
  • You need fine-grained control over HTTP headers, status codes, or streaming responses.
  • You are handling authentication callbacks (OAuth redirects).
  • Mobile apps or other non-Next.js clients need to interact with your backend.

On EuroParts Lanka, Server Actions handle every user-facing form: contact inquiries, part requests, quote submissions. API routes handle the Stripe webhook that processes payment confirmations and the internal endpoint that the inventory management system calls.

On iamuvin.com, the contact form uses a Server Action exclusively. There is no API route for it because no external service needs to call it. The only caller is the React form component.

The pattern I have settled on:

actions/           Server Actions (form submissions, mutations)
app/api/           API Routes (webhooks, external APIs, auth callbacks)

Clean separation. No overlap. Every form uses a Server Action. Every external integration uses an API route.


Testing Server Actions

Server Actions are just async functions, which makes them straightforward to test. Here is how I test the contact form action:

tsx
// __tests__/actions/contact.test.ts
import { describe, it, expect, vi } from "vitest";
import { submitContactForm } from "@/actions/contact";

function createFormData(data: Record<string, string>): FormData {
  const formData = new FormData();
  Object.entries(data).forEach(([key, value]) => {
    formData.append(key, value);
  });
  return formData;
}

const initialState = { success: false, message: "" };

describe("submitContactForm", () => {
  it("returns validation errors for empty fields", async () => {
    const formData = createFormData({
      name: "",
      email: "",
      message: "",
    });

    const result = await submitContactForm(initialState, formData);

    expect(result.success).toBe(false);
    expect(result.errors?.name).toBeDefined();
    expect(result.errors?.email).toBeDefined();
    expect(result.errors?.message).toBeDefined();
  });

  it("returns validation error for invalid email", async () => {
    const formData = createFormData({
      name: "Test User",
      email: "not-an-email",
      message: "This is a valid message with enough characters.",
    });

    const result = await submitContactForm(initialState, formData);

    expect(result.success).toBe(false);
    expect(result.errors?.email).toBeDefined();
  });

  it("creates contact on valid submission", async () => {
    const formData = createFormData({
      name: "Test User",
      email: "test@example.com",
      message: "This is a valid test message for the contact form.",
    });

    const result = await submitContactForm(initialState, formData);

    expect(result.success).toBe(true);
    expect(result.message).toContain("Thank you");
  });
});

The key insight is that you test Server Actions the same way you test any other function. Create a FormData object, call the function, assert on the return value. No HTTP mocking. No request/response objects. Just function calls.

For integration tests with Playwright, I test the full form flow:

tsx
// e2e/contact.spec.ts
import { test, expect } from "@playwright/test";

test("contact form submits successfully", async ({ page }) => {
  await page.goto("/contact");

  await page.getByLabel("Name").fill("Playwright Test");
  await page.getByLabel("Email").fill("test@example.com");
  await page.getByLabel("Message").fill(
    "This is an automated test submission from Playwright."
  );

  await page.getByRole("button", { name: "Send Message" }).click();

  await expect(
    page.getByText("Thank you")
  ).toBeVisible({ timeout: 10000 });
});

test("contact form shows validation errors", async ({ page }) => {
  await page.goto("/contact");

  await page.getByLabel("Name").fill("A");
  await page.getByLabel("Email").fill("invalid");
  await page.getByLabel("Message").fill("Short");

  await page.getByRole("button", { name: "Send Message" }).click();

  await expect(
    page.getByText("at least 2 characters")
  ).toBeVisible();
});

I run unit tests on every commit and Playwright tests on every PR. The Server Action tests catch validation regressions instantly because they execute the actual function with real Zod schemas.


My Form Architecture

After building dozens of production forms, I have settled on a consistent architecture that I apply to every project. Here is the full structure:

lib/
  validations/
    contact.ts         # Zod schemas (shared between client and server)
    order.ts
    profile.ts

actions/
  contact.ts           # Server Actions
  orders.ts
  profile.ts

components/
  forms/
    contact-form.tsx   # Client components with useActionState
    order-form.tsx
    profile-form.tsx
  ui/
    submit-button.tsx  # Reusable useFormStatus button
    form-field.tsx     # Label + input + error display
    form-message.tsx   # Success/error banner

The Zod schemas live in lib/validations/ because I share them between the Server Action (runtime validation) and the client form (optional client-side pre-validation for better UX). One schema, used in two places, always in sync.

Here is the reusable FormField component that I copy into every project:

tsx
// components/ui/form-field.tsx
"use client";

interface FormFieldProps {
  label: string;
  name: string;
  type?: string;
  error?: string[];
  required?: boolean;
  placeholder?: string;
  multiline?: boolean;
}

export function FormField({
  label,
  name,
  type = "text",
  error,
  required = false,
  placeholder,
  multiline = false,
}: FormFieldProps) {
  const errorId = `${name}-error`;
  const inputProps = {
    id: name,
    name,
    required,
    placeholder,
    "aria-describedby": error ? errorId : undefined,
    className: `
      w-full px-4 py-3 rounded-lg border transition-colors duration-200
      ${error
        ? "border-red-500 focus:ring-red-500"
        : "border-gray-700 focus:ring-[#F7931A]"
      }
      bg-[#111827] text-white focus:outline-none focus:ring-2
    `,
  };

  return (
    <div className="space-y-1.5">
      <label htmlFor={name} className="block text-sm font-medium text-gray-300">
        {label}
        {required && <span className="text-[#F7931A] ml-1">*</span>}
      </label>

      {multiline ? (
        <textarea {...inputProps} rows={5} />
      ) : (
        <input type={type} {...inputProps} />
      )}

      {error && (
        <p id={errorId} className="text-sm text-red-400">
          {error[0]}
        </p>
      )}
    </div>
  );
}

This component handles the label, input, error display, accessibility attributes, and styling in one place. Every form field on every project uses it. Consistency across the board.

The full flow for building a new form in any project:

  1. Define the Zod schema in lib/validations/.
  2. Create the Server Action in actions/ that validates with the schema and performs the mutation.
  3. Build the client form component using useActionState, FormField, and SubmitButton.
  4. Write unit tests for the Server Action.
  5. Write a Playwright E2E test for the full form flow.

Five steps. Every form. No exceptions. This is the process that keeps my form code maintainable across projects from a one-page portfolio to a full e-commerce platform.


Key Takeaways

  1. Server Actions replace API routes for form submissions. Less boilerplate, progressive enhancement, direct integration with React's rendering model.
  2. Always validate with Zod on the server. Client-side validation is UX. Server-side validation is security. Do both.
  3. Use `useActionState` for form state management. It gives you previous state, the form action, and pending state in one hook.
  4. Extract `SubmitButton` with `useFormStatus` once. Reuse it everywhere. Consistent loading UX across your entire application.
  5. Return structured errors, do not throw for validation failures. Thrown errors hit error boundaries and replace your form. Return objects with error details instead.
  6. Use `useOptimistic` for instant feedback on high-confidence actions. Favorites, likes, toggles. Not for payments or deletions.
  7. File uploads work natively with `FormData`. Validate type and size on the server. Increase bodySizeLimit in your Next.js config if needed.
  8. Server Actions for your app, API routes for the outside world. Forms and mutations go through Server Actions. Webhooks, external APIs, and OAuth callbacks go through API routes.
  9. Test Server Actions like regular functions. Create FormData, call the function, assert on the return value. No HTTP mocking required.
  10. Establish a consistent form architecture and use it on every project. Schemas in lib/validations/, actions in actions/, form components in components/forms/. Same structure, every time.

Server Actions have fundamentally changed how I build forms in Next.js. The combination of server-side execution, progressive enhancement, and tight React integration makes them the best option for form handling in the React ecosystem today. I use them on every single project, and I have not written a /api/contact route handler in over a year.

If you are building a project that needs production-grade forms, check out my services -- I have shipped this architecture across industries from auto parts to fintech to portfolio sites.


*Built by Uvin Vindula -- Web3/AI engineer based between Sri Lanka and the UK. I write about the patterns I actually use in production, not the ones I read about in docs. Follow me at @IAMUVIN for daily insights on Next.js, Web3, and building for the modern web.*

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.