Skip to content

Build and Package

How the React and TS code in src/ ends up in dist/, where the VITE_* exposure boundary in import.meta.env is enforced, how the zod check in src/shared/config/env.ts doubles as a build-time gate, and how api/functions/ rides on a separate deploy track. The FSD tree set up in architecture meets the bundle here and turns into something a static host can serve.

graph LR
src[src/ — Vite SPA]
vite[vite build]
dist[dist/]
hosting[static hosting]
api[api/functions/]
cli[supabase functions deploy]
edge[Supabase Edge runtime]
src --> vite --> dist --> hosting
api --> cli --> edge

src/ goes through Vite. pnpm build drops dist/. That folder lands on Vercel, Netlify, Cloudflare Pages, or a self-hosted setup.

api/functions/ is unrelated to Vite. supabase functions deploy <name> ships one Deno function at a time to the Supabase project. The build artifact lives inside Supabase’s infrastructure rather than on disk.

The two tracks are separated because the runtimes are different. The SPA runs in a browser; Edge Functions run on Deno. Tying them to the same build pipeline is how the service_role key leaks into the SPA bundle, or how a browser API ends up inside a Deno function.

Terminal window
pnpm build # production build into dist/
pnpm preview # preview dist/ locally

What pnpm build drops into dist/ is a static file bundle. One index.html, vendor chunks, route chunks, the CSS bundle, and the static assets. Because the SPA has one entry point, the build artifact follows the shape of a single entry chunk plus a vendor chunk plus per-route lazy chunks.

In that state, anything that can serve static files — nginx, S3 plus CloudFront, Vercel, Netlify — can host it.

import.meta.env and the VITE_ exposure boundary

Section titled “import.meta.env and the VITE_ exposure boundary”

Vite’s environment-variable exposure is decided by prefix. Only variables starting with VITE_ are exposed to the client bundle.

// reachable from the client
import.meta.env.VITE_SUPABASE_URL // ok
import.meta.env.VITE_SUPABASE_ANON_KEY // ok
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY // ok
// undefined on the client
import.meta.env.SUPABASE_SERVICE_ROLE_KEY // undefined
import.meta.env.STRIPE_SECRET_KEY // undefined
import.meta.env.STRIPE_WEBHOOK_SECRET // undefined

A server-only secret like SUPABASE_SERVICE_ROLE_KEY that slips into client code shows up as undefined through import.meta.env. The exposure boundary is baked into the variable name itself.

Where those variables genuinely belong — inside an Edge Function — they get reached through Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'). Supabase injects those variables from its own infrastructure when the function deploys.

src/shared/config/env.ts validates env at build entry with zod. Missing values fail the build immediately.

import { z } from 'zod';
const Env = z.object({
VITE_SUPABASE_URL: z.string().url(),
VITE_SUPABASE_ANON_KEY: z.string().min(20),
VITE_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
VITE_API_MODE: z.enum(['edge-functions', 'hono']).default('edge-functions'),
});
export const env = Env.parse(import.meta.env);

The moment Env.parse(...) executes is wherever something in the bundle first imports env. If static evaluation does not reach that line at build time, the failure lands on the first runtime entry — still before the user’s screen renders, which means it shows up as a failed deploy rather than a live incident.

Constraints like startsWith('pk_') pin the prefix on the key. A value that does not start with pk_test_ or pk_live_ (because someone accidentally pasted a secret key in that slot, for instance) gets caught by zod. That guards against the case where the right variable name carries the wrong key.

Per-route chunk size sits at “under 200 KB gzip” to score 5 on the build dimension of review-saas. Once the initial chunk crosses 500 KB the score drops to 3.

pnpm build prints the per-chunk size in the console. When a chunk looks heavy, the bundle analyzer shows which dependency is sitting inside.

Terminal window
pnpm build
# or
pnpm dlx vite-bundle-visualizer

Vite’s default already handles per-route code splitting. Importing a page component through React.lazy makes the chunk drop at route entry. The route tree itself is generated by phase 5, and the lazy import pattern sits inside it.

Three dependencies are the usual heavy hitters.

  • A chart library (recharts, chart.js) imported into a marketing page. Keep those inside the dashboard chunk.
  • DOMPurify or marked-style sanitize/parse libraries imported into the entry chunk. Lazy load instead.
  • The Stripe SDK’s elements package imported outside the checkout page. Keep it inside features/checkout only.
Terminal window
supabase functions deploy checkout-session
supabase functions deploy customer-portal
supabase functions deploy stripe-webhook

Each function uses api/functions/<name>/index.ts as the entry. supabase functions deploy <name> bundles that directory as a Deno bundle and ships it to the Supabase Edge runtime.

Env injection happens through the Supabase dashboard or the CLI.

Terminal window
supabase secrets set STRIPE_SECRET_KEY=sk_live_...
supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_...
supabase secrets list

supabase secrets set stores the secret on the Supabase project itself, and a function reaches it through Deno.env.get('STRIPE_SECRET_KEY'). Nothing in .env* files plays into this.

Running Edge Functions locally is different.

Terminal window
supabase functions serve --env-file ./api/functions/.env.local

The local .env.local belongs in .gitignore. Only .env.example ships in git. Security baseline S2.

What to confirm right before deploy.

VariableGoes intoSet where
VITE_SUPABASE_URLClient bundleHosting env (Vercel, Netlify, Cloudflare Pages)
VITE_SUPABASE_ANON_KEYClient bundleHosting env
VITE_STRIPE_PUBLISHABLE_KEYClient bundleHosting env
VITE_API_MODEClient bundle (adapter selection)Hosting env
SUPABASE_SERVICE_ROLE_KEYEdge Function onlysupabase secrets set
STRIPE_SECRET_KEYEdge Function onlysupabase secrets set
STRIPE_WEBHOOK_SECRETEdge Function only (webhook function)supabase secrets set

This table feeds into the four hosting branches in distribution. How each host injects env is where they diverge.

The .env.example shipping in the base is intentional, and any extra variables for your domain (third-party API keys, for instance) get appended to the same file by /5-customize-saas during phase 5. This is one of the spots that fills in when you fork.

After pnpm build drops dist/ and supabase functions deploy ships a function, the base’s automation reaches its limit. Choosing which host gets the bundle, attaching a custom domain, registering the Stripe production webhook endpoint — those are all outside the tool.

The docs/deployment/DEPLOYMENT.md that the deploy-saas skill drops alongside dist/ is a decision memo, not an automated handoff to anyone or anywhere.

The boundary is deliberate. Hosting consoles, Stripe production keys, and DNS all expect a human identity to act, and any automation that crosses that line drags secret leaks and misconfigured production webhooks with it. The next section walks through the four hosting paths and how the Stripe production webhook gets registered.

Distribution comes next. The trade-offs between Vercel, Netlify, Cloudflare Pages, and self-host; the env-injection commands and custom domain steps for each; the CSP connect-src declaration; registering the Stripe production webhook; and splitting the Supabase production and staging projects.