•7 min read

Handling Forms in Next.js 14: Server Actions vs Client Components

Handling Forms in Next.js 14: Server Actions vs Client Components

In the rapidly evolving ecosystem of Next.js, form handling has seen a profound paradigm shift. With the stable release of Next.js 14, the days of writing verbose client-side form handlers, managing excessive loading states, and wrestling with traditional API routes are over. Server Actions, combined with React's modern hooks (useFormState and useFormStatus), offer a deeply integrated, progressively enhanced approach to building robust forms.

When paired with schema validation libraries like Zod, developers can now enforce strict type safety across the client-server boundary. In this comprehensive guide, we'll explore how to handle forms in Next.js 14, comparing traditional Client Components with the modern Server Actions approach.

Audio Briefing
0:00 / 0:00

The Old Paradigm: Client-Side Form Handling

Before Next.js 14 and the maturation of the App Router, form handling was predominantly a client-side affair. Developers relied heavily on React state (useState) or dedicated libraries like React Hook Form or Formik. The typical flow involved:

  1. Preventing the default form submission event.
  2. Collecting and parsing form data.
  3. Sending an asynchronous fetch request to an API route.
  4. Managing isLoading, isError, and isSuccess states manually.
  5. Updating the UI based on the API response.

While this approach works, it results in a significant amount of boilerplate code and pushes more JavaScript to the client. Here is what a typical client-side form looked like:

'use client';

import { useState } from 'react';

export default function TraditionalForm() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);
    setError('');

    const formData = new FormData(e.currentTarget);
    const data = Object.fromEntries(formData);

    try {
      const res = await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(data),
      });

      if (!res.ok) throw new Error('Submission failed');
      // Handle success
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="title" required />
      <button type="submit" disabled={loading}>
        {loading ? 'Submitting...' : 'Submit'}
      </button>
      {error && <p>{error}</p>}
    </form>
  );
}

This pattern is effective but redundant. It requires an entirely separate API route and forces the component to be marked with 'use client', increasing the client bundle size.

Advertisement

The New Standard: Next.js 14 Server Actions

Server Actions flip this paradigm by allowing you to define asynchronous server functions directly within your React components (or in separate files) that can be invoked directly from a <form> element's action attribute.

What are Server Actions?

At their core, Server Actions are asynchronous functions executed on the server. They integrate seamlessly with Next.js caching and revalidation systems, meaning you can mutate data and immediately update the UI without writing manual state invalidation logic.

Let's look at how we can refactor the previous example using Server Actions:

import { revalidatePath } from 'next/cache';

// This function runs exclusively on the server
async function submitData(formData) {
  'use server';
  
  const title = formData.get('title');
  const content = formData.get('content');

  // Perform database mutation here
  // await db.posts.create({ title, content });

  // Revalidate the cache to show the new post
  revalidatePath('/posts');
}

export default function ServerActionForm() {
  return (
    <form action={submitData}>
      <input type="text" name="title" required />
      <textarea name="content" required />
      <button type="submit">Submit</button>
    </form>
  );
}

Notice the absence of 'use client', useState, and fetch. This form works even if the user has disabled JavaScript in their browser, providing out-of-the-box Progressive Enhancement. But what about loading states and validation errors? That's where React's new hooks and Zod come into play.

Managing State with useFormState and useFormStatus

To create a rich user experience, we still need to provide feedback during form submission. React introduced two new hooks specifically designed for forms that use Server Actions.

useFormStatus

useFormStatus provides status information of the last form submission. It must be used in a component rendered inside a <form>.

'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending} className="bg-blue-600 text-white px-4 py-2 rounded">
      {pending ? 'Submitting...' : 'Save Post'}
    </button>
  );
}

useFormState

useFormState allows you to update state based on the result of a Server Action. It's perfect for handling validation errors and success messages. Let's combine useFormState with Zod for robust, schema-driven validation.

Robust Validation with Zod

Zod is a TypeScript-first schema declaration and validation library. It is incredibly powerful when paired with Server Actions to ensure the data reaching your database is exactly what you expect.

First, define your schema and the shape of your form state:

import { z } from 'zod';

const postSchema = z.object({
  title: z.string().min(3, 'Title must be at least 3 characters'),
  content: z.string().min(10, 'Content must be at least 10 characters'),
});

Next, create the Server Action. It will use Zod's safeParse to validate the incoming FormData.

'use server';

import { revalidatePath } from 'next/cache';
// Assume postSchema is imported here

export async function createPost(prevState, formData) {
  // Validate form fields using Zod
  const validatedFields = postSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  });

  // If form validation fails, return errors early
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing Fields. Failed to Create Post.',
    };
  }

  // Mutate data
  try {
    // await db.posts.create(validatedFields.data);
    
    revalidatePath('/posts');
    return { message: 'Post created successfully!', errors: {} };
  } catch (error) {
    return { message: 'Database Error: Failed to Create Post.', errors: {} };
  }
}

Finally, wire it all together in a Client Component. While the form rendering and state management happen on the client, the actual execution and validation remain securely on the server.

'use client';

import { useFormState } from 'react-dom';
import { createPost } from '@/app/actions';
import { SubmitButton } from './SubmitButton';

export default function CreatePostForm() {
  const initialState = { message: null, errors: {} };
  const [state, dispatch] = useFormState(createPost, initialState);

  return (
    <form action={dispatch} className="flex flex-col gap-4 max-w-md">
      <div>
        <label htmlFor="title">Title</label>
        <input id="title" name="title" type="text" className="border p-2 w-full" />
        {state.errors?.title && (
          <p className="text-red-500 text-sm mt-1">{state.errors.title[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="content">Content</label>
        <textarea id="content" name="content" className="border p-2 w-full" />
        {state.errors?.content && (
          <p className="text-red-500 text-sm mt-1">{state.errors.content[0]}</p>
        )}
      </div>

      <SubmitButton />

      {state.message && (
        <p className="text-sm text-gray-700 bg-gray-100 p-3 rounded" aria-live="polite">
          {state.message}
        </p>
      )}
    </form>
  );
}
Advertisement

Client Components vs Server Actions: When to use what?

While Server Actions represent the future of Next.js data mutations, traditional Client Components still have their place in complex applications.

Use Server Actions When:

  • You need to mutate data securely (database interactions, API calls with hidden secrets).
  • You want progressive enhancement (forms that work without JavaScript enabled).
  • You want to reduce client-side bundle size.
  • You want tightly coupled cache revalidation (using revalidatePath or revalidateTag).

Use Client-Side Handling When:

  • You require complex, multi-step wizards where state needs to persist across steps without hitting the server continuously.
  • You need real-time, keystroke-by-keystroke validation before submission.
  • You are heavily dependent on complex third-party client-side libraries that assume browser environments and require local state integration.

Conclusion

Next.js 14 has dramatically simplified the developer experience around form handling. By embracing Server Actions, useFormState, useFormStatus, and Zod, you can build highly resilient, performant, and type-safe forms. This architecture not only reduces the cognitive load of managing arbitrary loading and error states manually but also enforces a cleaner separation of concerns between client-side rendering and server-side execution.

As the React ecosystem continues to evolve, standardizing on these native APIs will ensure your applications remain robust, accessible, and ready for the future of the web. Moving away from the heavy-handed approaches of the past towards these progressive-enhancement capable methods is a crucial step for any modern web developer.

You Might Also Like

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement