Starter templates
Every starter renders the same generic business site: marketing sections, a storefront, cart and Stripe checkout, a blog, a reviews wall, programmatic SEO pages, and a custom-database demo. What changes is the platform's constraints.
Pick your framework with the picker in the navbar — every code example across these docs follows your choice.
git clone --depth 1 https://github.com/businessdash/SDK-Starter-Templates.git my-app
cd my-app/React-Bun
cp .env.example .env
pnpm installThe three shapes
A starter is one of three things, and which one it is decides how it holds a key, what it can call, and whether it needs a server at all.
Web — the SDK does the work
Angular · Astro · HTMX · Next.js · Nuxt · Qwik · React · Remix · Solid · SvelteKit · T3 · TanStack Start · Vanilla JS
These import @businessdash/sdk directly. Server-rendered pages hold the secret
key server-side; anything the browser touches goes through a same-origin
proxy route or a publishable token.
Server — a hand-written client, full API
Laravel (PHP) · Phoenix (Elixir) · Vapor (Swift)
The SDK is TypeScript, so these talk to the Package API directly — a job that turns out to be small, because the surface is plain REST with a bearer key. Each is roughly 600–900 lines for everything a starter needs.
They hold a secret key: it never leaves the process, so the whole API is available.
The parts that would be genuinely hard to port aren't ported. The
<bd-form> web component and the analytics beacon load from a CDN as ES
modules, and the schema CLI runs as a dev-only dependency — so the canonical
checksum stays in one place rather than being reimplemented byte-for-byte in
three languages.
Mobile — publishable token only
React Native (Expo) · Swift (iOS/macOS) · Flutter · Kotlin (Android)
An app ships its credential inside the artifact. strings on an .ipa or
.apk finds anything you bundle, so every mobile client here refuses a secret
key at construction.
That is far less limiting than it sounds, and it is the single most useful thing to know about building a BusinessDash app:
The publishable scope set covers the entire customer-facing surface — storefront, cart, checkout, blog, marketing content, chat, forms, the whole customer portal, sign-in, and public custom-object reads. A BusinessDash mobile app needs no backend-for-frontend. Only operator and admin writes require a secret key, and a customer app never makes those.
Four rules that bite everyone
These are platform-independent, and every starter encodes them.
1. The access gate answers reads with HTTP 200
A read against a lapsed plan returns 200 with available: false in the
body, not a 4xx. Reads use a body-only signal so a cached CDN response
can't hard-fail a page — which also means a client that only inspects the
status code decodes an empty screen and never notices.
Check the body before the status.
2. Every list response is keyed items
Not products, posts, reviews, subscriptions, comments, or
categories. Reading the wrong key yields nothing rather than an error, so a
page renders "no products" against a full catalog and nothing looks broken.
A single blog post also arrives wrapped — { post, access }, where
access is granted or paywall. A paywalled post comes back truncated
rather than absent, so ignoring the flag renders a teaser as the whole
article.
3. Money has two shapes
Product, subscription, invoice and checkout totals are integer cents — the platform-wide convention for stored money, so no total picks up a floating-point rounding error on the way to Stripe.
Cart unitPrice and subtotal arrive decimal.
Every starter ships two separate formatting functions rather than one overloaded helper, because mixing them is a 100× error in either direction.
4. The cart visitor token is generated locally
There is no endpoint that mints one — the platform keys the cart on whatever
arrives in X-BD-Cart-Visitor. (cart/session exists, but it mints a
tokenized iframe-embed URL: a different feature.)
It is an opaque id, not a secret. Ordinary storage is correct for it; the session token is the one that belongs in a Keychain or SecureStore.
Checkout returns the Stripe URL as stripeUrl, not url.
The CLI is the same everywhere
Schema push, data-model push, and seeding go through bd — a Node
CLI that talks to the same REST API. It is identical for every starter,
including the ones with no JavaScript in them at all.
npm run sync-schema # push bd.config.ts to the draft slot
npm run sync-data-model # push bd.data-model.config.ts (+ generated forms)
npm run view-data-model # what's LIVE right now — the dashboard can change it too
npm run seed # upsert rows from ./bd-records
Nothing changes on the platform until you promote the draft in the
dashboard. sync-* prints the migration plan first.
Why the config files stay TypeScript
The schema artifact carries a canonical SHA-256 that the server recomputes and compares. Reimplementing the canonicalisation in PHP, Elixir, Swift, Dart, or Kotlin would mean matching it byte for byte — and a silent mismatch rejects every push.
So the config stays TypeScript and the real CLI computes the checksum. Every
non-JS starter carries a small package.json whose only job is that: Node is
a build-time tool, never a runtime dependency. A Laravel app in production
has no Node anywhere near it.
The scripts run the CLI through tsx, so the TypeScript config loads on any
supported Node version. Node 22.6+ can strip types natively, but relying on
that would break on older runtimes — and the failure message ("Could not
load TypeScript config") doesn't obviously point at your Node version.
Seeding is plain JSON
sync-records reads ./bd-records/*.json, so seed data is completely
language-neutral — byte-identical across every starter:
{
"object": "todos",
"universalIdentifier": "todo-seed-welcome",
"values": { "title": "Welcome", "done": false },
"relations": { "todo": "<target universalIdentifier>" }
}
Two things worth understanding:
universalIdentifier is identity, not a name. It is what makes re-running
seed converge rather than duplicate — rows are upserted against it.
relations link by the target row's universalIdentifier, not by a
database id you don't have yet. That's what lets one push reference rows it is
creating in the same run.
Seeds land in objects that must already exist, so the order is: sync-data-model
→ promote in the dashboard → seed.
Platform notes
Remix is React Router 7
The Remix starter runs on React Router 7 framework mode (as of 0.9.60) —
imports come from react-router, routes are declared through
@react-router/fs-routes with the same flat-file convention, and the dev/build
commands are react-router dev / react-router build. If you cloned it
earlier as a Remix v2 app, the React Router upgrade
guide covers the same migration the
starter went through.
React Native imports /native
Expo is the only mobile starter that reuses the SDK — React Native is
JavaScript, after all. It must import @businessdash/sdk/native, not the root:
the root re-exports the auth handler, which uses Node's Buffer. The call is
inside a function, so the root import appears to work and then throws in
production.
Metro also needs unstable_enablePackageExports before any subpath export
resolves.
Chat is polling everywhere
There is no SSE or WebSocket anywhere in the Package API. Front Desk chat is
chatbot/messages every 3–5 seconds while the widget is open.
The Phoenix starter is the only one that improves on this: one GenServer polls per conversation and fans results out over PubSub, so N viewers collapse to one upstream request. Every other starter polls per client.
Forms are the one thing an app reimplements
<bd-form> is a DOM custom element. Web and server starters mount it from a
CDN and get conditional blocks, availability pickers and file uploads for
free. Mobile starters read the schema and render it with native components —
the schema is typed, but the renderer is yours.
What each starter assumes
| Starter | Key | Client | Notes |
|---|---|---|---|
| Web (13 frameworks) | secret + publishable | @businessdash/sdk | Proxy route for browser calls |
| Laravel | secret | hand-written PHP | CDN islands, npx for the CLI |
| Phoenix | secret | hand-written Elixir | ⚠️ raw body required for the webhook HMAC |
| Vapor | secret | hand-written Swift | ⚠️ folder must not be named Vapor |
| React Native (Expo) | publishable | @businessdash/sdk/native | EXPO_PUBLIC_ prefix |
| Swift | publishable | BdKit (SPM) | Session token → Keychain |
| Flutter | publishable | packages/bd (pure Dart) | Config via --dart-define |
| Kotlin | publishable | :bd (pure JVM) | Config via local.properties |
Each starter's README carries the platform-specific traps in full — the Phoenix raw-body reader, the Vapor directory-name collision, the Metro resolver flag. Read it before the first commit, not after the first bug.
Where they live
All starters live in one repository: bd-sdk-starters. Clone the whole thing and use the directory you need — each one is self-contained.