Next.js Contact Form Without API Routes

Skip writing route handlers, server actions, and SMTP config. Add a contact form to your Next.js site that sends emails — without any server-side code.

· 7 min read

In this guide

The API route problem

Next.js makes it easy to create API routes — but "easy" is relative. For a simple contact form, you still need to write a route handler, install a mail library like Nodemailer or Resend, configure SMTP credentials or an API key, handle errors, and test it in both development and production. That's 30-60 minutes of work for something that should take 30 seconds.

Server actions in the App Router make the code slightly shorter, but you're still writing server-side logic, managing secrets, and handling email delivery yourself. And if you're deploying to Vercel's serverless environment, you need to deal with cold starts and function timeouts.

A form backend eliminates all of this. Your Next.js component makes a fetch call to an external API, and the service handles email delivery, spam filtering, and submission storage. Optaristo is built for exactly this — it's free during beta and works with zero configuration.

Get your Optaristo access key

  1. Sign up for free — no credit card, no usage limits.
  2. Create a form in the dashboard. Name it something like "Next.js Contact".
  3. Copy your access key.

App Router: client component

Since the form uses browser APIs (useState, event handlers), mark it as a client component with the 'use client' directive:

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

import { useState } from 'react';

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

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setStatus('loading');

    const formData = new FormData(e.target as HTMLFormElement);
    const data = Object.fromEntries(formData);

    const res = await fetch(
      'https://app.optaristo.com/api/submit',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json'
        },
        body: JSON.stringify(data)
      }
    );

    setStatus(res.ok ? 'success' : 'error');
  }

  if (status === 'success') {
    return <p>Thanks! We'll be in touch soon.</p>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="hidden" name="access_key"
        value={process.env.NEXT_PUBLIC_OPTARISTO_KEY} />
      <input type="hidden" name="_gotcha"
        style={{ display: 'none' }} />

      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <textarea name="message" placeholder="Message" rows={5} required />

      <button disabled={status === 'loading'}>
        {status === 'loading' ? 'Sending...' : 'Send Message'}
      </button>
      {status === 'error' && <p>Something went wrong.</p>}
    </form>
  );
}

Then import it into your page as a server component:

app/contact/page.tsx
import ContactForm from './ContactForm';

export const metadata = {
  title: 'Contact Us',
  description: 'Get in touch with us.'
};

export default function ContactPage() {
  return (
    <main>
      <h1>Contact Us</h1>
      <ContactForm />
    </main>
  );
}

The page itself stays a server component for SEO benefits, while the interactive form is isolated as a client component. No API routes anywhere in the project.

Pages Router: same approach

If you're using the Pages Router, the component is identical — just drop the 'use client' directive (everything is client-side in Pages Router) and import the component in your page:

pages/contact.tsx
import Head from 'next/head';
import ContactForm from '../components/ContactForm';

export default function ContactPage() {
  return (
    <>
      <Head>
        <title>Contact Us</title>
      </Head>
      <h1>Contact Us</h1>
      <ContactForm />
    </>
  );
}

Store the key in an env variable

The access key is not a secret (it's visible in your HTML source regardless), but it's still good practice to use an environment variable so you can swap keys between staging and production:

.env.local
NEXT_PUBLIC_OPTARISTO_KEY=your_access_key_here

The NEXT_PUBLIC_ prefix makes it available in client-side code. Reference it in your component as process.env.NEXT_PUBLIC_OPTARISTO_KEY.

Add spam protection

Optaristo rate-limits by IP automatically. For additional protection, the honeypot field in the code above (_gotcha) catches bots without requiring any CAPTCHA. Real users never see it; bots fill it in automatically and get flagged as spam.

You can also lock your form to only accept submissions from specific domains in the Optaristo dashboard — useful if you want to make sure the key only works on yoursite.com.

Frequently asked questions

Do I need API routes for a Next.js contact form?

No. With a form backend like Optaristo, you submit form data directly from the client to an external API. No API routes, no server actions, no route handlers needed.

Does this work with the Next.js App Router?

Yes. The contact form component is a client component that uses fetch to submit data. It works identically with both the App Router and the Pages Router.

Delete the API route. Keep the form.

Get your access key and add a working contact form to your Next.js site in under a minute.

Get my free access key

Related guides