Supabase
The Ekinoxis default database. In 13 of 26 projects. If a new project needs persistence, it uses Supabase unless there is a stated reason not to.
Authoritative: the supabase MCP server (8 of our repos configure it) — use it to
inspect schema, run SQL, apply migrations and read logs rather than guessing.
Environment
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= # public (older repos: ..._ANON_KEY)
SUPABASE_SERVICE_ROLE_KEY= # SECRET — bypasses ALL RLS
⚠️ The portfolio has three names for the publishable key (_ANON_KEY,
_PUBLISHABLE_KEY, _PUBLISHABLE_DEFAULT_KEY). New code uses _PUBLISHABLE_KEY.
The service-role key bypasses every RLS policy. It belongs in server code only —
route handlers, server actions, cron jobs. If it appears in a "use client" file or
behind a NEXT_PUBLIC_ prefix, the database is fully open to the internet.
The client pair (Next.js App Router)
This is written from scratch in 9 projects. It should be a shared package; until it is, copy it exactly.
lib/supabase/client.ts — browser:
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
}
lib/supabase/server.ts — server components, actions, route handlers:
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies(); // await — Next 15+
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (list) => {
try { list.forEach(({ name, value, options }) => cookieStore.set(name, value, options)); }
catch { /* called from a Server Component — middleware refreshes instead */ }
},
},
},
);
}
lib/supabase/admin.ts — service role, server only:
import "server-only"; // build fails if imported client-side
import { createClient } from "@supabase/supabase-js";
export const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } },
);
The import "server-only" line is cheap insurance. Use it.
RLS
Every table is RLS-on with an explicit policy. A table with RLS enabled and no policy returns zero rows — which reads exactly like "the query is broken" and has cost us hours more than once.
alter table public.positions enable row level security;
create policy "own rows: read"
on public.positions for select
using ((select auth.uid()) = user_id);
create policy "own rows: write"
on public.positions for insert
with check ((select auth.uid()) = user_id);
Wrap auth.uid() in (select …) — it lets Postgres evaluate it once per query
instead of once per row, and is a large win on big tables.
Index every column a policy filters on:
create index on public.positions (user_id);
When auth is Privy, not Supabase Auth
Several of our dApps authenticate with Privy — so auth.uid() is null. Two options:
- Service-role writes behind a verified route handler (what we do): verify the Privy token, then use
adminto write, scoping by the verified wallet address yourself. - Mint a Supabase JWT from the Privy identity. More correct, more moving parts. Not currently done anywhere.
If you take option 1, RLS on those tables should deny all public access — the route handler is the only door.
Migrations
Migrations live in supabase/migrations/ as timestamped SQL, committed to the repo.
supabase migration new add_positions_table
supabase db push # apply to linked project
supabase gen types typescript --linked > lib/database.types.ts
Then type the client: createBrowserClient<Database>(...).
Never edit a table in the dashboard UI on a project that has migrations — the next
db push will fight you.
Gotchas
- RLS on + no policy = empty result, no error. First thing to check when a query returns
[]. cookies()is async in Next 15+.awaitit.- Middleware must refresh the session or server components see a stale/expired token. Supabase's
updateSessionmiddleware helper is not optional in App Router. .single()throws when zero rows match. Use.maybeSingle()unless absence is genuinely an error.- Default
select()returns 1000 rows max. Paginate with.range(from, to). - Realtime needs the publication.
alter publication supabase_realtime add table foo;— subscribing without it silently never fires. - Storage buckets have their own RLS, separate from table policies.