1. Set it up
bun create theoven todo-api --db sqlite --auth basiccd todo-apibun install-
Set a signing secret. The scaffold refuses to boot without one, on purpose — a framework that invents a secret has invented one every deployment shares.
Terminal window cp .env.example .envOpen
.envand put something inAUTH_SECRET:Terminal window # macOS / Linuxopenssl rand -base64 32 -
Create the database.
Terminal window bun run db:generate # writes a migration from your schemabun run db:migrate # applies it -
Run it.
Terminal window bun run dev
You now have a working API at http://localhost:3000, with browsable documentation at
/docs.
curl -X POST localhost:3000/auth/signup \ -H 'content-type: application/json' \That returns a user and an access token. You have not written any code.
What you got
Section titled “What you got”src/ app.ts builds the app — every brick registered here index.ts starts the server env.ts configuration, read and validated once at boot db.ts the database brick client.ts the connection, shared auth.ts the auth provider mail.ts the mail driver schema.ts your tables routes/ the route table — the filesystem *is* the routerapp.ts is the whole configuration
Section titled “app.ts is the whole configuration”export const app = createApp({ logLevel: config.logLevel }) .use(requestLogger()) .use(securityHeaders()) .use(database) .use(mail(driver)) .use(auth(provider)) .use(openapi({ info: { title: 'todo-api', version: '0.1.0' } }))
await loadRoutes(app, `${import.meta.dir}/routes`)Six lines, and each one is a feature. .use(auth(provider)) is what mounted those eight
/auth/* endpoints. .use(database) is what will put a typed client on every request.
Leave one out and the thing it provides becomes a compile error wherever you use it — not a crash at 3am.
index.ts is separate on purpose
Section titled “index.ts is separate on purpose”import app from './app'
await app.listen(config.port)app.ts builds and exports; index.ts listens. That split is what lets oven routes,
oven openapi and your tests import the app without binding a port. Keep it.
One connection, shared
Section titled “One connection, shared”export const sqlite = new Database(config.databaseUrl)export const client = drizzle(sqlite, { schema })export const database = db(drizzleSqlite({ client: sqlite, schema }))auth-basic builds its user store at construction — before an app exists — so it needs a client
of its own. The database brick adopts that same connection rather than opening a second.
env.ts fails loudly
Section titled “env.ts fails loudly”function read() { return { port: env.port('PORT', 3000), databaseUrl: env.string('DATABASE_URL', './data.db'), authSecret: env.string('AUTH_SECRET'), }}env.string('AUTH_SECRET') has no default, so a missing one fails at startup with a message
naming the variable — rather than at 3am as undefined somewhere unrelated.
Two things to try before moving on
Section titled “Two things to try before moving on”The API reference at /docs is generated from your routes. It
already lists every auth endpoint. As you add schemas in the next chapter, it fills in.
The mail inbox at /_oven/mail. Try a password reset:
curl -X POST localhost:3000/auth/forgot-password \ -H 'content-type: application/json' \The reset link appears in that inbox and in your terminal, because mail defaults to the console driver. The whole flow works before you have signed up for a mail provider.
Clear the decks
Section titled “Clear the decks”The scaffold ships an example users resource to show the shape. You are building todos, so
delete it:
rm -rf src/routes/users src/routes/health.get.ts