Zero-Leak Forms with Next.js Server Actions
Next.js Server Actions let you run form submission logic entirely on the server — your API keys never appear in the browser bundle, you get progressive enhancement for free (forms work without JavaScript), and your client component shrinks because there's no fetch wrapper or API client code to ship. This guide shows the complete pattern: Zod validation in the action, Forge submission from the server, and a client component that consumes the action with full type safety.
1. Why Client-Side Form Submission Is a Security Risk
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.
2. The Server Action Pattern with Zod + Forge
// 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 };
}3. The Client Component — useActionState
// 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>
);
}4. Why This Architecture Wins
- Zero API key exposure: Server Actions run on the server. Your
FORGE_API_KEYnever touches the browser bundle. - Progressive enhancement: The
<form action={action}>pattern works without JavaScript via native HTML POST - Smaller client bundle: No fetch wrappers, no API client, no error-handling boilerplate shipped to the browser
- Type safety end-to-end: TypeScript flows from your Zod schema → action return type → client state — zero
any - No useEffect needed:
useActionStatehandles pending/error/success state natively
5. Handling File Uploads Server-Side
Server Actions also support file uploads via the native FormData API. Call formData.get("file") to get the File object, then upload to your storage provider before submitting the metadata to Forge:
"use server";
export async function submitWithAttachment(formData: FormData) {
const file = formData.get("attachment") as File | null;
let attachmentUrl: string | undefined;
if (file && file.size > 0) {
// Upload to your storage provider (Supabase Storage, S3, Cloudflare R2)
const buffer = await file.arrayBuffer();
attachmentUrl = await uploadToStorage(Buffer.from(buffer), file.name);
}
await fetch(`https://api.useforge.cloud/f/${process.env.FORGE_FORM_ID}/submit`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.FORGE_API_KEY}`,
},
body: JSON.stringify({
name: formData.get("name"),
email: formData.get("email"),
attachment_url: attachmentUrl,
}),
});
return { success: true };
}6. Incoming Webhooks — Also Handle Server-Side
Incoming Forge webhooks should be handled in a Route Handler (not a Server Action — webhooks are external POST requests, not form submits). See our webhook security guide for the full HMAC verification and idempotency implementation.
Zero-exposure forms in your Next.js app
Forge + Server Actions: your API key stays on the server, your form works without JavaScript, your bundle stays small.
Ready to build?
Create your free Forge account. 4 forms, 100 submissions/month, forever free.
Start Free