Skip to content

Distribution

This section assumes pnpm build already dropped dist/, and supabase functions deploy already shipped your Edge Functions into the Supabase project. From here on, we look at the four paths that move those static artifacts and functions to a user’s browser.

build-and-package was where the tool stopped doing things automatically. Picking a host, attaching a custom domain, registering the Stripe production webhook, declaring CSP connect-src — none of that is automated. Production keys, DNS, and payment consoles all expect a human identity to act, and any automation that crosses that line drags leaks or misconfigured production endpoints behind it.

PathAuto-buildEnv injectionEdge FunctionsBest fit
VercelOn git pushDashboard or vercel envOn SupabaseDefault recommendation, least overhead
NetlifyOn git pushDashboard or netlify env:importOn SupabaseVercel alternative
Cloudflare PagesOn git pushDashboard or wrangler pages secretOn Supabase or Cloudflare WorkersEdge infrastructure integration
Self-hostManual or CIHosting environment directlyOn SupabaseWhen corporate policy blocks external hosting

The four paths are not exclusive. Staging on a Vercel preview deploy and production on self-host is a fine split. Migrating off one onto another is also common.

Before the host choice, the call to make first is splitting the Supabase project. Running production and staging on the same project means one wrong line in an RLS policy lands directly on real user data.

Supabase organization
├── saas-base-production ← real users, real Stripe live key
└── saas-base-staging ← developer testing, Stripe test key

Each project carries its own SUPABASE_URL, ANON_KEY, SERVICE_ROLE_KEY, and STRIPE_*_KEY. Production env on the host points to the production Supabase project, and the preview/staging env points to the staging one.

The migrations apply the same SQL to both.

Terminal window
supabase link --project-ref staging-ref
supabase db push # staging first
# verify, then
supabase link --project-ref production-ref
supabase db push # then production

The rule from architecture about never building policies through the UI sits right here. The same migration has to land in both environments identically, or drift starts leaking in.

Terminal window
vercel link # link the project
vercel env pull .env.local # pull remote env into local
vercel deploy --prod # production deploy

After one vercel link, every git push ships a preview deploy on its own. Production deploy comes from pushing to the main branch or an explicit vercel deploy --prod.

Env injection lives in the dashboard under Settings → Environment Variables, or through the CLI.

Terminal window
vercel env add VITE_SUPABASE_URL production
vercel env add VITE_SUPABASE_ANON_KEY production
vercel env add VITE_STRIPE_PUBLISHABLE_KEY production

SUPABASE_SERVICE_ROLE_KEY and STRIPE_SECRET_KEY do not go here. Both belong on the Edge Function side through supabase secrets set. Putting them on the hosting env, even if the SPA bundle never sees them, opens a build-time exposure risk.

Custom domain lives in the dashboard under Domains. One DNS A or CNAME record, and Vercel issues the SSL certificate on its own.

Terminal window
netlify link
netlify env:import .env.production
netlify deploy --prod

netlify env:import swallows .env.production wholesale. .env.production carries only production keys and stays in .gitignore. Deleting that file after import is the safer move.

The build command sits in netlify.toml.

[build]
command = "pnpm build"
publish = "dist"

Custom domain and SSL follow the same path as Vercel.

Terminal window
wrangler pages deploy dist

Cloudflare Pages auto-deploys on git push, or you can push manually with wrangler pages deploy dist. Both routes work fine.

Env injection lives in the dashboard or in wrangler.

Terminal window
wrangler pages secret put VITE_SUPABASE_URL --project-name=saas-base

The Cloudflare advantage is integration with Workers. If you want to move Edge Functions off Supabase onto Cloudflare Workers, swap to the Hono adapter under api/adapter-hono/. Setting VITE_API_MODE=hono flips the client to fetch('/api/...') calls that resolve through Workers routes.

That branch gets picked in phase 4 (/4-design-saas) at the API adapter decision step. The decision lands in .claude/state/api-adapter.json. Once it is set, swapping later means changing the adapter in one place while the features code does not move.

