The onboarding example
examples/onboarding in the repository is a React Router 8 app on Bounda. It runs on SQLite out
of the box and on PostgreSQL when DATABASE_URL is set.
git clone https://github.com/bounda-dev/boundacd bounda && pnpm install && pnpm build && pnpm generatecd examples/onboardingpnpm testpnpm devWhat happens when a user registers
Section titled “What happens when a user registers”- The
/registeraction dispatchesregisterUserand redirects to/users/:userId. The page already shows the user: the app in the context reads its own writes. UserRegisteredstarts the processuser-onboarding. Its handler schedulessendWelcomeEmaila minute later; when it runs, theemailSendercollaborator sends the email (email-sender.consolein the demo,email-sender.memoryin the tests) andWelcomeEmailSentis appended.- Activating the user completes the process. A registration nobody activates within a week hits
the process time-out, which dispatches
expireRegistration. - Two read models follow along:
users-directory, paginated bylistUsers, anduser-details, with the timestamps of every step.
Things worth copying
Section titled “Things worth copying”The integration in one line. vite.config.ts adds bounda() before reactRouter(); the
plugin generates the types and serves @bounda-dev/react-router/app, which root.tsx and the
routes import. Nothing in the app knows how Bounda boots.
Storage from the environment. bounda.config.ts picks the adapter:
const url = process.env.DATABASE_URL;
export default defineConfig({ storage: url === undefined ? sqlite({ path: "./data/onboarding.db" }) : postgresql({ url }), commands: { sendWelcomeEmail: { emailSender: { use: process.env.EMAIL_SENDER ?? "console" } }, },});A paginated query with defaults. Fields with .default() are optional for callers and always
present in the repository and the handler:
export const payload = ({ z }: Query.PayloadArgs) => z.object({ page: z.int().positive().default(1), pageSize: z.int().positive().max(100).default(20), });
export const repository = async ({ table, page, pageSize }: Query.RepositoryArgs) => { const [users, total, active] = await Promise.all([ table.findMany({ orderBy: { field: "registeredAt", direction: "desc" }, limit: pageSize, offset: (page - 1) * pageSize, }), table.count(), table.count({ status: "active" }), ]); return { users, total, active };};The loader calls listUsers({ page }) and the component gets users, total, active, page
and pages typed.
Domain errors as form feedback. app/errors.server.ts turns ValidationError into a 400 with
the issues and DomainError into a 409; the route components render actionData.error.
Time in tests. tests/onboarding.test.ts registers a user, advances the clock a minute and
checks the welcome email was sent; advances a week and checks the registration expired:
clock.advance(7 * DAY);await app.processUntilIdle();expect(await app.queries.getUserDetails({ userId })).toMatchObject({ status: "expired" });The domain is tested on the in-memory adapter; the web app is compiled in CI with
react-router typegen, tsc and react-router build.