Back to Blog
Tutorial 10 min read

Zero-Leak Forms with Next.js Server Actions

S
Sarah ChenStaff Engineer, Forge · Published November 2, 2025

The standard approach to form submission in Next.js sends data from the client to an API route. This means your API keys might exist in the client bundle — a serious security risk. Server Actions eliminate this entirely: the submission handler runs on the server, your API key never touches the browser.

The Problem with Client-Side API Calls

When you POST to the Forge API from React (client-side), your FORGE_API_KEY must be available in the browser to make the authenticated request. If you use the NEXT_PUBLIC_ prefix, it's exposed in the compiled bundle. Even without the prefix, rotating keys becomes painful.

Server Actions to the Rescue

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

import { z } from "zod";

const contactSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
  message: z.string().min(10),
});

export async function submitContact(formData: FormData) {
  const parsed = contactSchema.safeParse({
    email: formData.get("email"),
    name: formData.get("name"),
    message: formData.get("message"),
  });

  if (!parsed.success) {
    return { error: "Invalid form data" };
  }

  const res = await fetch(
    `https://api.useforge.cloud/f/${process.env.FORGE_FORM_ID}/submit`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        // FORGE_API_KEY is a server-only env var — never in the client bundle
        Authorization: `Bearer ${process.env.FORGE_API_KEY}`,
      },
      body: JSON.stringify(parsed.data),
    }
  );

  if (!res.ok) return { error: "Submission failed. Please try again." };
  return { success: true };
}

The Client Component

// components/ContactForm.tsx
"use client";

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

export function ContactForm() {
  const [state, action, isPending] = useActionState(submitContact, null);

  if (state?.success) {
    return <p className="text-green-600">Thanks! We'll be in touch soon.</p>;
  }

  return (
    <form action={action}>
      <input name="email" type="email" placeholder="Work email" required />
      <input name="name" placeholder="Full name" required />
      <textarea name="message" rows={4} required />
      {state?.error && <p className="text-red-500">{state.error}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? "Sending..." : "Send message"}
      </button>
    </form>
  );
}

Why This Architecture Wins

  • Zero API key exposure: Server Actions run on the server. Period.
  • Progressive enhancement: Forms work without JavaScript because Server Actions support native HTML form POST
  • Smaller client bundle: No fetch wrappers or API client code shipped to the browser
  • Type safety end-to-end: TypeScript flows from your Zod schema through the action return type to the client state

Adding Webhook Verification Server-Side

Incoming Forge webhooks should also be handled in a Server Action or Route Handler. See our webhook security guide for the full HMAC verification implementation.

Read the full Next.js integration guide or start building free.

Ready to build?

Create your free Forge account. 4 forms, 100 submissions/month, forever free.

Start Free