Skip to content

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.

Terminal window
git clone https://github.com/bounda-dev/bounda
cd bounda && pnpm install && pnpm build && pnpm generate
cd examples/onboarding
pnpm test
pnpm dev
  1. The /register action dispatches registerUser and redirects to /users/:userId. The page already shows the user: the app in the context reads its own writes.
  2. UserRegistered starts the process user-onboarding. Its handler schedules sendWelcomeEmail a minute later; when it runs, the emailSender collaborator sends the email (email-sender.console in the demo, email-sender.memory in the tests) and WelcomeEmailSent is appended.
  3. Activating the user completes the process. A registration nobody activates within a week hits the process time-out, which dispatches expireRegistration.
  4. Two read models follow along: users-directory, paginated by listUsers, and user-details, with the timestamps of every step.

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.