Inletbase logoInletbase logoInletbase
PricingAboutContact

Form backend

Form BackendSubmissions inboxAI auto-replyWorkflowsCRM sync

AI chatbot

AI ChatbotTrainingPlaygroundChat widgetHuman handoff

Learn

DocumentationQuickstartAPI reference

Updates

BlogChangelog

Company

SecurityContactStatus
    Inletbase logoInletbase logoInletbase
    PricingAboutContact

    Form backend

    Form BackendSubmissions inboxAI auto-replyWorkflowsCRM sync

    AI chatbot

    AI ChatbotTrainingPlaygroundChat widgetHuman handoff

    Learn

    DocumentationQuickstartAPI reference

    Updates

    BlogChangelog

    Company

    SecurityContactStatus
    Inletbase logoInletbase logoInletbase
    PricingAboutContact

    Form backend

    Form BackendSubmissions inboxAI auto-replyWorkflowsCRM sync

    AI chatbot

    AI ChatbotTrainingPlaygroundChat widgetHuman handoff

    Learn

    DocumentationQuickstartAPI reference

    Updates

    BlogChangelog

    Company

    SecurityContactStatus
    Use Cases

    How to Build a Contact Form for a Next.js Website Without a Backend

    ZaidZaid·Oct 16, 2026· 10 min read
    Inletbase contact form for a Next.js website, showing name, email, and message fields with a green send message button, demonstrating how to build a contact form without a backend.

    You can build a Next.js contact form without writing a backend by sending the form to a hosted form endpoint. Use a Server Action to forward the data and keep your API key private, or post straight from a client component. The endpoint stores the submission and can notify you, so you need no API route, database or mail server.

    Key takeaways

    • “Without a backend” means you don’t build or host a mail server, database or API route. You still run Next.js.
    • A Server Action is the best default, because the API key stays on the server.
    • Post from the browser only when you need a static export, and treat the key as visible.
    • Validate on the server, add a honeypot field, and show the visitor a clear error.
    • Test the failure cases (wrong key, wrong form, empty fields) before you ship.

    What does “without a backend” mean in Next.js?

    It means you don’t write the part that receives, stores and emails a form submission. A hosted form endpoint does that. Your Next.js app only collects the fields and sends them.

    You still have a Next.js project, and a Server Action does run on your server. The difference is that you write no mail code, no database tables and no /api/contact route that talks to an email provider.

    What are your options?

    There are four ways to send a Next.js form to an endpoint. They differ in where the secret lives and whether they work with a static export.

    ApproachBackend code you writeAPI key visible in the browser?Works with static export?Best when
    Server Action → hosted endpointA short actionNoNoYou use the App Router and want the key private
    Client component → hosted endpointNoneYesYesYou export a static site or the page is already interactive
    API route → hosted endpointA route handlerNoNoYou must stay on the Pages Router, or need custom server logic
    API route → your own email providerA route, SMTP or API client, secretsNoNoYou need full control and accept the upkeep

    Quick decision guide

    1. Using the App Router and not exporting statically? Use a Server Action.
    2. Exporting a static site? Use a client component.
    3. On the Pages Router with a server? Use a client component, or an API route if you want the key private.
    4. Need custom email logic? Then you do want your own backend.

    What do you need before you start?

    • A Next.js project using the App Router (the code below was built on Next.js 16 with React 19; useActionState needs React 19).
    • A hosted form endpoint. Here that is Inletbase, which has a free Starter plan with 1 form and 100 submissions a month. See the pricing page for current limits.

    Step 1: How do you create the form endpoint?

    Create a form in the Inletbase dashboard, give it the fields you will send, and copy two things: the form slug and your API key.

    Every Inletbase form gets a submission endpoint in this shape:

    POST https://inletbase.com/api/v1/forms/{slug}/submit

    Send a JSON object whose keys match your form’s fields. Authenticate with your API key in an Authorization: Bearer header, or, for platforms that can’t set headers, add ?apikey=YOUR_API_KEY to the URL. The endpoint is CORS-enabled, so it can be called from a browser as well as from a server. For this form, define three fields: name, email and message. For the full reference, see the API docs.

    Step 2: How do you store the key and slug?

    Put both in .env.local, with no NEXT_PUBLIC_ prefix, so Next.js keeps them on the server:

    INLETBASE_FORM_SLUG=your-form-slug
    INLETBASE_API_KEY=your-api-key

    Restart the dev server after you add them. Anything with the NEXT_PUBLIC_ prefix is bundled into the browser, which is why it must not be used here. The Next.js environment variables guide explains the rule.

    Step 3: How do you write the Server Action?

    The Server Action validates the fields, ignores bots, forwards the data to the endpoint, and returns a state object the form can display.

    // app/contact/actions.ts
    "use server";
    
    export type ContactState = {
      status: "idle" | "success" | "error";
      message: string;
      fieldErrors?: Partial<Record<"name" | "email" | "message", string>>;
    };
    
    const ENDPOINT = `https://inletbase.com/api/v1/forms/${process.env.INLETBASE_FORM_SLUG}/submit`;
    
    export async function submitContact(
      _prev: ContactState,
      formData: FormData
    ): Promise<ContactState> {
      // Honeypot: real visitors never see or fill this field.
      if (String(formData.get("website") ?? "") !== "") {
        return { status: "success", message: "Thanks, we got your message." };
      }
    
      const name = String(formData.get("name") ?? "").trim();
      const email = String(formData.get("email") ?? "").trim();
      const message = String(formData.get("message") ?? "").trim();
    
      const fieldErrors: NonNullable<ContactState["fieldErrors"]> = {};
      if (!name) fieldErrors.name = "Please enter your name.";
      if (!/^\S+@\S+\.\S+$/.test(email)) fieldErrors.email = "Please enter a valid email.";
      if (message.length < 10) fieldErrors.message = "Please write at least 10 characters.";
    
      if (Object.keys(fieldErrors).length > 0) {
        return { status: "error", message: "Please fix the highlighted fields.", fieldErrors };
      }
    
      try {
        const res = await fetch(ENDPOINT, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${process.env.INLETBASE_API_KEY}`,
          },
          body: JSON.stringify({ name, email, message }),
        });
    
        if (!res.ok) {
          console.error("Form submit failed", res.status, await res.text());
          return { status: "error", message: "Something went wrong. Please try again." };
        }
    
        return { status: "success", message: "Thanks, we got your message." };
      } catch (err) {
        console.error("Form submit network error", err);
        return { status: "error", message: "Could not send your message. Check your connection." };
      }
    }

    Three details matter here. The server re-checks every field, because the browser’s required attribute is easy to bypass. The honeypot returns “success” to bots so they don’t retry. And failures are logged on the server and shown to the visitor as a friendly message, not a stack trace.

    Step 4: How do you build the form component?

    The form is a client component because it uses hooks. useActionState holds the result of the action, and useFormStatus gives the button its pending state.

    // app/contact/contact-form.tsx
    "use client";
    
    import { useActionState } from "react";
    import { useFormStatus } from "react-dom";
    import { submitContact, type ContactState } from "./actions";
    
    const initialState: ContactState = { status: "idle", message: "" };
    
    function SubmitButton() {
      const { pending } = useFormStatus();
      return (
        <button type="submit" disabled={pending}>
          {pending ? "Sending..." : "Send message"}
        </button>
      );
    }
    
    export function ContactForm() {
      const [state, formAction] = useActionState(submitContact, initialState);
    
      if (state.status === "success") {
        return <p role="status">{state.message}</p>;
      }
    
      return (
        <form action={formAction} noValidate>
          <label htmlFor="name">Name</label>
          <input id="name" name="name" autoComplete="name" required
            aria-invalid={Boolean(state.fieldErrors?.name)}
            aria-describedby={state.fieldErrors?.name ? "name-error" : undefined} />
          {state.fieldErrors?.name && <p id="name-error" role="alert">{state.fieldErrors.name}</p>}
    
          <label htmlFor="email">Email</label>
          <input id="email" name="email" type="email" autoComplete="email" required
            aria-invalid={Boolean(state.fieldErrors?.email)}
            aria-describedby={state.fieldErrors?.email ? "email-error" : undefined} />
          {state.fieldErrors?.email && <p id="email-error" role="alert">{state.fieldErrors.email}</p>}
    
          <label htmlFor="message">Message</label>
          <textarea id="message" name="message" rows={5} required
            aria-invalid={Boolean(state.fieldErrors?.message)}
            aria-describedby={state.fieldErrors?.message ? "message-error" : undefined} />
          {state.fieldErrors?.message && <p id="message-error" role="alert">{state.fieldErrors.message}</p>}
    
          {/* Honeypot field */}
          <div style={{ position: "absolute", left: "-9999px" }} aria-hidden="true">
            <label htmlFor="website">Leave this empty</label>
            <input id="website" name="website" tabIndex={-1} autoComplete="off" />
          </div>
    
          {state.status === "error" && !state.fieldErrors && <p role="alert">{state.message}</p>}
          <SubmitButton />
        </form>
      );
    }

    Step 5: How do you add it to a page?

    // app/contact/page.tsx
    import { ContactForm } from "./contact-form";
    
    export default function ContactPage() {
      return (
        <main>
          <h1>Contact us</h1>
          <ContactForm />
        </main>
      );
    }

    Keeping the page itself a server component means only the form ships client JavaScript. For the framework’s own guidance, see the Next.js forms guide.

    How do you post from the browser instead (static export)?

    Use a client component that calls the endpoint directly. This is the only option that works with output:'export', because Server Actions need a server.

    // app/contact/client-form.tsx
    "use client";
    
    import { useState, type FormEvent } from "react";
    
    const ENDPOINT =
      `https://inletbase.com/api/v1/forms/${process.env.NEXT_PUBLIC_INLETBASE_FORM_SLUG}/submit` +
      `?apikey=${process.env.NEXT_PUBLIC_INLETBASE_API_KEY}`;
    
    type Status = "idle" | "sending" | "sent" | "error";
    
    export function ClientContactForm() {
      const [status, setStatus] = useState<Status>("idle");
    
      async function onSubmit(event: FormEvent<HTMLFormElement>) {
        event.preventDefault();
        setStatus("sending");
        const form = event.currentTarget;
        const data = Object.fromEntries(new FormData(form));
    
        try {
          const res = await fetch(ENDPOINT, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(data),
          });
          const result = await res.json();
          if (res.ok && result.success) {
            form.reset();
            setStatus("sent");
          } else {
            setStatus("error");
          }
        } catch {
          setStatus("error");
        }
      }
    
      return (
        <form onSubmit={onSubmit}>
          <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 message"}
          </button>
          {status === "sent" && <p role="status">Thanks, we got your message.</p>}
          {status === "error" && <p role="alert">Something went wrong. Please try again.</p>}
        </form>
      );
    }

    Why this works well with Inletbase. Every Inletbase form comes with a CORS-enabled endpoint, so the browser can post to it directly, with no proxy or API route in between. You can allow only specific domains for your embedded forms, and the endpoint has rate limits built in, 30 requests a minute per IP and 120 a minute per API key, which help slow scripted floods from the start. The ?apikey= URL option is made for exactly this kind of header-free setup, and you can switch to the Server Action version at any time if you would rather keep the key on your server.

    Pages Router: use this client component. Drop the "use client" line, since every component under pages/ is interactive, and put it in pages/contact.tsx. Server Actions are an App Router feature.

    What does the endpoint send back?

    Map each response to something the visitor can understand, and log the technical detail on your side.

    ResponseMeaningWhat to show the visitor
    200 with "success": trueSubmission saved (the body includes a submissionId)The thank-you message
    400 with detailsA field failed validationThe field message, or “Please check the form”
    401API key missingA generic error (and fix it yourself)
    403API key invalidA generic error (and fix it yourself)
    404Form slug not foundA generic error (and fix it yourself)

    How do you protect the form from spam?

    Layer three simple defenses, and add a CAPTCHA only if spam gets through.

    1. Honeypot field: the hidden website field above. Bots fill it, people don’t.
    2. Server-side validation: the checks in the Server Action, which a bot can’t skip by editing the HTML.
    3. Rate limits: the endpoint caps requests per IP and per key, which slows scripted floods.
    4. Optional: add a CAPTCHA or a time-based check if volume grows.

    What happens to a submission after it is sent?

    Each submission appears in your Inletbase dashboard, and can trigger an AI auto-reply or a workflow you build without code. You can send it to HubSpot through the native CRM sync, or forward it to any endpoint with a webhook. If you want the CRM side explained, read how to sync website form submissions to your CRM. To learn what a form backend does more broadly, see what is a form backend.

    How do you test the form properly?

    Test the failure cases, not just the happy path.

    1. Valid submission: confirm it appears in the dashboard.
    2. Empty and invalid fields: confirm each shows its message.
    3. Wrong API key: expect a generic error and a 403 in your server log.
    4. Wrong form slug: expect a 404 in your server log.
    5. Double click: confirm the button disables while sending.
    6. Honeypot: fill the hidden field in dev tools and confirm no submission is created.
    7. Screen reader: confirm errors are announced.
    8. Production build: run next build and test the deployed site, not just next dev.

    What commonly goes wrong?

    SymptomLikely causeFix
    “Event handlers cannot be passed to Client Component props”The form lives in a server componentAdd "use client" to the form component
    Environment variable is undefinedMissing from .env.local, or the server was not restartedAdd it and restart; use NEXT_PUBLIC_ only for client-side code
    401 or 403API key missing or wrongCheck the key, and that it is read on the server
    404Wrong slugCopy the slug from the dashboard
    400 with detailsField names don’t match the form’s fieldsMake your input names match the dashboard fields
    Build fails with static exportServer Actions can’t run without a serverUse the client version

    Original insight: where does the secret live?

    Before you write any code, ask where the API key will live. If the answer is “the browser”, the key is public, and you should limit the form to your domains and accept that. If the answer is “the server”, use a Server Action. Most “does this need a backend?” arguments are really this one question.

    Conclusion

    A Next.js contact form doesn’t need its own backend. Send it to a hosted endpoint, validate on the server, add a honeypot, and show clear errors. Use a Server Action to keep your key private, or a client component for a static export, then test the failure cases before you ship.

    Next step: try Inletbase free and follow the steps above, or read the API docs for the full request and response reference.

    Frequently asked questions

    Can I build a Next.js contact form without a backend?

    Yes. Send the form to a hosted form endpoint instead of writing your own mail and storage code. You still run Next.js, but you don’t build a mail server, database or email API route. A Server Action or a client component can make the request.

    Do I need an API route to send email from Next.js?

    No. A hosted form endpoint receives the submission and handles storage and notification, so you don’t need an API route that calls an email provider. Add an API route only if you need custom server logic or want to use your own email service.

    Should I use a Server Action or a client component for a contact form?

    Use a Server Action in the App Router when you want to keep your API key on the server. Use a client component when you export a static site or the form already lives in an interactive component. Both send the same request to the endpoint.

    Is it safe to put an API key in a Next.js client component?

    Anything in client code is visible to visitors, so treat the key as public. Reduce the risk by restricting the form to your own domains and relying on rate limits, or avoid the problem entirely by using a Server Action so the key stays on the server.

    Does a Next.js contact form work with static export?

    Yes, with a client component that posts to the endpoint, because it needs no server at runtime. Server Actions don’t work with a static export since they need a server. Use the client version if you set output: 'export'.

    How do I stop spam on a Next.js contact form?

    Combine a hidden honeypot field, server-side validation and rate limits. Bots usually fill every field, so a hidden one catches many of them. Validate on the server, because browser checks are easy to bypass. Add a CAPTCHA only if spam still gets through.

    Can I use this in the Pages Router?

    Yes, with the client component version. Remove the "use client" line, since pages under pages/ are interactive by default, and post to the endpoint. Server Actions are an App Router feature, so the Pages Router needs the client approach or an API route.

    Can I send Next.js form submissions to a CRM?

    Yes. Inletbase syncs form submissions to HubSpot natively, and can forward them to any endpoint with a webhook. That means you can capture a Next.js form once and route it onward without Zapier or extra middleware.

    Share this article:
    Zaid

    Article by

    Zaid

    Zaid is a Digital Marketing Expert at Inletbase, a platform that helps businesses collect, manage, and reply to leads faster using AI-powered tools.

    AI agents, built in minutes

    • Connect your forms and data instantly
    • Train on your content in a few clicks
    Build your agent for free

    Tags

    App RouterContact FormForm APIForm backendForm Spam ProtectionHeadless Form BackendNext.js Contact FormNext.js FormsNo BackendReact Forms

    Related articles

    AI chatbot vs rule-based chatbot comparison for choosing the best chatbot for your website.

    AI Chatbot vs Rule-Based Chatbot: Which Is Best for Your Website?

    A rule-based chatbot follows a fixed script of buttons and keywords, while an AI chatbot understands questions typed in a visitor’s own words and answers from your content. For most websites, an AI chatbot trained on your own content is the better choice. A rule-based bot wins only when every question is predictable and every […]

    ZaidZaid · Oct 21, 2026
    Cover image for “10 Best Customer Support Plugins for Framer in 2026,” featuring Inletbase and Framer logos on a black and purple background.

    10 Best Customer Support Plugins for Framer in 2026

    The best customer support plugin for Framer depends on what you need. Inletbase suits sites that want forms plus an AI chatbot with human handoff, Helploom suits live chat with a help center, BubblaV suits AI support with human handoff, and Human Reply or the Telegram Chat Button suit simple, human-only replies. All 10 are […]

    ZaidZaid · Oct 12, 2026
    Diagram showing leads from three different websites flowing into Inletbase to centralize and manage website leads in one place.

    How to Turn Chatbot Conversations Into CRM Leads (Without Zapier)

    To turn chatbot conversations into CRM leads without Zapier, have the chatbot send the lead directly to your CRM once a visitor shares their contact details. Use a native CRM sync, a webhook to your CRM’s own endpoint, the CRM’s API, or a chat tool built into the CRM. Send only qualified chats, match on […]

    ZaidZaid · Oct 10, 2026

    Latest articles

    Inletbase dashboard showing website form submissions synced to a CRM automatically.

    How to Sync Website Form Submissions to Your CRM Automatically

    To sync website form submissions to your CRM automatically, send each submission to the CRM the moment it is submitted, using a native integration, a webhook, a connector tool or a CRM-hosted form. Map name, email, message, source and UTM fields, assign an owner on creation, match on email to avoid duplicates, and test every […]

    ZaidZaid · Oct 7, 2026
    Inletbase form backend dashboard with a website form playground

    What Is a Form Backend? And How It Works

    A form backend is a hosted service that receives the data from your website’s form, stores it, checks it for spam, and sends it on to places like your email, your CRM, or Slack. It does the work a server would normally do, so your form works without you building or running one yourself. At […]

    ZaidZaid · Oct 2, 2026

    Ready to automate your forms and deploy custom AI?

    100 submissions/mo and 1 AI chatbot included. No credit card required. Setup in 2 minutes.

    Inletbase logoInletbase logoInletbase
    Request AI summary
    ProductForm BackendAI ChatbotPricingSecurityStatus
    IntegrationsAPI IntegrationWordPress PluginFramer Plugin
    ResourcesBlogDocumentationChangelogAboutContact
    LegalPrivacy PolicyTerms of ServiceData Processing AddendumSubprocessorsCookie Policy
    © 2026 Inletbase LLC. All rights reserved.Hey AI, learn about us
    Inletbase