Back to Blog

React Server Actions: Form Handling, Optimistic UI, and Progressive Enhancement

Master React Server Actions in Next.js: handle forms without API routes, implement optimistic UI with useOptimistic, add progressive enhancement, validate with Zod, and handle errors correctly.

Viprasol Tech Team
September 27, 2026
13 min read

React Server Actions let you run server-side code directly from a form submit or button click โ€” no API route required. The code executes on the server, the client gets the result, and you never expose implementation details in the browser.

Beyond convenience, Server Actions enable progressive enhancement: forms work without JavaScript, and enhance to optimistic UI when JavaScript is available. That's the same pattern native HTML forms have always used, but with React's component model.


Basic Server Action

// src/app/projects/actions.ts
"use server"; // Everything in this file runs on the server

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "@/db";
import { getUserFromSession } from "@/lib/auth";

const createProjectSchema = z.object({
  name: z.string().trim().min(1, "Name is required").max(100),
  description: z.string().max(500).optional(),
  visibility: z.enum(["private", "team", "public"]).default("private"),
});

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

export async function createProject(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  // 1. Authenticate
  const user = await getUserFromSession();
  if (!user) {
    return { success: false, message: "You must be logged in" };
  }

  // 2. Parse and validate
  const rawData = {
    name: formData.get("name"),
    description: formData.get("description"),
    visibility: formData.get("visibility"),
  };

  const parsed = createProjectSchema.safeParse(rawData);
  if (!parsed.success) {
    return {
      success: false,
      message: "Validation failed",
      errors: parsed.error.flatten().fieldErrors,
    };
  }

  // 3. Business logic
  const { data } = parsed;

  try {
    const { rows } = await db.query<{ id: string }>(
      `INSERT INTO projects (name, description, visibility, owner_id, tenant_id)
       VALUES ($1, $2, $3, $4, $5)
       RETURNING id`,
      [data.name, data.description, data.visibility, user.id, user.tenantId]
    );

    const projectId = rows[0].id;

    // 4. Revalidate cached data
    revalidatePath("/projects");

    // 5. Return success (or redirect)
    return { success: true, data: { projectId } };
  } catch (error) {
    console.error("Failed to create project:", error);
    return { success: false, message: "Failed to create project. Please try again." };
  }
}

useActionState for Form State

useActionState (formerly useFormState) manages the state from a Server Action:

// src/app/projects/new/page.tsx
"use client";

import { useActionState } from "react";
import { createProject, type ActionState } from "../actions";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

const initialState: ActionState = { success: false };

