Setu is an internal operations app built on Supabase and Vercel. The frontend is a Vercel-style static app; its API boundary is a Supabase Edge Function backed by Postgres.
It covers equipment and program requests, comments, roster scheduling, inventory, programs, home content, and user settings.
This architecture can run on the free tiers of Supabase and Vercel for both
the Setu and Setu Dev environments, within each provider's current quotas and
terms. Supabase hosts the database, Storage, Edge Functions, and the 10-minute
pg_cron job; Vercel only serves the static frontend. Supabase's current Free
plan includes two active projects, 500,000 Edge Function invocations, 1 GB of
file storage, and 500 MB of database storage per project. Free projects pause
after 7 days with no API activity — each project's pause timer is independent,
so pinging one does not keep the other awake.
.github/workflows/keep-alive.yml pings
both the Setu and Setu Dev projects every 3 days to prevent this. It needs
four repository secrets (Settings → Secrets and variables → Actions in
GitHub), using the same URL/publishable key pair from each project's
Project Settings → API Keys page (see step 1 below for where to find
these):
SETU_SUPABASE_URL_PROD/SETU_SUPABASE_PUBLISHABLE_KEY_PROD— the Setu (production) projectSETU_SUPABASE_URL_DEV/SETU_SUPABASE_PUBLISHABLE_KEY_DEV— the Setu Dev project
Trigger a manual run from the Actions tab (Keep Supabase projects alive → Run workflow) to verify it after setup.
Comment email delivery also depends on the email provider's limits. The current Resend Free plan includes 3,000 transactional emails per month with a 100-email daily limit. The application intentionally treats delivery as best-effort and does not retry failed messages. Vercel's Hobby plan has its own usage limits and is intended for personal, non-commercial use, so check Vercel's current terms if this becomes a commercial deployment.
See the providers' current plan details before relying on these numbers: Supabase pricing, Vercel pricing, and Resend pricing.
Browser → Vercel static app → Supabase Auth
├→ Edge Function: api
├→ Postgres
└→ Storage
frontend/contains the browser application.supabase/migrations/contains the database schema.supabase/functions/api/contains the authenticated API boundary.shared/types.d.tscontains the frontend/API contract.
- Node.js 22 or newer
- A Supabase account and a development project
- A Google OAuth client configured for that Supabase project
- Git
Docker is not required for the recommended hosted-development workflow. The
Supabase CLI is installed as a project dependency by npm install.
git clone <repository-url>
cd setu
npm install
cp .env.example .env.localEdit .env.local with the URL and publishable/anon key for the Supabase
development project. In the Supabase dashboard, open the project (if your
organization has more than one, confirm which is the shared development
project before continuing — do not point .env.local at a production
project), then go to Project Settings → API Keys, "Publishable and secret
API keys" tab:
SETU_SUPABASE_URL=https://<project-ref>.supabase.co
SETU_SUPABASE_PUBLISHABLE_KEY=<the "Publishable key" value shown there>.env.local is ignored by Git. Never put a service-role/secret key or OAuth
secret in it — only the publishable key is safe to use here.
The frontend signs users in with Google OAuth, which needs an OAuth client in Google Cloud plus matching config in Supabase. Do this before your first sign-in attempt — see the note at the end of step 3 if you already tried signing in.
In Google Cloud Console (APIs & Credentials):
- Pick or create a GCP project for this app. If prompted, configure the OAuth consent screen first (app name, support email) — this is a one-time step per GCP project. While the app is in "Testing" mode, only accounts you explicitly add as test users can sign in.
- Create Credentials → OAuth client ID → Application type Web application.
- Under Authorized JavaScript origins, add
http://localhost:3000. - Under Authorized redirect URIs, add the callback URL shown in the
Supabase panel from the next step, typically
https://<project-ref>.supabase.co/auth/v1/callback. - Click Create. Google shows a Client ID
(
....apps.googleusercontent.com) and Client Secret (GOCSPX-...) — keep this tab open, you'll need both in the next step.
In the Supabase dashboard, under Authentication → Sign In / Providers (this section may just be labeled "Providers" in older dashboard versions), scroll to Auth Providers and click Google:
- Paste the Client ID into Client IDs and the Client Secret into Client Secret (for OAuth).
- Leave Skip nonce checks and Allow users without an email off — they're workarounds for platforms Setu doesn't need (native iOS flows, providers that omit email).
- Toggle Enable Sign in with Google on, then Save.
Without this provider setup, the browser will show
Unsupported provider: provider is not enabled.
Authenticate the Supabase CLI and connect it to the development project.
supabase login opens a browser tab to authorize the CLI — run it from an
interactive terminal, not a non-interactive/CI shell:
npx supabase login
npx supabase link --project-ref <project-ref>
npx supabase db push
npx supabase secrets set SETU_APP_ORIGIN=http://localhost:3000
npx supabase functions deploy api
npx supabase functions deploy email-dispatcherComment email delivery
Comments enqueue email rows transactionally in email_outbox. Deploy the
email-dispatcher function with RESEND_API_KEY, EMAIL_FROM, and a shared
EMAIL_DISPATCH_SECRET. Schedule an authenticated POST to the function every
10 minutes using the checked-in Supabase Cron/pg_net job. Store the project
URL in Vault as setu_project_url and the same dispatch secret used by the
function as setu_email_dispatch_secret. The dispatcher claims at most 100 pending
rows, marks successful deliveries as sent, and marks failures as terminal
failed rows; failed mail is intentionally not retried.
If any of RESEND_API_KEY, EMAIL_FROM, or EMAIL_DISPATCH_SECRET is empty,
the dispatcher immediately returns without claiming or sending any queue rows.
Email-domain access control
The Admin Settings page manages the allowed email domains. An empty list keeps
the app open to all domains; adding the first domain switches the app to
allowlist mode. The migration creates the Supabase Before User Created Auth
Hook function. For hosted projects, enable it once in Authentication →
Hooks using the Postgres function
public.restrict_user_by_email_domain (the checked-in supabase/config.toml
contains the equivalent local configuration). A refreshed page is enough for
an existing session to pick up a changed allowlist.
db push applies the checked-in migrations, including the trigger that
creates a profiles row for each new Supabase Auth user. SETU_APP_ORIGIN
is required for the API's CORS policy.
Complete this step before your first Google sign-in. If you already
signed in earlier (e.g. while testing the OAuth setup in step 2, before
db push had run), your auth.users row exists but has no matching
profiles row — see Troubleshooting below.
npm run devOpen http://localhost:3000 and sign in with Google. The dev server uses the same Supabase API transport as the Vercel build.
npm run dev # Start the local frontend
npm run typecheck # Type-check backend and frontend
npm run format:check # Check formatting
npm run build:vercel # Build the Vercel output in web/
npx supabase db push # Apply migrations to the linked project
npx supabase functions deploy apiThere is no mock backend. If the Edge Function reports that an operation has not been migrated, that operation is not implemented yet.
Browser shows "Something went wrong / Failed to send a request to the Edge
Function": the api function isn't deployed to your linked project yet.
Run npx supabase functions deploy api (step 3).
Browser shows "Something went wrong / Edge Function returned a non-2xx
status code": check the function logs in the Supabase dashboard
(Edge Functions → api → Logs). An error there reading Cannot coerce the result to a single JSON object means a .single() query got zero rows —
almost always because the signed-in user has no matching public.profiles
row. This happens if you signed in before running db push, since the
profile-creation trigger only fires for auth users created after the
trigger exists. Fix it by backfilling the missing row(s) for any existing
auth.users from the Supabase dashboard's SQL Editor:
insert into public.profiles (id, email, name)
select u.id, coalesce(u.email, ''),
coalesce(u.raw_user_meta_data ->> 'full_name', u.raw_user_meta_data ->> 'name', '')
from auth.users u
left join public.profiles p on p.id = u.id
where p.id is null;This only inserts rows for users missing a profile, so it's safe to re-run.
New sign-ins after db push don't need this — the trigger handles them
automatically.
If every account has a profile and you still see this error only for an
admin/approver account: the profiles RLS policy lets admins/approvers
read every profile row, so an unfiltered select('*').single() query for
"my own profile" can return more than one row. The Edge Function code
filters explicitly by .eq('id', userId) before .single() for this
reason — if you're extending supabase/functions/api/index.ts, follow the
same pattern rather than relying on RLS alone to narrow a .single() query.
Browser shows "Unsupported provider: provider is not enabled": the Google provider isn't turned on yet, or Client ID/Secret aren't saved — see step 2.
Signing in on a Vercel deployment redirects to localhost instead of the
app: that project's Supabase Auth Site URL is still pointing at the
local dev default. See Deploy to Vercel step 2.
Not required for local development — npm run dev (step 4 above) runs the
full app against your Supabase dev project without Vercel. This section only
applies when you're ready to deploy a preview or production build, and it
typically uses its own Supabase project rather than the shared development
project from step 1.
Set these environment variables for the appropriate Vercel environments (Preview and Production):
SETU_SUPABASE_URLSETU_SUPABASE_PUBLISHABLE_KEY
Vercel uses npm run build:vercel and publishes web/ as configured in
vercel.json.
In the Supabase dashboard, for the project this Vercel deployment uses, go to Authentication → URL Configuration:
- Set Site URL to the deployed domain, e.g.
https://<vercel-domain>. - Add that same URL to Redirect URLs (use
https://<vercel-domain>/**, orhttps://*.vercel.app/**to also cover preview deployments).
Skip this and Google sign-in still completes, but the browser is sent back to
whatever Site URL happens to be set — often http://localhost:3000, left
over from local dev — instead of the Vercel domain.
Also add the Vercel domain to the Google OAuth client from step 2 above:
under Authorized JavaScript origins, add https://<vercel-domain>. The
Authorized redirect URIs entry doesn't need to change — Google always
redirects to the Supabase callback URL, not the app directly.
Vercel only builds and serves web/; the database schema and Edge Function
are deployed separately. Run these against the same project used in step 1
(pass --project-ref <project-ref> if it isn't the CLI's currently linked
project):
npx supabase secrets set SETU_APP_ORIGIN=https://<vercel-domain> --project-ref <project-ref>
npx supabase functions deploy api --project-ref <project-ref>
npx supabase db push --project-ref <project-ref>Skipping this step is the most common cause of a newly connected Vercel deployment showing "Something went wrong / getDashboard: Failed to send a request to the Edge Function" — see Troubleshooting.
The current Edge Function implements whoAmI, getDashboard, and
updateOwnProfile. The remaining CRUD and workflow operations are still being
migrated; the frontend contract remains in shared/types.d.ts so each
operation can be ported incrementally.
The database schema already replaces Sheets-era JSON and comma-separated
fields with relational tables. Image storage is represented by the private
request-images bucket and still needs its complete upload/signing workflow.
The database roles are admin, approver, viewer, and user:
| Role | Access |
|---|---|
admin |
Everything, including settings and role management |
approver |
Requests, approvals, scheduling, and read-only users |
viewer |
All requests and standard app sections except roster |
user |
Own and participant requests; no roster |
New Supabase Auth users receive a profile through the database trigger. Assign the first development administrator explicitly in the Supabase SQL editor:
update public.profiles
set role = 'admin'
where email = 'your-email@example.com';