Skip the Backend: Connect a Contact Form Directly to Supabase

Next.js + TypeScript·Supabase JS SDK·No server required

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
💡
The anon key is intentionally safe to use in the browser - it's designed for public client-side usage. What protects your data is Row Level Security (RLS), not hiding the key.

Setup Guide

1

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).

2

Grab your API keys

In your project dashboard go to Settings → API. You'll find your Project URL and anon public key there.

3

Create a .env file in your project root

.env
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
⚠️
Never commit your .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.
4

Create lib/supabase.ts

In your project root, inside a lib/ folder, create the Supabase client:

lib/supabase.ts
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:

app/contact/form.tsx
"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 customers table
  • Add an INSERT policy for the anon role - so anyone can submit the form
  • Do not add a SELECT policy for anon - so nobody can read all submissions
SQL - RLS policy for public form 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)
💡
With this setup: anyone can submit your form, but nobody can read your customer list - not even someone who has your anon key.

© Hitesh Suthar

Built with Chai & ❤

Coding