Skip to content

Faster Chat — Architecture Guide

Last Updated: 2026-04-23 Stack: Preact + Hono + SQLite + Bun + Vercel AI SDK


Faster Chat is a self-hosted, privacy-first AI chat interface. It is a monorepo with three workspaces: a Preact SPA frontend, a Hono API backend, and shared internal packages.

┌──────────────────────────────────────────────────────────────┐
│ Frontend (Vite) │
│ Preact SPA — http://localhost:3000 │
│ ├─ TanStack Router — File-based routing │
│ ├─ TanStack Query — Server state sync │
│ ├─ Zustand — UI state (theme, sidebar, prefs) │
│ ├─ Vercel AI SDK — Streaming chat transport │
│ └─ Tailwind CSS 4 — Utility-first styling │
└───────────────────────┬──────────────────────────────────────┘
│ /api/*
┌───────────────────────▼──────────────────────────────────────┐
│ Hono API Server │
│ Bun runtime — http://localhost:3001 │
│ ├─ /api/chats — Streaming chat + CRUD │
│ ├─ /api/auth — Session-based auth │
│ ├─ /api/admin — User/provider/model management │
│ ├─ /api/files — File uploads/attachments │
│ ├─ /api/images — Image generation │
│ ├─ /api/memory — Cross-conversation memory │
│ └─ /api/settings — App-wide configuration │
└───────────────────────┬──────────────────────────────────────┘
│ bun:sqlite
┌───────────────────────▼──────────────────────────────────────┐
│ SQLite Database │
│ Single-file database (server/data/chat.db) │
│ ├─ users, sessions — Auth & RBAC │
│ ├─ chats, messages — Conversation persistence │
│ ├─ providers, models — AI provider registry │
│ ├─ files — Attachment metadata │
│ ├─ folders — Chat organization │
│ ├─ user_memories — Cross-conversation facts │
│ └─ settings — App configuration │
└──────────────────────────────────────────────────────────────┘

faster-chat/
├── frontend/ # Preact SPA
│ ├── src/
│ │ ├── main.jsx # Entry point — renders App
│ │ ├── App.jsx # Root component, QueryClientProvider
│ │ ├── router.jsx # TanStack route tree definition
│ │ ├── pages/
│ │ │ ├── public/
│ │ │ │ └── Login.jsx # Public login page
│ │ │ └── authenticated/
│ │ │ ├── Chat.jsx # Main chat page
│ │ │ ├── Admin.jsx # Admin dashboard
│ │ │ ├── Settings.jsx # User settings
│ │ │ ├── Import.jsx # Conversation import
│ │ │ └── Folder.jsx # Folder view
│ │ ├── components/
│ │ │ ├── chat/ # Chat-specific components
│ │ │ │ ├── ChatInterface.jsx # Main chat layout
│ │ │ │ ├── MessageList.jsx # Render message stream
│ │ │ │ ├── MessageItem.jsx # Individual message
│ │ │ │ ├── InputArea.jsx # Text input + submit
│ │ │ │ ├── ModelSelector.jsx # Model dropdown
│ │ │ │ └── ...
│ │ │ ├── admin/ # Admin panel components
│ │ │ ├── layout/ # Shell components
│ │ │ │ ├── MainLayout.jsx # App shell (sidebar + content)
│ │ │ │ ├── Sidebar.jsx # Chat list sidebar
│ │ │ │ └── ...
│ │ │ ├── settings/ # Settings sections
│ │ │ ├── markdown/ # Markdown rendering
│ │ │ └── ui/ # Reusable UI primitives
│ │ ├── hooks/ # Custom React hooks
│ │ │ ├── useChat.js # Core chat orchestration hook
│ │ │ ├── useChatStream.js # AI SDK streaming hook
│ │ │ ├── useChatPersistence.js # Save/load messages via API
│ │ │ ├── useChatsQuery.js # TanStack Query hooks for chats
│ │ │ └── ...
│ │ ├── state/ # Zustand stores
│ │ │ ├── useAuthState.js # Auth state (user, login, logout)
│ │ │ ├── useUiState.js # UI preferences
│ │ │ ├── useThemeStore.js # Theme settings
│ │ │ └── useAppSettings.js # App-wide settings
│ │ ├── lib/ # Utilities & API clients
│ │ │ ├── api.js # Base API client
│ │ │ ├── authClient.js # Auth API wrappers
│ │ │ ├── chatsClient.js # Chat API wrappers
│ │ │ ├── messageUtils.js # Message content helpers
│ │ │ └── shiki.js # Syntax highlighting setup
│ │ └── constants/ # Frontend-only constants
│ ├── index.html
│ ├── vite.config.js # Vite + Preact + Tailwind
│ └── package.json
├── server/ # Hono API
│ ├── src/
│ │ ├── index.js # Server entry — routes + middleware
│ │ ├── init.js # First-run setup (encryption keys, dirs)
│ │ ├── routes/
│ │ │ ├── auth.js # Login/register/logout
│ │ │ ├── chats.js # Chat CRUD + streaming endpoint
│ │ │ ├── admin.js # User management, audit logs
│ │ │ ├── providers.js # Provider CRUD
│ │ │ ├── models.js # Model listing & defaults
│ │ │ ├── files.js # File upload/download
│ │ │ ├── images.js # Image generation
│ │ │ ├── memory.js # Memory extraction & storage
│ │ │ ├── folders.js # Chat folders
│ │ │ ├── settings.js # App settings
│ │ │ ├── import.js # ChatGPT import
│ │ │ └── version.js # Version info
│ │ ├── middleware/
│ │ │ ├── auth.js # Session validation + RBAC
│ │ │ ├── securityHeaders.js # HSTS, CSP, etc.
│ │ │ └── rateLimiter.js # Per-endpoint rate limiting
│ │ ├── lib/
│ │ │ ├── db.js # SQLite database + dbUtils
│ │ │ ├── providerFactory.js # AI SDK provider instantiation
│ │ │ ├── imageProviderFactory.js# Image generation providers
│ │ │ ├── imageGeneration.js # Image generation orchestration
│ │ │ ├── encryption.js # AES-256-GCM for API keys
│ │ │ ├── memory.js # Memory extraction logic
│ │ │ ├── modelsdev.js # models.dev integration
│ │ │ ├── fileUtils.js # File storage helpers
│ │ │ ├── security.js # SSRF protection, validation
│ │ │ ├── chatgptImporter.js # ChatGPT JSON import
│ │ │ ├── tools/ # AI tool implementations
│ │ │ │ ├── webSearch.js # Brave Search tool
│ │ │ │ └── fetchUrl.js # URL fetching for search
│ │ │ └── search/ # Search subsystem
│ │ │ ├── index.js # Search dispatcher
│ │ │ ├── fetchUrl.js # URL content extraction
│ │ │ └── providers/
│ │ │ └── brave.js # Brave Search API
│ │ └── test/ # Bun test suite
│ ├── data/
│ │ ├── uploads/ # Uploaded files & generated images
│ │ └── chat.db # SQLite database
│ └── package.json
├── packages/shared/ # Shared internal package
│ └── src/
│ ├── constants/ # Shared constants
│ │ ├── providers.js # Provider defaults & registry
│ │ ├── prompts.js # System prompts
│ │ ├── ui.js # UI limits & defaults
│ │ ├── database.js # DB constants
│ │ ├── voice.js # Voice config
│ │ ├── search.js # Search constants
│ │ ├── imageGeneration.js # Image gen defaults
│ │ └── ...
│ ├── types/ # JSDoc type definitions
│ ├── utils/ # Shared utilities
│ └── index.js # Package exports
├── docs/ # Documentation
├── scripts/ # Build & deploy helpers
├── docker-compose.yml # Docker orchestration
├── Dockerfile # Production image
└── package.json # Workspace root

User types message
[frontend] InputArea.jsx
↓ handleSubmit
[frontend] useChat.submitMessage()
[frontend] useChatPersistence.saveUserMessage() → POST /api/chats/:id/messages
↓ (TanStack Query invalidates chat cache)
[frontend] useChatStream.send() → POST /api/chats/:id/stream
[server] chats.js — streamText() with Vercel AI SDK
[server] providerFactory.js — creates provider client
[external] OpenAI/Anthropic/Ollama/etc.
↓ (SSE streaming response)
[frontend] useChatStream — receives chunks, updates messages
[frontend] onMessageComplete callback
[frontend] useChatPersistence.saveAssistantMessage() → POST /api/chats/:id/messages
[server] Memory extraction (async, post-response)
[server] SQLite — messages table updated
User visits /login
[frontend] Login.jsx → POST /api/auth/login
[server] auth.js — bcrypt compare, createSession()
[server] Set HTTP-only cookie: session=<id>
[frontend] useAuthState.login() — updates user state
[frontend] router navigates to /chat/:id
Subsequent requests include session cookie automatically
[server] ensureSession middleware validates cookie against SQLite
[server] Attaches user object to Hono context: c.get('user')
Admin opens /admin
[frontend] Admin.jsx — tabbed interface (Connections, Models, Users, Customize)
[frontend] ConnectionsTab — lists providers via GET /api/admin/providers
Admin adds provider + API key
[frontend] POST /api/admin/providers
[server] Encrypt API key with AES-256-GCM → store in providers table
Admin clicks "Refresh Models"
[server] Fetch models from provider API (or models.dev cache)
[server] Populate models table
[frontend] ModelsTab — enable/disable models, set default

TanStack Router provides code-based routing with lazy-loaded page components:

RouteAuthComponentPurpose
/loginPublicLogin.jsxAuthentication
/ProtectedIndexRouteGuardRedirects to active chat
/chat/$chatIdProtectedChat.jsxMain chat interface
/adminProtected (admin)Admin.jsxAdmin dashboard
/settingsProtectedSettings.jsxUser preferences
/importProtectedImport.jsxImport conversations
/folder/$folderIdProtectedFolder.jsxFolder chat list

Routes are defined in frontend/src/router.jsx. The ProtectedLayout component wraps all authenticated routes, checking the session and rendering MainLayout with Sidebar.

Three-layer state architecture:

LayerToolPurposeExample
Server StateTanStack QueryAPI data that persists on serverChat list, messages, models
Client UI StateZustandEphemeral UI preferencesTheme, sidebar open, selected model
Local Component StateuseStateComponent-scoped valuesInput text, modal open

Key rule: Never duplicate server data in Zustand or useState. If data comes from an API, it lives in TanStack Query.

The frontend uses a layered hook architecture for chat:

  • useChat — Orchestrates the entire chat experience. Coordinates persistence, streaming, and user input.
  • useChatStream — Wraps the Vercel AI SDK useChat hook. Handles SSE streaming, message assembly, and tool execution (web search).
  • useChatPersistence — TanStack Query mutations for saving/loading messages and chat metadata.
  • useChatsQuery — Query hooks for chat CRUD operations (list, create, delete, pin, archive).

Components follow an 8-section structure:

  1. Server state queries
  2. Client state (Zustand)
  3. Derived values (computed in render)
  4. Event handlers
  5. Effects (rare — DOM sync only)
  6. Early returns
  7. Render helpers
  8. JSX

Constraints:

  • No TypeScript — .jsx components, .js utilities
  • No useCallback — event handlers don’t need memoization
  • Derive, don’t duplicate — computable values are expressions, not state
  • Composition over configuration — pass children, not boolean flags

The Hono app is assembled with middleware and routes:

  1. Security headers — Applied to all responses (HSTS, CSP, X-Frame-Options)
  2. Logger — Request logging
  3. Body size limit — 50MB cap on /api/*
  4. CORS — Origin-restricted (production: APP_URL only; dev: localhost only)
  5. Route mounting — Each domain has its own router
  6. Static file serving — In production, serves frontend/dist and SPA fallback
  7. models.dev cache initialization — Fetches provider metadata on startup
MiddlewareApplied ToPurpose
securityHeaders*Security headers on all responses
logger*Request logging
ensureSession/api/* (except auth/version)Validates session cookie, attaches user
requireRole('admin')/api/admin/*RBAC enforcement
createRateLimiterSpecific routesPer-endpoint rate limiting

Each route file exports a Hono router instance mounted in index.js:

RouterBase PathAuthKey Operations
authRouter/api/authPublicLogin, register, logout, session check
chatsRouter/api/chatsSessionCRUD chats, stream messages, delete
adminRouter/api/adminAdminUser CRUD, audit logs, stats
providersRouter/api/admin/providersAdminProvider CRUD, key encryption
modelsRouter/apiMixedList models, set defaults
filesRouter/api/filesSessionUpload, download, delete attachments
imagesRouter/api/imagesSessionGenerate images (DALL-E, FLUX)
memoryRouter/api/memorySessionGet/clear memories, toggle settings
foldersRouter/api/foldersSessionFolder CRUD, chat organization
settingsRouter/api/settingsMixedPublic GET, admin PUT
importRouter/api/importSessionChatGPT JSON import
versionRouter/api/versionPublicApp version info

The entire database layer is a single file exporting dbUtils — a plain object of functions. No ORM. SQLite is accessed directly via bun:sqlite prepared statements.

Schema:

TablePurpose
usersAccounts with role (admin/member/readonly)
sessionsHTTP-only cookie sessions with expiry
providersAI provider configs with encrypted API keys
modelsAvailable models per provider, enabled flag
model_metadataCapabilities (vision, tools, pricing)
chatsConversations with soft delete, pin, archive
messagesChat messages with role, content, model, metadata
filesUploaded file metadata (stored on disk)
foldersUser-defined chat folders
settingsKey-value app configuration
user_memoriesExtracted facts per user
audit_logSecurity event log

Migrations: The schema is created on startup with CREATE TABLE IF NOT EXISTS. Schema changes (new columns) are handled by checking PRAGMA table_info and running ALTER TABLE if needed.

Production optimizations:

  • PRAGMA journal_mode = WAL — Write-ahead logging for concurrency
  • PRAGMA synchronous = NORMAL — Balanced durability/performance
  • PRAGMA cache_size — Increased page cache
  • PRAGMA temp_store = MEMORY — Temp tables in RAM
  • PRAGMA mmap_size — Memory-mapped I/O

5.5 Provider System (server/src/lib/providerFactory.js)

Section titled “5.5 Provider System (server/src/lib/providerFactory.js)”

The provider factory maps provider names to Vercel AI SDK client constructors:

Provider Name → AI SDK Factory
─────────────────────────────────────────
anthropic → @ai-sdk/anthropic
openai → @ai-sdk/openai
azure → @ai-sdk/azure
google → @ai-sdk/google
google-vertex → @ai-sdk/google-vertex
mistral → @ai-sdk/mistral
groq → @ai-sdk/groq
cohere → @ai-sdk/cohere
amazon-bedrock → @ai-sdk/amazon-bedrock
xai → @ai-sdk/xai
deepseek → @ai-sdk/deepseek
cerebras → @ai-sdk/cerebras
fireworks → @ai-sdk/fireworks
ollama → @ai-sdk/openAI (with custom baseURL)
openrouter → @ai-sdk/openAI (with custom baseURL)

Unknown providers fall back to OpenAI-compatible mode (custom baseURL).

The getModelInstance() function:

  1. Looks up the provider in PROVIDER_FACTORIES
  2. Decrypts the stored API key with decryptApiKey()
  3. Instantiates the provider client
  4. Returns the model instance (provider.chat(modelId) or provider(modelId))

The @faster-chat/shared package contains code used by both frontend and backend:

  • Constants — Provider defaults, UI limits, file config, prompts, shortcuts
  • Types — JSDoc type definitions for IDE support (no TS compiler)
  • Utilities — Formatters, provider validation

This ensures both sides agree on contract values without manual sync.


The project uses plain JavaScript (.js/.jsx) with JSDoc types. The rationale:

  • Faster iteration — no compile step, no type churn across fast-moving AI SDKs
  • Runtime validation at API boundaries (Zod on the server)
  • Shared constants and clear contracts reduce type errors
  • Tests cover critical paths

Trade-off: Less compile-time safety, but faster development and lower contribution barrier.

SQLite is used as the sole database (no PostgreSQL in production yet):

  • Single file — trivial backups and migrations
  • bun:sqlite is extremely fast (native bindings)
  • WAL mode handles concurrent reads during writes
  • Fits the self-hosted, single-server deployment model

Future: PostgreSQL is on the roadmap for larger multi-user deployments.

Preact provides a 3KB runtime vs React’s ~40KB:

  • Faster load times, especially on slower connections
  • Full React API compatibility via @preact/compat
  • Works with the entire React ecosystem (TanStack, AI SDK, etc.)

Vite aliases react@preact/compat so dependencies work transparently.

Cookie-based sessions are used instead of JWT:

  • Server-side revocation — logout invalidates the session immediately
  • No token storage complexity on the client
  • Works seamlessly with HTTP-only cookies (XSS resistant)

Sessions are stored in SQLite with automatic expiry cleanup.

Provider API keys are encrypted at rest with AES-256-GCM:

  • Each key gets a unique IV and auth tag
  • The encryption key is in server/.env (auto-generated on first run)
  • Keys are never sent to the client
  • Brave Search API key uses the same encryption mechanism

Important: server/.env must be backed up — losing the encryption key means all stored API keys are unrecoverable.


The chat uses Vercel AI SDK’s streamText on the backend and useChat (via @ai-sdk/react) on the frontend:

  1. Frontend sends message history to /api/chats/:id/stream
  2. Backend builds message array, injects system prompt + memories
  3. Backend calls streamText() with the model instance
  4. AI SDK handles provider-specific streaming protocols
  5. Response streamed as Server-Sent Events (SSE)
  6. Frontend receives chunks and assembles the message in real-time
  7. On completion, assistant message is saved to SQLite

When enabled and the model supports tools:

  1. Backend creates webSearch tool using Brave Search API
  2. AI SDK automatically invokes the tool when the model decides to search
  3. Tool fetches search results (cached 5 minutes)
  4. Model receives results and cites sources inline
  5. Frontend displays source citations as clickable pills

SSRF protection: URL fetching validates DNS and blocks private IP ranges.

Cross-conversation memory extracts facts about the user:

  1. After each assistant response, an async extraction runs
  2. A cheap/fast model (configurable, e.g. Haiku/GPT-4o-mini) extracts facts
  3. Facts are deduplicated and stored in user_memories
  4. On subsequent chats, memories are injected into the system prompt

Gating (three levels):

  • Global toggle (admin setting)
  • Per-user toggle (user can disable entirely)
  • Per-chat opt-out (individual chat can skip memory)

Separate from chat streaming:

  1. User selects image generation model in InputArea
  2. Frontend sends prompt to POST /api/images/generate
  3. Backend routes to appropriate provider (OpenAI DALL-E, Replicate FLUX, OpenRouter)
  4. Image generated and saved to server/data/uploads/generated/
  5. URL returned to frontend for inline display
  1. User uploads file via POST /api/files
  2. File stored on disk with UUID prefix: {uuid}_{filename}
  3. Metadata saved to files table (size, mime type, hash)
  4. File IDs attached to messages via file_ids JSON array
  5. Messages with file IDs render attachment previews

ConcernMitigation
XSSCSP headers, no inline scripts, React/Preact escaping
CSRFSameSite cookies, origin-restricted CORS
Session hijackingHTTP-only cookies, automatic expiry
API key exposureAES-256-GCM encryption at rest
SSRFDNS validation, private IP blocking
Rate limitingPer-endpoint configurable limits
File uploadsSize limits, mime type validation, hash deduplication
SQL injectionParameterized queries (prepared statements only)

  1. Add factory to server/src/lib/providerFactory.js (or use OpenAI-compatible fallback)
  2. Add defaults to packages/shared/src/constants/providers.js
  3. Add provider metadata to packages/shared/src/constants/config.js
  4. Register in admin UI if special handling needed
  1. Create router file in server/src/routes/
  2. Export Hono router instance
  3. Mount in server/src/index.js with app.route("/api/...", router)
  4. Add corresponding API client in frontend/src/lib/
  5. Add TanStack Query hooks in frontend/src/hooks/
  1. Add CREATE TABLE IF NOT EXISTS to server/src/lib/db.js schema section
  2. Add CRUD functions to dbUtils object
  3. Export from db.js
  4. Use in route handlers
  1. Create component in frontend/src/pages/authenticated/ (or public/)
  2. Add route in frontend/src/router.jsx
  3. Add sidebar link if needed in frontend/src/components/layout/Sidebar.jsx

Terminal window
# Install dependencies
bun install
# Start dev servers (frontend + backend concurrently)
bun run dev
# Run server tests
bun run test
# Format code
bun run format
# Build for production
bun run build
# Start production server
bun run start

Dev server ports:

First run:

  1. bun run dev
  2. Visit http://localhost:3000/login
  3. Register first account (auto-promoted to admin)
  4. Configure providers in /admin

The server has a comprehensive test suite using Bun’s built-in test runner:

Test FileCoverage
auth.test.jsLogin, register, session validation
chats.test.jsChat CRUD, message streaming
admin.test.jsUser management, role changes
providers.test.jsProvider CRUD, encryption
models.test.jsModel listing, defaults
memory.test.jsMemory extraction, gating
folders.test.jsFolder CRUD, chat organization
settings.test.jsApp settings, white label
encryption.test.jsAES-256-GCM encrypt/decrypt
middleware.test.jsAuth middleware, rate limiting
smoke.test.jsServer startup health check

Run with: bun run test


For deployment details, see docker-setup.md and caddy-https-setup.md. For feature specs, see the features/ directory.