A static site is wonderful right up to the moment you need it to remember something — a contact message, a saved snippet, a view count. The classic answer is "spin up a server," but I didn't want to maintain one. Supabase let me add a Postgres database, authentication, and instant APIs to my otherwise static portfolio without running any backend code of my own. Here is how I structured it and the mistakes I hit along the way.

Why Supabase for a Static Front-End

Supabase gives you a hosted Postgres database with an auto-generated REST and realtime API, plus a small JavaScript client you drop into the browser. For a static site that means:

Connecting from the Browser

The client initializes with the project URL and the public anon key. These are meant to be public — the security boundary is the database, not the key:

import { createClient } from
  'https://esm.sh/@supabase/supabase-js@2';

const supabase = createClient(
  'https://YOUR-PROJECT.supabase.co',
  'YOUR-ANON-PUBLIC-KEY'
);

Loading the client straight from an ESM CDN keeps me in the no-build-step world the rest of the site lives in.

A Contact Form That Persists

The contact form was the first feature. The table is intentionally simple:

create table messages (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  email       text not null,
  body        text not null,
  created_at  timestamptz default now()
);

Inserting a row from the form handler is a single call:

const { error } = await supabase
  .from('messages')
  .insert({ name, email, body });

if (error) showError(error.message);
else showSuccess('Message sent!');

Row Level Security Is the Whole Game

This is the part people skip and then panic about. By default, with RLS enabled and no policies, nothing is allowed — which is the safe default. For the contact form I want anonymous visitors to be able to insert a message but never to read other people's messages:

alter table messages enable row level security;

-- anyone may submit a message
create policy "anon can insert"
  on messages for insert
  to anon
  with check (true);

-- but nobody anonymous may read them back
-- (no SELECT policy = no read access)

The mental shift is important: with RLS, you don't hide the API, you describe exactly who can do what at the row level. The anon key being public no longer matters because the policy, not the key, decides access.

A Read-Only Content Vault

I also wanted a "vault" of public snippets and prompts that anyone can browse but only I can edit. That's the mirror image of the contact form — public SELECT, restricted writes:

create policy "public can read vault"
  on vault_items for select
  to anon
  using (is_published = true);

create policy "owner can write"
  on vault_items for all
  to authenticated
  using (auth.uid() = owner_id);

The is_published flag means I can save drafts that stay invisible until I flip them live — a tiny CMS, enforced entirely in SQL.

Lessons From the Migrations

I keep every schema change in a versioned .sql file in the repo rather than clicking around the dashboard. A few things I learned:

Rate Limiting and Spam

A public insert policy invites spam. I added two defenses without a server: a hidden honeypot field that real users never fill, and a Postgres function that rejects more than a few inserts per minute from the same source. For anything heavier, Supabase Edge Functions can sit in front of the table — but for a portfolio, the simple measures held up fine.

The Takeaway

"Static site" and "has a database" are no longer contradictory. With Supabase I got persistence, auth, and proper authorization while keeping a zero-build, zero-server front end. The single most important concept to internalize is Row Level Security — once you think of access control as SQL policies instead of hidden endpoints, the whole architecture clicks into place.

The contact form on this site runs on exactly this setup — try sending a message.