Hasnain Irfan

Open for Work

Hire Me

October 22, 2026 · 8 min read

How to Build a Next.js Contact Form with Supabase (App Router)

Build a Next.js contact form with Supabase: a Route Handler, server-side validation, a honeypot, rate limiting and email, with code from a live site.

Hasnain IrfanSenior Software Engineer · Remote for US, UK & UAE teams

Cover: Next.js contact form with Supabase, step by step, by Hasnain Irfan

To build a Next.js contact form with Supabase, post the form to a Route Handler (app/api/contact/route.ts), validate the fields on the server, save the row with a Supabase client that uses your secret (service role) key, and then email yourself the message. The secret key stays on the server, so visitors never see it, and a hidden "honeypot" field plus a small rate limit keep out a lot of spam. This is the exact setup behind the brief form on hasnainirfan.com, and the code below is taken from it, lightly simplified.

Key takeaways

  • Use a Route Handler (route.ts in the app directory) as the form's backend. It receives the request with the standard Web Request and Response APIs.
  • Validate on the server with a schema library such as Zod. Never trust what the browser sends.
  • Keep the Supabase secret key on the server. Supabase's docs say it "bypasses Row Level Security" and must never be put "in a browser, a shipped application, or source control."
  • Stop bots cheaply: a hidden honeypot field catches many simple spam bots, and a per-address limit slows down repeated sends.
  • Save first, then email. If the email fails, the message is still in your database, and you can record whether the email went out.

What do you need before you start?

You need a Next.js app using the App Router, a Supabase project, and an email account that can send over SMTP. The packages are @supabase/supabase-js, zod and nodemailer. Keep the keys in environment variables (.env.local locally, your host's settings in production):

VariableWhat it isWhere it's used
NEXT_PUBLIC_SUPABASE_URLYour project URLServer (also safe in the browser)
SUPABASE_SERVICE_ROLE_KEYThe secret keyServer only
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSYour email accountServer only

Never prefix the secret key with NEXT_PUBLIC_: in Next.js, that prefix puts a variable into the browser bundle.

Step 1: How should the Supabase table look?

Create one table for submissions, with length limits in the database itself so bad data can't get in even if your code has a bug. Turn on Row Level Security (RLS) so the table is closed by default:

create table public.contact_submissions (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  email       text not null,
  budget      text,
  message     text not null,
  created_at  timestamptz not null default now(),
  email_sent  boolean,
  email_error text,
  constraint contact_submissions_lengths check (
    char_length(name) between 1 and 120
    and char_length(email) between 3 and 200
    and char_length(message) between 1 and 5000
  )
);

alter table public.contact_submissions enable row level security;

With RLS on and no policies, the public (publishable/anon) key can't read or write this table. Your Route Handler writes with the secret key, which, per Supabase's API key docs, skips RLS. If every submission goes through your Route Handler, you don't need a public insert policy at all, and leaving it out means nobody can skip your spam checks by writing to the database directly. Add a select policy only for the admins who should read messages.

Step 2: How do you create a server-only Supabase client?

Create a small helper that builds the client with the secret key, and mark the file server-only so Next.js fails the build if a client component ever imports it:

// lib/supabase/service.ts
import 'server-only';
import { createClient } from '@supabase/supabase-js';

export function serviceClient() {
  const url = process.env.NEXT_PUBLIC_SUPABASE_URL, key = process.env.SUPABASE_SERVICE_ROLE_KEY;
  if (!url || !key) return null;
  return createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } });
}

persistSession: false matters on a server: there's no visitor session to store, and you don't want one request's state leaking into the next.

Step 3: How do you write the Route Handler?

A Route Handler is a route.ts file that exports a function per HTTP method. The Next.js docs note that a route.ts can't sit in the same folder as a page.tsx, which is why it lives under app/api/. Here is the handler, slightly shortened from the live one:

// app/api/contact/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { serviceClient } from '@/lib/supabase/service';
import { sendEnquiryEmails } from '@/lib/mail';