Drop the dist/ from pnpm build onto nginx, S3 plus CloudFront, or your own static-hosting infrastructure.

Terminal window
# example: nginx
sudo cp -r dist/* /var/www/saas-app/
sudo nginx -s reload
# example: S3 + CloudFront
aws s3 sync dist/ s3://saas-app-bucket/
aws cloudfront create-invalidation --distribution-id <id> --paths "/*"

For auto-deploy, run the same script from GitHub Actions or GitLab CI. The env lives in the CI’s secrets.

Two extra seats matter for self-host: SPA fallback and cache policy. SPAs use client-side routing, so a direct hit on /app/settings needs nginx to return index.html.

location / {
try_files $uri $uri/ /index.html;
}

For caching, index.html with no-cache plus JS/CSS chunks with long-cache (immutable, one year) is the safer combination. Vite hashes the chunk filenames, so a new deploy ships different filenames anyway.

Declare connect-src in <meta http-equiv="Content-Security-Policy"> inside index.html. Arbitrary external URL fetches get blocked. Security baseline S8.

<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
connect-src 'self'
https://*.supabase.co
https://api.stripe.com
https://m.stripe.network;
script-src 'self' https://js.stripe.com;
frame-src https://js.stripe.com https://hooks.stripe.com;
img-src 'self' data: https:;
">

Stripe Checkout opens through js.stripe.com and hooks.stripe.com, which is why both are listed in script-src and frame-src. If Supabase realtime is in use, add wss://*.supabase.co to connect-src.

Measurement tools (Sentry, PostHog, Mixpanel) are yours to add. Which one lands gets decided in phase 4, and this is one of the spots that fills in when you fork.

The Stripe production webhook gets registered once.

  1. Stripe dashboard → Developers → Webhooks → Add endpoint.
  2. Endpoint URL: https://<project-ref>.supabase.co/functions/v1/stripe-webhook.
  3. Events to send: checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed.
  4. Copy the Signing secret (whsec_...) shown right after registration.
  5. Store it as a Supabase secret.
Terminal window
supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_...

The stripe.webhooks.constructEvent(body, sig, secret) call inside the stripe-webhook Edge Function verifies the signature against this secret. A mismatch rejects every event. The most common cause of “webhook arrives, DB never updates” sits right there.

A production trigger like stripe trigger is in the no-auto-run list (S5). It only runs when the user calls it on purpose. Locally, stripe listen --forward-to localhost:54321/functions/v1/stripe-webhook forwards events, and production only handles real payment events — that combination is the safer one.

The real list of events and which tables get updated by them depends on your pricing model. The six above are the standard seat for a subscription SaaS; one-time payments or usage-based billing need different events. This is one of the spots that fills in when you fork.

build-and-package already had one. Here it is again, split by hosting.

  • Client (VITE_*): on the hosting env for Vercel, Netlify, or Cloudflare Pages. On the build environment’s env for self-host.
  • Server (SUPABASE_SERVICE_ROLE_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET): in the Supabase project through supabase secrets set. Never on the hosting env.

What matters more than anywhere else is not mixing the production and staging keys. If a preview deploy points at the production Stripe key, test payments turn into real ones. Split the Stripe test keys (pk_test_..., sk_test_...) and the live keys (pk_live_..., sk_live_...) by environment, confirm which one is in the env one more time, and only then move into a production deploy.

That closes the saas-base expert tier. Intro named the three guardrails, architecture covered the FSD six layers, the API adapter, the RLS SSOT, and the three-step Stripe split. Verification covered the six-dimension review and the S1 through S8 baseline. Build-and-package showed the two-track build. This section split the four paths that move those artifacts to a user’s browser.

Step-by-step walkthroughs, screenshots, and hosting console entries for non-developer readers live in the Beginner manual, where the same topics are paced differently.

Updates comes next. How the /update-from-base channel pulls in new skills, updated hooks, and AI_AUTOMATION.md changes from the base into your fork, plus the area split between base-owned, user-owned, and mixed.