Formclick

How to Handle Form Submissions in Next.js

Three ways to handle forms in Next.js — a client component posting to a form backend, Server Actions, and Route Handlers — with code and guidance on when to use each.

FFormclick Team4 min read
A close-up of a laptop displaying code in a dimly lit room with a coffee mug nearby.
Photo by Daniil Komov on Pexels
On this page
  1. Approach 1: Post to a form backend from a client component
  2. Approach 2: Server Actions
  3. Approach 3: Route Handlers (API endpoints)
  4. Which should you choose?
  5. Common pitfalls

Next.js gives you several ways to handle a form, and choosing between them is the confusing part. This guide covers the three main approaches in the App Router, with working code and a clear recommendation for each situation.

Approach 1: Post to a form backend from a client component

Best when you want submissions emailed and stored without writing backend code, or your site is a static export (output: 'export'), where Server Actions and Route Handlers aren't available.

// app/contact/ContactForm.tsx
'use client';

import { useState } from 'react';

export default function ContactForm() {
  const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');

  async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = event.currentTarget;
    setStatus('sending');
    try {
      const res = await fetch('https://api.formclick.io/submit', {
        method: 'POST',
        headers: { Accept: 'application/json' },
        body: new FormData(form),
      });
      const result = await res.json();
      if (!res.ok || !result.success) throw new Error(result.message);
      form.reset();
      setStatus('sent');
    } catch {
      setStatus('error');
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <input type="hidden" name="access_key" value={process.env.NEXT_PUBLIC_FORMCLICK_KEY} />
      <input type="checkbox" name="botcheck" className="hidden" tabIndex={-1} autoComplete="off" />

      <label htmlFor="name">Name</label>
      <input id="name" name="name" required />

      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />

      <button type="submit" disabled={status === 'sending'}>
        {status === 'sending' ? 'Sending…' : 'Send'}
      </button>
      <p role="status">
        {status === 'sent' && 'Thanks! We will be in touch.'}
        {status === 'error' && 'Something went wrong. Please try again.'}
      </p>
    </form>
  );
}

Put the key in .env.local as NEXT_PUBLIC_FORMCLICK_KEY. It's fine for it to reach the browser — a Formclick access key can only submit to its own form, not read anything.

Pros: no server code, works with static exports, spam filtering, email notifications, dashboard and integrations included. Cons: an external service.

Approach 2: Server Actions

Server Actions let a form call a function that runs on the server. They work even before JavaScript loads (progressive enhancement), and pair nicely with useActionState for pending and result states.

// app/contact/actions.ts
'use server';

export type State = { ok: boolean; message: string } | null;

export async function sendMessage(_prev: State, formData: FormData): Promise<State> {
  const email = String(formData.get('email') ?? '').trim();
  const message = String(formData.get('message') ?? '').trim();

  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { ok: false, message: 'Enter a valid email.' };
  if (message.length < 10) return { ok: false, message: 'Your message is a little short.' };

  // Forward to Formclick from the server (or save to your database, call your CRM…)
  const res = await fetch('https://api.formclick.io/submit', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
    body: JSON.stringify({
      access_key: process.env.FORMCLICK_KEY,
      email,
      message,
      name: formData.get('name'),
    }),
  });
  const result = await res.json();
  return result.success
    ? { ok: true, message: 'Thanks! Message sent.' }
    : { ok: false, message: result.message || 'Please try again.' };
}
// app/contact/page.tsx
'use client';

import { useActionState } from 'react';
import { sendMessage } from './actions';

export default function ContactPage() {
  const [state, action, pending] = useActionState(sendMessage, null);
  return (
    <form action={action}>
      <input name="name" aria-label="Name" />
      <input name="email" type="email" required aria-label="Email" />
      <textarea name="message" required aria-label="Message" />
      <button disabled={pending}>{pending ? 'Sending…' : 'Send'}</button>
      {state && <p role="status">{state.message}</p>}
    </form>
  );
}

Pros: validation and secrets stay on the server; works without client JavaScript. Cons: needs a server runtime; you still need somewhere to send or store the data.

When a Server Action forwards submissions, every request comes from your server's IP address, so per-visitor protections such as rate limits see one visitor. Add your own spam checks (a honeypot, Turnstile verification) in the action.

Approach 3: Route Handlers (API endpoints)

Use a Route Handler when something other than your own pages submits data — a mobile app, a third-party widget, another site — or when you need a classic JSON API.

// app/api/contact/route.ts
export async function POST(request: Request) {
  const data = await request.json();
  if (!data.email || !data.message) {
    return Response.json({ success: false, message: 'Email and message are required.' }, { status: 400 });
  }
  // …store, email or forward…
  return Response.json({ success: true });
}

Pros: a reusable endpoint for any client. Cons: you build validation, spam protection, CORS and delivery yourself.

Which should you choose?

Situation Use
Marketing site, contact or lead form Client component → form backend
Static export (output: 'export') Client component → form backend
Logged-in app, data goes to your database Server Action
Need a public API for other clients Route Handler
Want server-side validation and email/dashboard without building them Server Action → form backend

Common pitfalls

  • Forgetting 'use client' on components that use state or event handlers.
  • Exposing secret keys — only NEXT_PUBLIC_ variables reach the browser; keep real secrets server-side.
  • Not handling errors — show the server's message when a submission fails.
  • Double submits — disable the button while sending. See How to Prevent Duplicate Form Submissions.

For more React patterns, read How to Integrate HTML Forms With React.

Found this useful? Share it.

All articles