const Body = z.object({
  name: z.string().trim().min(1).max(120),
  email: z.string().trim().email().max(200),
  budget: z.string().trim().max(100).optional().default(''),
  message: z.string().trim().min(1).max(5000),
  website: z.string().max(500).optional(),   // the honeypot: people never see it, bots fill it
});
const recent = new Map<string, number[]>(); // 5 sends a minute per address, per server instance

export async function POST(req: Request) {
  const ip = (req.headers.get('x-forwarded-for') || '').split(',')[0].trim() || 'local', now = Date.now();
  const hits = (recent.get(ip) || []).filter(t => now - t < 60_000);
  if (hits.length >= 5) return NextResponse.json({ ok: false, error: 'Too many messages. Try again in a minute.' }, { status: 429 });
  recent.set(ip, [...hits, now]);

  let data: z.infer<typeof Body>;
  try { data = Body.parse(await req.json()); }
  catch { return NextResponse.json({ ok: false, error: 'Please add your name, a valid email and a message.' }, { status: 400 }); }
  if (data.website) return NextResponse.json({ ok: true }); // a bot: say yes, keep nothing

  const db = serviceClient();
  let id: string | null = null;
  if (db) {
    const { data: row, error } = await db.from('contact_submissions')
      .insert({ name: data.name, email: data.email, budget: data.budget || null, message: data.message })
      .select('id').single();
    if (error) console.error('contact: save failed', error.message); else id = row.id;
  }

  let sent = false, mailError: string | null = null;
  try { await sendEnquiryEmails(data); sent = true; }
  catch (err) { mailError = String((err as Error).message || err).slice(0, 300); }
  if (db && id) await db.from('contact_submissions').update({ email_sent: sent, email_error: mailError }).eq('id', id);

  if (!id && !sent) return NextResponse.json({ ok: false, error: 'Could not send right now. Please email me directly.' }, { status: 502 });
  return NextResponse.json({ ok: true });
}

Three details make this robust:

  1. The honeypot returns success. A bot that fills the hidden field gets ok: true and moves on; it never learns it was caught.
  2. Saving and emailing are independent. The visitor only sees an error if both failed, so a broken email setting never loses a lead.
  3. The email result is written back (email_sent, email_error), so you can spot a broken SMTP setting from your dashboard.

One honest limit: the recent map lives in the memory of one server instance. On serverless hosting, requests can land on different instances, so treat it as a speed bump, not a guarantee. For a hard limit, use a shared store such as Redis or a database table.

Step 4: How do you email the message?

Send two emails with Nodemailer over SMTP: one to yourself with replyTo set to the visitor, so you can answer with one click, and a short confirmation to the visitor.

// lib/mail.ts (shortened)
import 'server-only';
import nodemailer from 'nodemailer';

export async function sendEnquiryEmails(q: { name: string; email: string; budget?: string; message: string }) {
  const port = Number(process.env.SMTP_PORT || 465);
  const t = nodemailer.createTransport({ host: process.env.SMTP_HOST, port, secure: port === 465,
    auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } });
  await t.sendMail({ from: process.env.SMTP_USER, to: process.env.SMTP_USER, replyTo: `"${q.name.replace(/"/g, '')}" <${q.email}>`,
    subject: `New project brief · ${q.name}`, text: `${q.message}\n\nName: ${q.name}\nEmail: ${q.email}\nBudget: ${q.budget || '-'}` });
  await t.sendMail({ from: process.env.SMTP_USER, to: q.email, subject: 'Thanks, I got your message',
    text: `Hi ${q.name.split(' ')[0]},\n\nThanks for the message. I'll reply soon.` }).catch(() => {}); // a courtesy; don't fail on it
}

Escape the visitor's text if you send an HTML email, and strip quotes from the name before putting it in a header, as above.

Step 5: How does the form send the data?

The form is ordinary HTML plus a few lines of JavaScript. The honeypot is a normal input hidden with CSS, with tabindex="-1" and autocomplete="off" so people and password managers skip it:

<form id="brief" novalidate>
  <input name="website" class="hp" tabindex="-1" autocomplete="off" aria-hidden="true">
  <input name="name" required autocomplete="name" placeholder="Your name">
  <input name="email" type="email" required autocomplete="email" placeholder="Email">
  <textarea name="message" required placeholder="What are you building?"></textarea>
  <button type="submit">Send message</button>
  <p aria-live="polite"></p>
