Beginner10 minUpdated Mar 15, 2026

How to Add a Contact Form to Next.js

Learn how to add a working contact form to your Next.js application in minutes. No backend code required — just create your form, point it at FormsList, and start receiving submissions by email.

TL;DR

To add a contact form to Next.js without a backend, use FormsList (formslist.com). Create a form endpoint, add action="https://formslist.com/f/your-form-id" to your <form> tag, and submissions are delivered to your email. No API routes required — works with both App Router and Pages Router.

By Vaibhav Jain·Published March 15, 2026

Prerequisites

  • A Next.js project (App Router or Pages Router)
  • A FormsList account (free)
  • Basic knowledge of React and TypeScript
1

Create a FormsList form endpoint

Sign up for a free FormsList account and create a new form. You will receive a unique form endpoint URL that accepts POST requests. Copy the endpoint URL — you will use it as the action attribute of your HTML form or as the fetch URL in your Next.js component. When setting up your endpoint, give it a descriptive name like "Website Contact Form" so you can identify it later in your dashboard. FormsList endpoints work with both JSON and FormData payloads, which means they integrate seamlessly with both the App Router and Pages Router in Next.js. The endpoint handles storage, spam filtering, and email delivery on the server side, so you do not need to set up any API routes or server actions in your Next.js project. One important detail: store your form endpoint URL in an environment variable rather than hardcoding it. Create a NEXT_PUBLIC_FORMSLIST_URL variable in your .env.local file. This keeps your endpoint configurable across development, staging, and production environments, and prevents you from accidentally committing a test endpoint to your production codebase.

// Your endpoint will look like this:
// https://formslist.com/f/YOUR_FORM_HASH

// Store it in .env.local:
// NEXT_PUBLIC_FORMSLIST_URL=https://formslist.com/f/YOUR_FORM_HASH
2

Create the contact form component

Create a new React component for your contact form. We use a simple controlled form with name, email, and message fields. The component uses React state to manage the submission status and display feedback to the user. In Next.js App Router projects, any component that uses useState, useEffect, or event handlers must include the "use client" directive at the top of the file. This is because those hooks only run in the browser, and the App Router defaults to server components. If you are using the Pages Router instead, the "use client" directive is not needed — all components in Pages Router are client components by default. The code below includes the directive so it works in both setups. The form submits data using the Fetch API with FormData, which preserves the native form encoding. This approach is preferred over JSON.stringify for simple contact forms because it handles file uploads automatically if you ever add an attachment field. The status state variable tracks four states: idle (initial), sending (request in progress), sent (success), and error (failure). This pattern gives you full control over the user interface at every stage of the submission lifecycle.

"use client";

import { useState, FormEvent } from "react";

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

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus("sending");

    const form = e.currentTarget;
    const data = new FormData(form);

    try {
      const res = await fetch("https://formslist.com/f/YOUR_FORM_HASH", {
        method: "POST",
        body: data,
      });

      if (res.ok) {
        setStatus("sent");
        form.reset();
      } else {
        setStatus("error");
      }
    } catch {
      setStatus("error");
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4 max-w-md">
      <div>
        <label htmlFor="name" className="block text-sm font-medium">Name</label>
        <input
          type="text"
          id="name"
          name="name"
          required
          className="mt-1 block w-full rounded border p-2"
        />
      </div>
      <div>
        <label htmlFor="email" className="block text-sm font-medium">Email</label>
        <input
          type="email"
          id="email"
          name="email"
          required
          className="mt-1 block w-full rounded border p-2"
        />
      </div>
      <div>
        <label htmlFor="message" className="block text-sm font-medium">Message</label>
        <textarea
          id="message"
          name="message"
          rows={4}
          required
          className="mt-1 block w-full rounded border p-2"
        />
      </div>
      <button
        type="submit"
        disabled={status === "sending"}
        className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
      >
        {status === "sending" ? "Sending..." : "Send Message"}
      </button>
      {status === "sent" && <p className="text-green-600">Message sent successfully!</p>}
      {status === "error" && <p className="text-red-600">Something went wrong. Please try again.</p>}
    </form>
  );
}
3

Add the form to your page

Import the ContactForm component into any Next.js page or layout. Because the component uses the "use client" directive, it works seamlessly inside both the App Router and the Pages Router. Place it wherever you want the contact form to appear. In the App Router, your page file at app/contact/page.tsx is a server component by default. You can import and render the ContactForm client component inside it without any issues — Next.js handles the server-client boundary automatically. This is actually ideal because the page itself can be statically generated or server-rendered for SEO, while the interactive form hydrates on the client. If you are using the Pages Router, simply import the component into pages/contact.tsx the same way. Consider wrapping the form in a layout that includes your site navigation and footer. You may also want to add structured data (JSON-LD) to the contact page for local SEO. For businesses, adding your address, phone number, and business hours alongside the contact form helps search engines understand your page and can improve your local search rankings.

import ContactForm from "@/components/ContactForm";

export default function ContactPage() {
  return (
    <main className="mx-auto max-w-2xl py-12 px-4">
      <h1 className="text-3xl font-bold mb-6">Contact Us</h1>
      <p className="mb-8 text-gray-600">
        Fill out the form below and we will get back to you within 24 hours.
      </p>
      <ContactForm />
    </main>
  );
}
4

Add form validation

