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

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.tsin theappdirectory) as the form's backend. It receives the request with the standard WebRequestandResponseAPIs. - 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):
| Variable | What it is | Where it's used |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | Your project URL | Server (also safe in the browser) |
SUPABASE_SERVICE_ROLE_KEY | The secret key | Server only |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS | Your email account | Server 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:
- The honeypot returns success. A bot that fills the hidden field gets
ok: trueand moves on; it never learns it was caught. - Saving and emailing are independent. The visitor only sees an error if both failed, so a broken email setting never loses a lead.
- 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:

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
websitefield 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Insert fails with a permissions error | You used the publishable key and RLS blocks it | Use the secret key on the server |
| Works locally, fails in production | Environment variables not set on the host | Add them in your hosting dashboard and redeploy |
| Emails never arrive | SMTP port/security mismatch, or a provider that needs an app password | Port 465 uses secure: true; 587 uses STARTTLS (secure: false) |
| 405 Method Not Allowed | The form posts to a route without a POST export | Export POST from route.ts |
| Spam still arriving | Bots that skip honeypots | Add 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