</form>
form.addEventListener('submit', async e => {
  e.preventDefault();
  const d = Object.fromEntries(new FormData(form));
  const res = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(d) });
  const out = await res.json();
  form.querySelector('[aria-live]').textContent = out.ok ? "Thanks! I'll reply within 24 hours." : out.error;
});

The aria-live paragraph reads the result aloud to screen-reader users. Here is the same form running on this site:

![The project brief form on hasnainirfan.com/contact with name, email, budget options and a message box](/blog/nextjs-contact-form-supabase/brief-form-mobile.webp "The live form on an iPhone-sized screen; it posts to /api/contact")

How do you test it?

Test each path before you rely on it:

  • Happy path: send a real message and check that a row appears in Supabase and the email arrives.
  • Validation: send an empty message or a bad email; you should get a 400 and a clear error.
  • Honeypot: fill the hidden website field from the browser console and send; you should see "ok" but no new row.
  • Rate limit: send six times within a minute; the sixth should return 429 (on one instance).
  • Email failure: set a wrong SMTP password in a preview deployment; the row should still save with email_sent = false.

Troubleshooting

SymptomLikely causeFix
Insert fails with a permissions errorYou used the publishable key and RLS blocks itUse the secret key on the server
Works locally, fails in productionEnvironment variables not set on the hostAdd them in your hosting dashboard and redeploy
Emails never arriveSMTP port/security mismatch, or a provider that needs an app passwordPort 465 uses secure: true; 587 uses STARTTLS (secure: false)
405 Method Not AllowedThe form posts to a route without a POST exportExport POST from route.ts
Spam still arrivingBots that skip honeypotsAdd a shared rate limit or a CAPTCHA service

Summary

A Next.js contact form with Supabase needs four parts: a closed table with RLS on, a server-only Supabase client with the secret key, a Route Handler that validates, filters bots, saves and emails, and a plain form that posts JSON to it. Save before you email, record whether the email went out, and never let the secret key near the browser. For the rest of the technical setup, see my Next.js SEO checklist.

If you'd rather have a form like this, or the whole site around it, built for you, I build Next.js and React apps with Supabase and Node.js backends. Send a brief through this exact form and I'll reply within 24 hours.

Questions

How do I connect a Next.js contact form to Supabase?

Post the form to a Route Handler such as app/api/contact/route.ts, validate the fields on the server, and insert the row with a Supabase client created from your secret (service role) key. The key stays on the server, so it never reaches the browser.

Is it safe to use the Supabase service role key in Next.js?

Only on the server. Supabase's docs say the secret key bypasses Row Level Security and must never be put in a browser, a shipped application or source control. Keep it in a server-only file and never give it the NEXT_PUBLIC_ prefix.

Do I need a Row Level Security policy for the contact form table?

Turn RLS on. If every submission goes through your Route Handler with the secret key, you don't need a public insert policy; add a select policy only for the admins who should read the messages.

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

Add a hidden honeypot field that people never see and bots often fill, and return success without saving when it's filled. Add a rate limit per address; an in-memory limit is a speed bump on serverless hosting, so use a shared store such as Redis for a hard limit.

How do I send an email when the contact form is submitted?

Use Nodemailer over SMTP in the same Route Handler: send yourself the message with replyTo set to the visitor, then a short confirmation to the visitor. Save the row first, so a failed email never loses the message.

Keep reading

October 21, 2026React Native vs Flutter for Startups: Which to Choose?React Native vs Flutter for startups: how they differ, which suits an MVP, performance, cost and store fees, with facts from the official docs (2026).October 20, 2026Cost to Hire a Next.js Developer in 2026: Rates and FeesWhat it costs to hire a Next.js developer in 2026: sourced hourly rates for freelancers, agencies and US employees, plus fees and what drives the total.October 19, 2026Next.js SEO Checklist for the App Router (2026)A 10-step Next.js SEO checklist for the App Router: metadata, canonicals, sitemap.ts, robots.ts, JSON-LD, share images and llms.txt, with live code.