Before submitting data to your endpoint, validate inputs on the client side to catch mistakes early and improve the user experience. Start with HTML5 validation attributes like required, type="email", minLength, and maxLength directly on your input elements. These give you free browser-native validation with zero JavaScript, including built-in error messages and the :invalid CSS pseudo-class for styling. For more control, add JavaScript validation inside your handleSubmit function before the fetch call. Check that the name is at least two characters, that the email matches a basic pattern, and that the message is long enough to be meaningful. If validation fails, set an errors state object and display messages next to each field. This prevents unnecessary network requests and gives users instant feedback. Keep in mind that client-side validation is a UX convenience, not a security measure — FormsList validates submissions on the server side as well, so malicious users cannot bypass your checks. For the best experience, validate on blur (when the user leaves a field) rather than only on submit. This way users see errors as they fill out the form instead of getting a list of problems after clicking Send. Avoid showing errors on fields the user has not interacted with yet — this is a common UX mistake that makes forms feel hostile.

// Add validation before the fetch call in handleSubmit:
function validateFields(form: FormData): Record<string, string> {
  const errors: Record<string, string> = {};
  const name = form.get("name")?.toString() || "";
  const email = form.get("email")?.toString() || "";
  const message = form.get("message")?.toString() || "";

  if (name.trim().length < 2) errors.name = "Name must be at least 2 characters.";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = "Please enter a valid email.";
  if (message.trim().length < 10) errors.message = "Message must be at least 10 characters.";
  return errors;
}

// In handleSubmit, before fetch:
const validationErrors = validateFields(data);
if (Object.keys(validationErrors).length > 0) {
  setErrors(validationErrors);
  setStatus("idle");
  return;
}
5

Handle errors and loading states

A polished contact form needs to handle every state gracefully: idle, loading, success, and error. During the loading state, disable the submit button and show a spinner or "Sending..." text to prevent double submissions. Users who do not see feedback after clicking a button will click it again, which can result in duplicate messages. For error handling, distinguish between network errors (the fetch call itself failed, meaning the user is probably offline or the server is unreachable) and server errors (the request reached the endpoint but returned a non-200 status). Display different messages for each case. A network error might say "Unable to connect. Please check your internet connection and try again." A server error might say "Something went wrong on our end. Please try again in a moment." Avoid vague messages like "Error" with no additional context. After a successful submission, show a clear success message and reset the form fields. Consider whether you want the success message to disappear after a few seconds or remain visible. For contact forms, keeping the message visible is usually better so the user has confidence their message was received. You can also redirect the user to a dedicated thank-you page, which has the added benefit of being trackable as a conversion event in Google Analytics or other analytics tools.

// Enhanced error handling pattern:
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
  e.preventDefault();
  setStatus("sending");
  setErrors({});

  const form = e.currentTarget;
  const data = new FormData(form);

  // Validate first
  const validationErrors = validateFields(data);
  if (Object.keys(validationErrors).length > 0) {
    setErrors(validationErrors);
    setStatus("idle");
    return;
  }

  try {
    const res = await fetch(process.env.NEXT_PUBLIC_FORMSLIST_URL!, {
      method: "POST",
      body: data,
    });

    if (res.ok) {
      setStatus("sent");
      form.reset();
      // Optional: redirect to thank-you page
      // router.push("/thank-you");
    } else if (res.status === 429) {
      setStatus("error");
      setErrorMessage("Too many submissions. Please wait a moment and try again.");
    } else {
      setStatus("error");
      setErrorMessage("Something went wrong. Please try again.");
    }
  } catch {
    setStatus("error");
    setErrorMessage("Unable to connect. Please check your internet and try again.");
  }
}
6

Set up email notifications and Slack alerts

Once your form is working, configure notifications so you never miss a submission. In the FormsList dashboard, navigate to your form settings and enable email notifications. You can add multiple recipient email addresses, which is useful if you have a team that shares responsibility for responding to inquiries. Each recipient receives a formatted email containing all the submitted field values. For faster response times, connect Slack as well. FormsList can send a message to any Slack channel whenever a new submission arrives. This is especially valuable for sales teams or support teams who live in Slack throughout the day. The Slack notification includes the full submission data so your team can see the message without leaving Slack and decide who should respond. You can also set up auto-responder emails that are sent to the person who filled out the form. A simple auto-reply like "Thank you for reaching out. We have received your message and will get back to you within 24 hours" sets expectations and reassures the sender that their message was not lost. FormsList auto-responders use the email field from the submission, so make sure your form includes an email input with name="email" for this feature to work correctly.

7

Test and customize notifications

Submit a test message through your form and verify it arrives in your FormsList dashboard. From the dashboard you can enable email notifications, set up auto-responders, and connect integrations like Slack or Google Sheets. Your Next.js contact form is now fully functional without any backend code. Test the form in multiple scenarios: submit with valid data, submit with missing fields to verify validation, submit with a very long message to check for truncation, and test on mobile devices to make sure the form is usable on small screens. If you are using the App Router with static generation, verify that the form works correctly after running next build and next start, since statically generated pages sometimes behave differently from the dev server. Finally, check your form's accessibility. Make sure every input has a corresponding label element, the form can be navigated and submitted entirely with the keyboard, and error messages are announced to screen readers. Use the aria-describedby attribute to associate error messages with their fields. A well-built contact form should score 100 on Lighthouse accessibility — this is straightforward with semantic HTML and proper labeling.

Frequently Asked Questions

Ready to collect form submissions?

Set up your form backend in under a minute. No server required, no complex configuration — just a simple endpoint for your forms.