Architecture
Understand how Quackback's layers fit together.
Know where everything lives. This overview shows how Quackback's layers fit together: routes, server functions, services, and database. Navigate confidently and make changes in the right places.
High-Level Architecture
┌─────────────────────────────────────────────────────────┐
│ Browser/Client │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Public │ │ Admin │ │ Auth │ │
│ │ Portal │ │ Dashboard │ │ Pages │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└────────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ TanStack Start Server │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Routes │ │ Server │ │ API │ │
│ │ (SSR) │ │ Functions │ │ Routes │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │
│ ┌──────────────┐ ┌────────┴───────┐ ┌─────────────┐ │
│ │ Services │ │ Auth │ │ Events │ │
│ │ Layer │ │ (Better Auth) │ │ System │ │
│ └──────────────┘ └────────────────┘ └─────────────┘ │
└────────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ PostgreSQL │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Drizzle ORM │ │ pgvector │ │ pg_cron │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘Tech Stack
Layer | Technology | Purpose |
|---|---|---|
Framework | TanStack Start | Full-stack React framework |
Routing | TanStack Router | File-based routing, type-safe |
Database | PostgreSQL 18+ | Primary data store |
ORM | Drizzle | Type-safe queries |
Auth | Better Auth | Authentication library |
Styling | Tailwind CSS v4 | Utility-first CSS |
Components | shadcn/ui | UI component library |
Validation | Zod | Runtime type validation |
State | TanStack Query | Server state management |
Runtime | Bun | JavaScript runtime |
Directory Structure
apps/web/
The main application:
apps/web/src/
├── routes/ # File-based routing
│ ├── __root.tsx # Root layout
│ ├── _portal/ # Public portal routes
│ ├── admin/ # Admin dashboard
│ ├── auth.*/ # Portal auth
│ ├── admin.login/ # Team auth
│ ├── onboarding/ # Setup wizard
│ └── api/ # API routes
│
├── components/ # React components
│ ├── admin/ # Admin-specific
│ ├── public/ # Portal components
│ ├── settings/ # Settings UI
│ └── ui/ # shadcn/ui primitives
│
├── lib/ # Business logic
│ ├── server/ # Server-side code
│ │ ├── functions/ # RPC endpoints (server functions)
│ │ ├── domains/ # Service layers by feature
│ │ │ ├── posts/ # Post service
│ │ │ ├── boards/ # Board service
│ │ │ ├── comments/ # Comment service
│ │ │ ├── statuses/ # Status service
│ │ │ ├── tags/ # Tag service
│ │ │ ├── members/ # Member service
│ │ │ ├── users/ # User service
│ │ │ ├── notifications/ # Notification service
│ │ │ ├── subscriptions/ # Subscription service
│ │ │ ├── ai/ # AI integrations
│ │ │ ├── sentiment/ # Sentiment analysis
│ │ │ ├── embeddings/ # Vector embeddings
│ │ │ ├── summary/ # AI post summaries
│ │ │ ├── merge-suggestions/ # AI duplicate detection
│ │ │ ├── changelog/ # Changelog entries
│ │ │ ├── roadmaps/ # Roadmap management
│ │ │ ├── api-keys/ # API key management
│ │ │ └── webhooks/ # Webhook management
│ │ ├── auth/ # Auth configuration
│ │ ├── events/ # Event dispatch & handlers
│ │ ├── integrations/ # Integration handlers
│ │ ├── storage/ # File storage
│ │ └── db.ts # Database proxy (lazy singleton)
│ │
│ ├── client/ # Client-side code
│ │ ├── hooks/ # React hooks
│ │ ├── queries/ # TanStack Query factories
│ │ ├── mutations/ # TanStack mutation hooks
│ │ ├── stores/ # Zustand stores
│ │ └── query/ # Query client setup
│ │
│ └── shared/ # Utilities & errors
│
└── router.tsx # Router configurationpackages/
Shared code:
packages/
├── db/ # Database layer
│ ├── src/
│ │ ├── schema/ # Drizzle table definitions
│ │ ├── client.ts # Connection factory
│ │ ├── seed.ts # Demo data seeding
│ │ └── migrate.ts # Migration runner
│ └── drizzle/ # Migration SQL files
│
├── ids/ # TypeID system
│ └── src/
│ ├── index.ts # ID generation & exports
│ ├── core.ts # Core ID functions
│ ├── prefixes.ts # Entity type prefixes
│ ├── types.ts # Branded type definitions
│ ├── zod.ts # Validation helpers
│ └── drizzle.ts # DB column types
│
├── email/ # Email service
│ └── src/
│ ├── index.ts # Send functions
│ └── templates/ # React Email templates
│
├── core/ # Shared core utilities
│
└── integrations/ # Integration connectorsKey Patterns
Server Functions
Type-safe RPC between client and server:
// lib/server/functions/posts.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
export const createPostFn = createServerFn({ method: 'POST' })
.inputValidator(z.object({
title: z.string().min(1),
content: z.string(),
boardId: boardIdSchema,
}))
.handler(async ({ data }) => {
const auth = await requireAuth()
return createPost(data, auth.member)
})Usage in components:
const mutation = useMutation({
mutationFn: createPostFn,
})Service Layer
Business logic separate from transport:
// lib/server/domains/posts/post.service.ts
export async function createPost(
input: CreatePostInput,
author: Author
): Promise<PostWithDetails> {
// Validation
if (!input.title?.trim()) {
throw new ValidationError('VALIDATION_ERROR', 'Title required')
}
// Business logic
const post = await db.insert(posts).values({
id: createId('post'),
...input,
authorId: author.id,
}).returning()
// Side effects
await dispatchPostCreated(post, author)
return post
}Database Access
Note:
Always use the database proxy from@/lib/server/dbfor database access to ensure proper connection management.
// ✅ Correct - use the proxy
import { db, posts, eq } from '@/lib/server/db'
// ❌ Wrong - bypasses tenant context
import { db } from '@quackback/db'The proxy handles singleton connection initialization with lazy loading.
TypeIDs
Branded UUIDs for type safety:
import { createId, type PostId, type BoardId } from '@quackback/ids'
// Create new ID
const postId: PostId = createId('post')
// => "post_01h455vb4pex5vsknk084sn02q"
// Type-safe function parameters
function getPost(id: PostId): Promise<Post> {
// Can't accidentally pass a BoardId here
}Event System
Domain events write to a durable outbox table, in the same database transaction as the mutation that raised them:
// lib/server/events/emit.ts
export async function emit<P>(tx: Transaction, def: EventDefinition<P>, input: EmitInput<P>) {
// INSERTs one row into `events` on the caller's transaction, so the event
// commits atomically with the mutation. Never enqueues BullMQ directly.
await tx.insert(events).values({ /* ... */ })
}A leader-elected relay (see durable event outbox below) drains unpublished rows into BullMQ, which fans out to webhooks, notifications, and workflow triggers. The request that raised the event never waits on hook delivery.
Hook Handlers
Extensible integration points:
// lib/server/events/handlers/webhook.ts (or similar handler file)
export const slackHandler: HookHandler = {
async run(event, target, config) {
const message = formatSlackMessage(event)
await slack.chat.postMessage({
channel: config.channelId,
...message,
})
return { success: true }
},
}Route Groups
Public Portal (_portal/)
User-facing feedback portal:
- Board listing
- Post detail with comments
- Voting interface
- Public roadmap
Admin Dashboard (admin/)
Team management interface:
- Feedback inbox
- Roadmap management
- Settings configuration
Authentication
Two separate auth flows:
auth.*- Portal users (password, email OTP, OAuth, OIDC)admin.login- Team members (password, email OTP, OAuth, OIDC)
Data Flow
Create Post Flow
User clicks "Submit" in portal
│
▼
Component calls createPostFn()
│
▼
Server function validates input with Zod
│
▼
requireAuth() checks session
│
▼
createPost() service function
│
▼
Insert into database via Drizzle
│
▼
dispatchPostCreated() event
│
▼
processEvent() runs hooks (async)
│
├──▶ Slack notification
├──▶ Email to subscribers
└──▶ AI sentiment analysis
│
▼
Return post to client
│
▼
React Query updates cacheDeployment
Quackback runs as a single workspace per deployment:
DATABASE_URLenvironment variable- Singleton database connection
- No tenant resolution needed
Process roles
Background processing doesn't have to share a process with HTTP serving. Every Quackback process reads QUACKBACK_ROLE (all, web, or worker) to decide whether it constructs BullMQ workers and runs the periodic sweepers, independent of whether it serves HTTP. The single-container default (all) does both in one process; splitting web and worker replicas lets you scale HTTP capacity without also scaling queue consumption, or vice versa. See Scale with multiple replicas.
Durable event outbox
Domain events (post.created, conversation.message.created, and so on) are written to an events outbox table in the same transaction as the mutation that raised them, so an event can never exist without the change it describes, and never gets lost to a crash between the two. A leader-elected relay process (worker-role, elected via a Postgres advisory lock so multiple worker replicas stay safe) polls the outbox and fans each row out into BullMQ jobs for webhooks, notifications, and workflow triggers, then marks the row published. This is the sole delivery path for events; nothing enqueues BullMQ directly.
Next Steps
- Server Functions - Deep dive into RPC patterns
- Database - Schema and migrations
- Adding Features - Build new functionality
Was this helpful?
Your feedback shapes what we write next.