Blog Post
6 min read

Edge + Server Actions: Shipping Faster with Less Client JavaScript

Client bundles bloat when every mutation lives in the browser.

Published on March 26, 2026

Client bundles bloat when every mutation lives in the browser.

You write a form, and suddenly you are shipping validation, error handling, retries, API logic, and state management—all to the browser. The bundle grows. Hydration slows. The page feels sluggy on slow networks.

Server actions flip the script: mutations live where they belong—on the server, close to your database and secrets.

When to Move Logic Server-Side

Not every form submission needs client-side magic.

Simple Form Submissions (No Optimistic UI)

If the form is straightforward and you do not need to update the UI before the server responds, use a server action:

"use server";

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

  // Validate
  if (!name || !email || !message) {
    throw new Error("Missing required fields");
  }

  // Send email (secrets safe on server)
  await sendEmail({
    to: "contact@example.com",
    subject: `New message from ${name}`,
    body: message,
  });

  // Redirect or return success
  revalidatePath("/contact");
  redirect("/thank-you");
}

Client-side:

import { submitContactForm } from '@/app/actions';

export function ContactForm() {
  const [pending, setPending] = useState(false);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setPending(true);
    const formData = new FormData(e.currentTarget);
    try {
      await submitContactForm(formData);
    } catch (err) {
      console.error(err);
    } finally {
      setPending(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" required />
      <input name="email" required />
      <textarea name="message" required />
      <button type="submit" disabled={pending}>
        {pending ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}

No validation library in the browser. No error handling state. Just a form and a server action.

Workflows Requiring Secrets

API keys, database credentials, third-party webhooks—these must never touch the browser.

Server actions let you use secrets safely:

"use server";

export async function processPayment(amount: number) {
  // Stripe key is safe on the server
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

  const paymentIntent = await stripe.paymentIntents.create({
    amount: Math.round(amount * 100), // in cents
    currency: "usd",
  });

  return { clientSecret: paymentIntent.client_secret };
}

Heavy Transforms: PDF, Image Processing, Heavy Compute

If you need to generate a PDF, resize an image, or run a heavy computation, do it on the server:

"use server";

import sharp from "sharp";

export async function resizeImage(imageBuffer: Buffer) {
  // sharp is huge; keep it off the client
  const resized = await sharp(imageBuffer)
    .resize(1200, 600, { fit: "cover" })
    .webp({ quality: 80 })
    .toBuffer();

  return resized;
}

Edge Considerations

If you deploy to an edge platform (Vercel Edge Functions, Cloudflare Workers, AWS Lambda@Edge), keep server actions light:

Cold starts: If an edge function is unused for a while, it is "cold"—takes 50–500ms to boot. If you load a huge library, cold start is slow.

Keep dependencies small: Audit what you import. Does this edge function really need the entire ORM? Or can it use a simpler query client?

// ❌ Heavy
import prisma from '@/lib/prisma'; // Entire ORM
export async function getUser(id: string) { ... }

// ✅ Light for edge
import { query } from '@/lib/db-client'; // Lightweight query function
export async function getUser(id: string) { ... }

Cache immutable reads aggressively:

const response = await fetch("https://api.example.com/config", {
  // Cache for 1 week since config rarely changes
  next: { revalidate: 60 * 60 * 24 * 7 },
});

Revalidate mutations immediately:

"use server";

export async function updateConfig(newValue: string) {
  await db.config.update(newValue);
  revalidatePath("/settings"); // Regenerate settings page
  revalidateTag("config"); // Bust cache for any page tagged 'config'
}

Validate inputs at the edge: Reject invalid requests early, before they reach your database.

"use server";

import { z } from "zod";

const updateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
});

export async function updateUser(input: unknown) {
  const parsed = updateUserSchema.parse(input); // Throws if invalid
  // Safe to use parsed.name, parsed.email, ...
  await db.user.update(parsed);
}

State Strategy

Reads: Server Components or React Query

Server components are fast for reads. Use them when the data does not change often:

// Server component - no JavaScript in browser
export default async function Dashboard() {
  const data = await fetchDashboard();
  return <Dashboard data={data} />;
}

For data that the user might refetch or that changes in real time, use a client library like React Query:

'use client';

import { useQuery } from '@tanstack/react-query';

export function UserList() {
  const { data } = useQuery({
    queryKey: ['users'],
    queryFn: async () => {
      const res = await fetch('/api/users');
      return res.json();
    }
  });

  return <div>{data?.map(u => <div key={u.id}>{u.name}</div>)}</div>;
}

Writes: Server Actions

Mutations go in server actions:

"use server";

export async function deleteUser(userId: string) {
  await db.user.delete({ where: { id: userId } });
  revalidatePath("/users"); // Refresh the user list
}

Shared Schemas: One Source of Truth

Do not define validation twice—once on the client, once on the server.

Use Zod or Valibot to define a schema, then generate types for both:

// lib/schemas.ts
import { z } from "zod";

export const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(["user", "admin"]),
});

export type CreateUserInput = z.infer<typeof createUserSchema>;

Client:

'use client';

import { createUserSchema } from '@/lib/schemas';

export function CreateUserForm() {
  const [error, setError] = useState('');

  async function handleSubmit(formData: FormData) {
    const input = Object.fromEntries(formData);
    const result = createUserSchema.safeParse(input);

    if (!result.success) {
      setError(result.error.message);
      return;
    }

    // Call server action
    try {
      await createUser(result.data);
    } catch (err) {
      setError(err.message);
    }
  }

  return <form onSubmit={handleSubmit}>...</form>;
}

Server:

"use server";

import { createUserSchema } from "@/lib/schemas";

export async function createUser(input: unknown) {
  const parsed = createUserSchema.parse(input); // Validates again on server
  await db.user.create(parsed);
}

Optimistic Updates: When to Skip Them

Optimistic updates (updating the UI before the server confirms) are great for fast feedback. But they add complexity.

Skip them if:

  • Latency is < 150ms. The server is fast enough that the delay is imperceptible.
  • Rollback is complex (if the server rejects, you have to undo client-side state changes).
  • The action is rare or non-critical.

Use them for:

  • Form submissions with > 150ms latency.
  • Frequent actions like liking, toggling, or sorting (users expect instant feedback).

Example with optimistic update:

'use client';

import { useOptimistic } from 'react';
import { toggleLike } from '@/app/actions';

export function LikeButton({ postId, initialLiked }) {
  const [liked, setLiked] = useOptimistic(initialLiked);

  async function handleToggle() {
    setLiked(!liked); // Update UI immediately
    try {
      await toggleLike(postId); // Confirm with server
    } catch (err) {
      setLiked(initialLiked); // Rollback if server fails
    }
  }

  return (
    <button onClick={handleToggle}>
      {liked ? '❤️' : '🤍'}
    </button>
  );
}

The Result

With server actions and edge deployment:

  • Smaller client bundle: No validation, no secrets, no heavy transforms.
  • Faster interactivity: Less JavaScript to parse and execute.
  • Better security: Secrets and logic stay on the server.
  • Faster execution: Work happens close to your database (edge) or in a trusted environment.

Ship faster. Ship smaller. Ship safer.