My SaaS starter kit has two builds. One ships to customers. One powers the public demo. They come from the same source tree, but the demo gets its own vite config, and that config has a comment at the top that says, in effect: temporary, demo only, do not commit.
Here it is, the whole thing:
// TEMPORARY build config for the /demo/ GitHub Pages deploy ONLY.
// Externalizes vendor deps (loaded via importmap from esm.sh) so the app
// bundle stays small. NOT for the shipped product — do not commit.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
base: '/laravel-react-saas-starter-kit/demo/',
build: {
outDir: 'dist-demo',
rollupOptions: {
external: ['react', 'react/jsx-runtime', 'react-dom',
'react-dom/client', 'react-router-dom', 'axios'],
},
},
});
Twenty lines. Three decisions worth stealing.
Decision one: the demo build externalizes vendor dependencies. The demo runs on GitHub Pages with base set to the /demo/ subpath, and react, react-dom, react-router-dom and axios are declared external so they load from an import map (esm.sh) instead of being bundled. The app bundle stays small, which matters when your demo is the first impression and the visitor is on a phone connection. The shipped product does none of this; its normal vite config bundles everything the usual way.
Decision two: the demo config lives next to the real one, not inside it. I did not add if (demo) branches to the shipped vite.config.js. Demo needs are not product needs. The day a demo special-case lands in the shipped config, every future customer build pays the tax: extra conditionals to read, extra ways for a demo flag to leak into production output. Two configs, one of them labelled disposable, keeps the product's build pipeline honest.
Decision three: the config is honest about being disposable. The comment says do not commit it. In practice that means the demo deploy is a local, manual step: build with this config, push dist-demo to the /demo/ path, and the repo's tracked build config stays the clean one. Nobody inherits a mysterious demo hack six months later and wonders whether it is load-bearing.
How does the app know it is the demo? A build-time flag. The axios client checks it:
const client = isDemoMode() ? createDemoClient() : axios.create({
baseURL: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api`,
headers: { Accept: 'application/json' },
});
VITE_DEMO_MODE=true swaps the real Laravel API for an in-memory mock that mirrors the endpoints and response shapes, so the preview is fully clickable with no backend. The product default is the real API. One seam, one flag, and the demo config never touches a line of application code.
The pattern generalises: anything you build to sell the thing should be physically separated from the thing itself. Demos rot faster than products. When the demo is a labelled, temporary config rather than conditionals woven through your build, deleting it later is a one-line act of hygiene instead of archaeology.
I write about real patterns from client work and the starter kit I sell. If the behind-the-scenes of a $19 boilerplate interests you, the whole thing is here: Laravel + React SaaS Starter Kit
Top comments (0)