# Ekx Supabase

> The default Ekinoxis database — Postgres, auth, storage and realtime via Supabase. Use when creating or changing tables, writing RLS policies, wiring the browser/server client pair with @supabase/ssr in Next.js App Router, running migrations, debugging "permission denied"/empty-result/RLS surprises, generating TypeScript types, or choosing between anon and service-role keys. Covers @supabase/supabase-js, @supabase/ssr and the Supabase MCP server.

- Skill: `ekinoxis-evm/ekx-supabase` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ekinoxis-evm/ekx-supabase`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ekinoxis-evm/ekx-supabase/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Ekinoxis-evm (https://skillmd.com/u/ekinoxis-evm)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ekinoxis-evm/ekx-supabase

---


# 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

```bash
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:

```ts
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:

```ts
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:

```ts
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.

```sql
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:

```sql
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:

1. **Service-role writes behind a verified route handler** (what we do): verify the Privy token, then use `admin` to write, scoping by the verified wallet address yourself.
2. 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.

```bash
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

1. **RLS on + no policy = empty result, no error.** First thing to check when a query returns `[]`.
2. **`cookies()` is async** in Next 15+. `await` it.
3. **Middleware must refresh the session** or server components see a stale/expired token. Supabase's `updateSession` middleware helper is not optional in App Router.
4. **`.single()` throws when zero rows match.** Use `.maybeSingle()` unless absence is genuinely an error.
5. **Default `select()` returns 1000 rows max.** Paginate with `.range(from, to)`.
6. **Realtime needs the publication.** `alter publication supabase_realtime add table foo;` — subscribing without it silently never fires.
7. **Storage buckets have their own RLS**, separate from table policies.

