React Router + Drizzle + SQLite
detailedFull-stack TypeScript implementation using React Router framework mode, Drizzle ORM against SQLite (Turso-ready), better-auth for sessions, and Server-Sent Events for realtime. Deploys as a single process — Fly.io or Railway.
For spec: Slack-like Messaging App
Setup
1. `bun install` 2. `bunx drizzle-kit migrate` 3. `bunx @better-auth/cli migrate` 4. `bun run dev`
Packages
react-router^7— Framework + routingdrizzle-orm^0.34— ORMdrizzle-kit^0.27— Migrationsbetter-auth^1.0— Auth sessionsbetter-sqlite3^11— SQLite driverEnvironment
DATABASE_URLrequired= ./db/app.dbBETTER_AUTH_SECRETrequired32+ char secret
BETTER_AUTH_URLrequired= http://localhost:5173Feature implementations
File Storage
file-storagePresigned S3 uploads; attachments rows reference messages.
Tables
attachments5 fieldsidtext · PKmessage_idtext · → messagess3_keytextmimetextsize_bytesinteger
Routes
/api/uploads/presignauthapp/routes/api.uploads.presign.tsx
200 { url: string; key: string }
Env vars
Messaging
messagingChannels and DMs share a messages table. Realtime via per-channel SSE streams backed by an in-process emitter.
Tables
messages7 fieldsidtext · PKchannel_idtext · nullable · → channelsrecipient_idtext · nullable · → usersender_idtext · → usercontenttextparent_idtext · nullable · → messages — thread rootcreated_atinteger
Exactly one of channel_id / recipient_id must be set — enforced in the action.
channels4 fieldsidtext · PKnametextis_privateinteger · default 0created_atinteger
channel_members3 fieldschannel_idtext · → channelsuser_idtext · → userjoined_atinteger
Routes
/channels/:channelIdauthapp/routes/channels.$channelId.tsx
Params: channelId:string (path)
200 { messages: Message[]; channel: Channel }
/api/messagesauthapp/routes/api.messages.tsx
Params: channelId?:string (body), recipientId?:string (body), content:string (body)
201 { message: Message }
/api/messages/streamauthPer-channel SSE stream of new messages
app/routes/api.messages.stream.tsx
Params: channelId:string (query)
Modules
app/lib/realtime/emitter.ts— In-process EventEmitter for fan-outapp/lib/messaging/service.ts— createMessage / listMessages
Notifications
notificationsNotifications table + SSE stream; falls back to email via Resend for offline users.
Tables
notifications6 fieldsidtext · PKuser_idtext · → userkindtext — e.g. "mention", "dm"payloadtext — JSON blobread_atinteger · nullablecreated_atinteger
Routes
/api/notifications/streamauthSSE; emits notification objects for the current user
app/routes/api.notifications.stream.tsx
Env vars
Roles & Permissions
roles-permissionsWorkspace-scoped roles (owner, admin, member) enforced via a canPerform() helper.
Tables
workspace_members3 fieldsworkspace_idtextuser_idtext · → userroletext — "owner" | "admin" | "member"
Modules
app/lib/rbac/can.ts— canPerform(user, action, resource) helper
Search
searchSQLite FTS5 virtual table over messages.content rebuilt incrementally via triggers.
Tables
messages_ftsVirtual FTS5 table shadowing messages(id, content)
Backfill with `bun run db:fts`; kept in sync by AFTER INSERT/UPDATE/DELETE triggers.
Routes
/searchauthapp/routes/search.tsx
Params: q:string (query)
User System & Auth
user-systembetter-auth with email/password; session cookie middleware per loader.
Tables
user5 fieldsManaged by better-auth — do not hand-edit.
idtext · PKemailtext · uniquenametext · nullableemailVerifiedinteger · default 0createdAtinteger
session4 fieldsidtext · PKuserIdtext · → usertokentext · uniqueexpiresAtinteger
Routes
/auth/loginapp/routes/auth.login.tsx
/api/auth/*better-auth mounted handler
app/routes/api.auth.$.tsx
Modules
app/lib/auth/index.ts— betterAuth() instanceapp/lib/auth/session.ts— getSession / requireSession helpers
Uncovered features
The spec includes features this implementation doesn't describe. Contributions welcome.