The Problem
I was building a contact form for my portfolio and needed to ship it fast - without setting up a backend. At that point I hadn't built a server before, but I already knew PostgreSQL. I needed a way to save form submissions directly from the frontend.
The solution: connect my form straight to Supabase. No Express, no API routes, no backend at all - just a client-side insert into a real Postgres database.
Why Supabase?
Think of Supabase as Firebase - but built on real SQL and fully open-source. A few reasons it's a great fit here:
- Instant auto-generated APIs - create a table, get a REST API immediately
- Developer-friendly - excellent JS/TS SDK, clean dashboard
- Generous free tier - perfect for portfolios and side projects
- Real PostgreSQL - you already know SQL, it all applies
Setup Guide
Create a free Supabase account & project
Go to supabase.com, sign up, and create a new project. Once it's ready, go to Table Editor and create a customers table with two columns: name (text) and email (text).
Grab your API keys
In your project dashboard go to Settings → API. You'll find your Project URL and anon public key there.
Create a .env file in your project root
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
.env file to Git. Add it to .gitignore. The anon key is safe in the browser, but keeping it in env vars is still best practice.Create lib/supabase.ts
In your project root, inside a lib/ folder, create the Supabase client:
import { createClient } from "@supabase/supabase-js"; export const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! );
You import this one client everywhere in your app - no need to re-initialize it.
Connect the Form
Wire up your form's submit handler to insert directly into your Supabase table. Here's a clean, correct implementation:
"use client"; import { useState } from "react"; import { supabase } from "@/lib/supabase"; export default function ContactForm() { const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [status, setStatus] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setStatus("sending"); const { error } = await supabase .from("customers") .insert({ name, email }); if (error) { console.error(error); setStatus("error"); } else { setStatus("success"); setName(""); setEmail(""); } }; return ( <form onSubmit={handleSubmit}> <input value={name} onChange={e => setName(e.target.value)} placeholder="Your name" required /> <input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="Your email" required /> <button type="submit" disabled={status === "sending"}> {status === "sending" ? "Sending..." : "Send"} </button> {status === "success"&& <p>Message sent! ✓</p>} {status === "error"&& <p>Something went wrong.</p>} </form> ); }
The three highlighted lines are the only Supabase-specific code. The rest is standard React state and a form - no backend needed.
A Note on Security
Since this is a public contact form (no login required), make sure only the right operations are allowed. In your Supabase dashboard:
- Enable RLS on the
customerstable - Add an INSERT policy for the
anonrole - so anyone can submit the form - Do not add a SELECT policy for anon - so nobody can read all submissions
-- Allow anonymous inserts (the contact form) CREATE POLICY "allow public insert" ON customers FOR INSERT TO anon WITH CHECK (true); -- Block all reads from anonymous users -- (no SELECT policy = no access)
