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.
Two tracks
Section titled “Two tracks”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 --> edgesrc/ 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.
Vite SPA build
Section titled “Vite SPA build”pnpm build # production build into dist/pnpm preview # preview dist/ locallyWhat 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 clientimport.meta.env.VITE_SUPABASE_URL // okimport.meta.env.VITE_SUPABASE_ANON_KEY // okimport.meta.env.VITE_STRIPE_PUBLISHABLE_KEY // ok
// undefined on the clientimport.meta.env.SUPABASE_SERVICE_ROLE_KEY // undefinedimport.meta.env.STRIPE_SECRET_KEY // undefinedimport.meta.env.STRIPE_WEBHOOK_SECRET // undefinedA 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.
env.ts zod check
Section titled “env.ts zod check”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.
Route chunk size targets
Section titled “Route chunk size targets”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.
pnpm build# orpnpm dlx vite-bundle-visualizerVite’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/checkoutonly.
Edge Functions deploy
Section titled “Edge Functions deploy”supabase functions deploy checkout-sessionsupabase functions deploy customer-portalsupabase functions deploy stripe-webhookEach 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.
supabase secrets set STRIPE_SECRET_KEY=sk_live_...supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_...supabase secrets listsupabase 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.
supabase functions serve --env-file ./api/functions/.env.localThe local .env.local belongs in .gitignore. Only .env.example ships in git. Security baseline S2.
Environment variable checklist
Section titled “Environment variable checklist”What to confirm right before deploy.
| Variable | Goes into | Set where |
|---|---|---|
VITE_SUPABASE_URL | Client bundle | Hosting env (Vercel, Netlify, Cloudflare Pages) |
VITE_SUPABASE_ANON_KEY | Client bundle | Hosting env |
VITE_STRIPE_PUBLISHABLE_KEY | Client bundle | Hosting env |
VITE_API_MODE | Client bundle (adapter selection) | Hosting env |
SUPABASE_SERVICE_ROLE_KEY | Edge Function only | supabase secrets set |
STRIPE_SECRET_KEY | Edge Function only | supabase secrets set |
STRIPE_WEBHOOK_SECRET | Edge 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.
Where automation stops
Section titled “Where automation stops”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.