export default function NewProjectPage() {
  const router = useRouter();
  const [state, formAction, isPending] = useActionState(
    createProject,
    initialState
  );

  // Redirect on success
  useEffect(() => {
    if (state.success && state.data?.projectId) {
      router.push(`/projects/${state.data.projectId}`);
    }
  }, [state, router]);

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="name" className="block text-sm font-medium">
          Project name
        </label>
        <input
          id="name"
          name="name"
          type="text"
          required
          className={`mt-1 block w-full rounded border px-3 py-2 ${
            state.errors?.name ? "border-red-500" : "border-gray-300"
          }`}
          aria-describedby={state.errors?.name ? "name-error" : undefined}
        />
        {state.errors?.name && (
          <p id="name-error" className="mt-1 text-sm text-red-600">
            {state.errors.name[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="description" className="block text-sm font-medium">
          Description (optional)
        </label>
        <textarea
          id="description"
          name="description"
          rows={3}
          className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="visibility" className="block text-sm font-medium">
          Visibility
        </label>
        <select
          id="visibility"
          name="visibility"
          className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
        >
          <option value="private">Private</option>
          <option value="team">Team</option>
          <option value="public">Public</option>
        </select>
      </div>

      {/* General error message */}
      {!state.success && state.message && !state.errors && (
        <p className="text-sm text-red-600">{state.message}</p>
      )}

      <button
        type="submit"
        disabled={isPending}
        className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
      >
        {isPending ? "Creating..." : "Create Project"}
      </button>
    </form>
  );
}

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Optimistic UI with useOptimistic

useOptimistic updates the UI immediately before the server responds, then reconciles:

// src/components/TodoList.tsx
"use client";

import { useOptimistic, useTransition } from "react";
import { toggleTodo, deleteTodo } from "@/app/todos/actions";

interface Todo {
  id: string;
  title: string;
  completed: boolean;
  isPending?: boolean;
}

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [isPending, startTransition] = useTransition();

  // Optimistic state โ€” shows expected state before server confirms
  const [optimisticTodos, updateOptimisticTodos] = useOptimistic(
    initialTodos,
    (currentTodos: Todo[], action: { type: "toggle" | "delete"; id: string }) => {
      if (action.type === "toggle") {
        return currentTodos.map((todo) =>
          todo.id === action.id
            ? { ...todo, completed: !todo.completed, isPending: true }
            : todo
        );
      }
      if (action.type === "delete") {
        return currentTodos.filter((todo) => todo.id !== action.id);
      }
      return currentTodos;
    }
  );

  function handleToggle(todoId: string) {
    startTransition(async () => {
      // Update UI immediately
      updateOptimisticTodos({ type: "toggle", id: todoId });
      // Then run Server Action (may fail โ€” UI will revert on error)
      await toggleTodo(todoId);
    });
  }

  function handleDelete(todoId: string) {
    startTransition(async () => {
      updateOptimisticTodos({ type: "delete", id: todoId });
      await deleteTodo(todoId);
    });
  }

  return (
    <ul className="space-y-2">
      {optimisticTodos.map((todo) => (
        <li
          key={todo.id}
          className={`flex items-center gap-3 ${todo.isPending ? "opacity-70" : ""}`}
        >
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => handleToggle(todo.id)}
            className="h-4 w-4"
          />
          <span
            className={todo.completed ? "line-through text-gray-400" : ""}
          >
            {todo.title}
          </span>
          {todo.isPending && (
            <span className="text-xs text-gray-400">Saving...</span>
          )}
          <button
            onClick={() => handleDelete(todo.id)}
            className="ml-auto text-red-500 text-sm"
          >
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

Progressive Enhancement

Server Actions work without JavaScript โ€” the form submits naturally. Add JavaScript for better UX:

// src/components/SubscribeForm.tsx
"use client";

import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions";

// This form works with AND without JavaScript:
// Without JS: Standard HTML form POST, page reloads with success/error
// With JS: useActionState intercepts, no page reload, better UX

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

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

  if (state.success) {
    return (
      <div className="text-green-600 font-medium">
        โœ“ {state.message ?? "You're subscribed!"}
      </div>
    );
  }

  return (
    <form action={formAction} className="flex gap-2">
      <input
        type="email"
        name="email"
        placeholder="you@example.com"
        required
        className="flex-1 rounded border px-3 py-2"
      />
      {/* useFormStatus in a submit button gives pending state */}
      <SubmitButton isPending={isPending} />
      {state.message && !state.success && (
        <p className="text-sm text-red-600 mt-1">{state.message}</p>
      )}
    </form>
  );
}

// Separate component so useFormStatus works correctly
function SubmitButton({ isPending }: { isPending: boolean }) {
  return (
    <button
      type="submit"
      disabled={isPending}
      className="rounded bg-blue-600 px-4 py-2 text-white text-sm disabled:opacity-50"
    >
      {isPending ? "Subscribing..." : "Subscribe"}
    </button>
  );
}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

File Upload with Server Actions

// src/app/profile/actions.ts
"use server";

import { put } from "@vercel/blob";
import sharp from "sharp";

export async function uploadAvatar(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  const user = await getUserFromSession();
  if (!user) return { success: false, message: "Unauthorized" };

  const file = formData.get("avatar") as File | null;
  if (!file || file.size === 0) {
    return { success: false, errors: { avatar: ["Please select an image"] } };
  }

  // Validate file type
  if (!file.type.startsWith("image/")) {
    return { success: false, errors: { avatar: ["File must be an image"] } };
  }

  // Validate size (max 5MB)
  if (file.size > 5 * 1024 * 1024) {
    return { success: false, errors: { avatar: ["Image must be under 5MB"] } };
  }

  // Process: resize and convert to WebP
  const buffer = Buffer.from(await file.arrayBuffer());
  const processed = await sharp(buffer)
    .resize(200, 200, { fit: "cover", position: "center" })
    .webp({ quality: 85 })
    .toBuffer();

  // Upload to blob storage
  const { url } = await put(
    `avatars/${user.id}.webp`,
    processed,
    {
      access: "public",
      contentType: "image/webp",
      addRandomSuffix: false, // Deterministic URL โ€” overwrites previous avatar
    }
  );

  // Update user record
  await db.query(
    "UPDATE users SET avatar_url = $1 WHERE id = $2",
    [url, user.id]
  );

  revalidatePath("/profile");

  return { success: true, message: "Avatar updated successfully" };
}

Error Handling Patterns

// src/app/actions.ts
"use server";

// Pattern 1: Return error state (for form validation)
export async function updateProfile(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  try {
    // ... action logic
    return { success: true };
  } catch (error) {
    // Don't throw from Server Actions โ€” return error state
    // Throwing would cause an unhandled error boundary
    console.error(error);
    return { success: false, message: "Something went wrong. Please try again." };
  }
}

// Pattern 2: Throw for redirects and permanent failures
// (Next.js redirect() internally throws โ€” don't catch it)
export async function deleteAccount(formData: FormData) {
  const user = await getUserFromSession();
  if (!user) throw new Error("Unauthorized"); // Shows error boundary

  await permanentlyDeleteUser(user.id);
  redirect("/goodbye"); // Throws internally โ€” must not be in try/catch
}

// Pattern 3: Revalidate after mutations
export async function updateProjectName(
  projectId: string,
  name: string
): Promise<void> {
  // Can be called directly (not via form) โ€” no FormData needed
  await db.query("UPDATE projects SET name = $1 WHERE id = $2", [name, projectId]);

  // Revalidate specific paths
  revalidatePath(`/projects/${projectId}`);
  revalidatePath("/projects");

  // Or revalidate by cache tag
  revalidateTag("projects");
}

When to Use Server Actions vs API Routes

ScenarioServer ActionsAPI Routes
Form submissionsโœ… IdealPossible but verbose
Progressive enhancementโœ… Built-inโŒ Manual
Optimistic UIโœ… useOptimisticPossible via React Query
Third-party API consumptionโœ… Fineโœ… Fine
Webhook endpointโŒ Can't set headersโœ… Required
Streaming responseโŒ Limitedโœ… Better
Mobile app APIโŒ Not appropriateโœ… Required
File downloadโŒโœ… Required
Cross-origin requestsโŒโœ… CORS support

See Also


Working With Viprasol

Server Actions simplify the full-stack development model significantly โ€” fewer API routes, built-in progressive enhancement, and type safety from form to database. Our Next.js engineers design Server Action patterns that handle file uploads, optimistic UI, validation, and error recovery correctly from the start.

Web development services โ†’ | Start a project โ†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.