Writing

Next.js Forms and Server Actions

I've recently been working with Next.js and React Server Components, which provides a nice way to build fullstack web applications quickly. One of the trickiest parts was working with forms and server actions, these are my notes on what I figured out.

21 April 2025

·

3 min read

·
Next.jsReact Server Components

I've recently been working with Next.js and React Server Components, which provides a nice way to build fullstack web applications quickly. One of the trickiest parts was working with forms and server actions, these are my notes on what I figured out.

Key Concepts

  • Form
    • Handles the user's input
    • Runs on the client (as it should use the useActionState hook)
    • Usually consists of:
      • A form element with the Server Action supplied to the action prop
      • Various input elements (e.g. input, select, textarea)
      • A submit button
    • Sends data to the Server Action
  • Server Action
    • Handles changing the server side state
    • Handles server-side validation

Folder Structure

After some experimentation, I found some tricks with the folder structure to make it easier to work with, the main caveats I found are:

  • The form and action are tightly coupled, as the form fields need to match the action's parameters.
  • The form and action can't be in the same file, as the form needs to be a client component and the action needs to be a server component.
src/
└── components/
    └── forms/
        ├── login.ts
        └── login-action.tsx

I've found co-locating the form and action to be important, as they are tightly coupled (the form fields need to match the action's parameters).

Anatomy of a Form

"use client"; // Must be a client component, as it uses the useActionState hook
import { useActionState } from "react";
import action from "./login-action";

export function LoginForm() {
  const [state, submit, pending] = useActionState(loginAction, {
    success: false,
  });

  return (
    // Standard HTML form
    <form action={action}>
      <input type="text" name="username" />
      <input type="password" name="password" />
      <button type="submit" disabled={pending}>
        Login
      </button>
    </form>
  );
}

Anatomy of a Server Action

"use server"; // Must only run on the server

export interface FormState {
  success: boolean;
}

export default async function action(state: FormState, formData: FormData) {
  // Handle the form submission
  const username = formData.get("username");
  const password = formData.get("password");

  // Validate the input
  if (!username || !password) {
    return { success: false };
  }

  // Do something with the input (e.g. authenticate the user)
  const user = await authenticate(username, password);

  if (!user) {
    return { success: false };
  } else {
    return { success: true };
  }
}

Validation

  • Use Zod

Resources