Architecture
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 │ │ Job queue │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
Tech Stack
| Layer | Technology | Purpose |
|---|---|---|
| Framework | TanStack Start | Full-stack React framework |
| Routing | TanStack Router | File-based routing, type-safe |
| Database | PostgreSQL 14+ | Data, job queue, cache, and realtime (LISTEN/NOTIFY) |
| 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 configuration
packages/
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 connectors
Key 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
Always use the database proxy from @/lib/server/db for 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, plus a dispatch job on the same transaction.
await tx.insert(events).values({ /* ... */ })
}A dispatch job on the Postgres job queue (see durable event outbox below) fans each event 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
Sign-in routes:
auth.*- Portal users (password, email OTP, OAuth, OIDC)admin.login- Team members (password, email OTP, OAuth, OIDC)
Sessions also record where the sign-in came from (dashboard, widget, or portal). Only a host/dashboard sign-in can use team permissions. Widget and portal-from-widget sign-ins always act as customers, even if that person is later invited as admin or member. See Session types.
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 cache
Deployment
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 claims jobs from the Postgres job queue 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. The same transaction inserts an event-dispatch row into the job_queue table, and a trigger sends a NOTIFY on commit so a worker picks it up at once. Workers claim jobs with row locks, so several worker replicas never run the same job twice, and a periodic sweep recovers anything a lost notification missed. The dispatch job fans the event out to webhooks, notifications, and workflow triggers, then marks the row published. There is no Redis or external queue.
Next Steps
- Server Functions - Deep dive into RPC patterns
- Database - Schema and migrations
- Adding Features - Build new functionality