Skip to main content

Overview

Featul is a modern SaaS feedback management platform built as a full-stack TypeScript application. The architecture follows modern best practices with clear separation of concerns, type safety throughout, and scalability at its core.

Tech Stack

  • Runtime: Bun (development) / Node.js 20+ (production)
  • Framework: Next.js 16 with App Router and Turbopack
  • Language: TypeScript
  • Database: PostgreSQL via Neon serverless
  • ORM: Drizzle ORM
  • API Layer: jstack (type-safe RPC over Hono)
  • Authentication: better-auth
  • State Management:
    • Server state: TanStack Query (React Query)
    • Client state: Zustand
  • Styling: Tailwind CSS 4 + Radix UI
  • Deployment: Vercel

Multi-Tenancy Architecture

Subdomain Routing

Featul uses subdomain-based multi-tenancy to provide each workspace with its own unique URL:
{workspace-slug}.featul.com
The subdomain routing is handled in apps/app/src/middleware/host.ts:
export const reservedSubdomains = new Set(["www", "app", "featul", "feedgot", "staging"])

export function getHostInfo(req: NextRequest) {
  const host = req.headers.get("host") || ""
  const hostNoPort = host.replace(/:\d+$/, "")
  const parts = hostNoPort.split(".")
  const isLocal = parts[parts.length - 1] === "localhost"
  const isMainDomain = hostNoPort.endsWith(".featul.com")
  const hasSub = (isLocal && parts.length >= 2) || (isMainDomain && parts.length >= 3)
  const subdomain = hasSub ? parts[0] : ""
  return { pathname, hostNoPort, isLocal, isMainDomain, subdomain }
}

Custom Domains

Workspaces on Starter or Professional plans can use custom domains. The system uses:
  • CNAME Record: Points to origin.featul.com
  • TXT Record: For DNS verification (_acme-challenge.{domain})
  • Domain Table: workspaceDomain stores verification status
See packages/db/schema/workspace.ts:30 for the domain schema.

Reserved Subdomains

The following subdomains are reserved for platform use:
  • www - Marketing site
  • app - Main application
  • featul - Brand domain
  • feedgot - Legacy/alternate brand
  • staging - Staging environment

Data Architecture

Database Schema Organization

The database schema is organized by domain in packages/db/schema/:
  • auth.ts - User authentication and sessions
  • workspace.ts - Workspaces, members, domains, invites
  • feedback.ts - Boards and feedback configuration
  • post.ts - Posts, tags, updates, reports, merges
  • comment.ts - Comments, reactions, mentions, reports
  • vote.ts - Votes and vote aggregates
  • changelog.ts - Changelog entries
  • branding.ts - Workspace branding configuration
  • plan.ts - Subscription and billing
  • integration.ts - Third-party integrations
  • notra.ts - Notra connection data

Workspace Data Model

Each workspace is the top-level tenant entity:
export const workspace = pgTable('workspace', {
  id: text('id').primaryKey().$defaultFn(() => `fl${createId()}`),
  name: text('name').notNull(),
  slug: text('slug').notNull().unique(),
  domain: text('domain').notNull(),
  ownerId: text('owner_id').notNull().references(() => user.id),
  plan: text('plan', { enum: ['free', 'starter', 'professional'] }).default('free'),
  logo: text('logo'),
  primaryColor: text('primary_color').default('#3b82f6'),
  customDomain: text('custom_domain'),
  timezone: text('timezone').default('UTC'),
  // ... timestamps and metadata
})
All workspace-related data (posts, boards, comments, votes) references the workspace ID to ensure data isolation.

API Architecture

Type-Safe RPC with jstack

The API layer uses jstack for end-to-end type safety. jstack provides:
  • Type-safe client-server communication
  • Built on Hono for performance
  • Automatic type inference
  • SuperJSON serialization for complex types
Router structure in packages/api/src/index.ts:
const appRouter = j.mergeRouters(api, {
  workspace: routerImports.workspace,
  board: routerImports.board,
  post: routerImports.post,
  comment: routerImports.comment,
  branding: routerImports.branding,
  changelog: routerImports.changelog,
  // ... more routers
})

Procedure Types

Two main procedure types are defined in packages/api/src/jstack.ts:56:
const baseProcedure = j.procedure.use(databaseMiddleware)
export const publicProcedure = baseProcedure
export const privateProcedure = baseProcedure.use(authMiddleware)
  • publicProcedure: For unauthenticated endpoints (viewing public boards)
  • privateProcedure: Requires authentication, provides ctx.session

Client Usage

From the frontend, API calls are fully type-safe:
const { data } = await client.workspace.bySlug.$post({ slug: "mantlz" })
// data is automatically typed based on the server response

Authentication

better-auth Integration

Authentication uses better-auth with:
  • Email/password + OTP verification
  • OAuth providers (Google, GitHub)
  • Passkey support
  • Two-factor authentication
  • Organization plugin for workspace membership

Cross-Subdomain Sessions

Sessions work across subdomains using:
  • Cookie domain set to .featul.com
  • AUTH_COOKIE_DOMAIN environment variable
  • Trusted origins configuration

Session Access

Server Components:
import { getServerSession } from "@featul/auth/session"

const session = await getServerSession()
API Procedures:
privateProcedure.get(async ({ ctx }) => {
  const userId = ctx.session.user.id // session available in context
})

State Management

Server State (TanStack Query)

All server data is managed with React Query:
const { data, isLoading } = useQuery({
  queryKey: ['workspace', slug],
  queryFn: () => client.workspace.bySlug.$post({ slug })
})

Client State (Zustand)

Local UI state uses Zustand stores in apps/app/src/lib/:
  • selection-store: Multi-select functionality for posts
  • branding-store: Theme and branding state
  • filter-store: Board filtering and sorting

File Structure

Key Files

  • apps/app/src/app/api/[[...route]]/route.ts - API catch-all route handler
  • packages/api/src/index.ts - API router aggregation
  • packages/api/src/jstack.ts - Middleware and procedure definitions
  • packages/auth/src/auth.ts - Authentication configuration
  • packages/db/index.ts - Database client and schema exports
  • apps/app/src/middleware/host.ts - Subdomain routing logic

Middleware Chain

Next.js middleware executes in this order:
  1. Host detection - Identify subdomain and custom domain
  2. Authentication - Check session validity
  3. Routing - Rewrite URLs for workspace context

Performance Considerations

Database Optimization

  • Neon serverless: Auto-scaling PostgreSQL
  • Connection pooling: Built into Drizzle with Neon
  • Prepared statements: Drizzle uses prepared statements by default
  • Indexes: Strategic indexes on workspace_id, user_id, timestamps

API Optimization

  • SuperJSON: Efficient serialization with type preservation
  • Lazy router imports: Routers are dynamically imported
  • Edge runtime: API routes can run on edge (Vercel)

Caching Strategy

// Public workspace data cached for 30s
c.header("Cache-Control", "public, max-age=30, stale-while-revalidate=300")

// Private data not cached
c.header("Cache-Control", "private, no-store")

Security

Data Isolation

All queries filter by workspaceId to ensure tenant isolation:
await db.select()
  .from(post)
  .innerJoin(board, eq(post.boardId, board.id))
  .where(eq(board.workspaceId, workspace.id))

CORS and Origin Validation

  • Trusted origins configured via API_TRUSTED_ORIGINS
  • Origin enforcement in packages/api/src/shared/request-origin.ts
  • CORS middleware applied to all API routes

Input Validation

All inputs validated with Zod schemas in packages/api/src/validators/.

Deployment

Build Process

Turborepo orchestrates the build:
bun run build  # Builds all apps and packages in dependency order

Environment Variables

See turbo.json:12 for the complete list of required environment variables.

Production Architecture

  • Frontend: Deployed to Vercel (Next.js App Router)
  • API: Serverless functions on Vercel
  • Database: Neon serverless PostgreSQL
  • File Storage: Cloudflare R2
  • Email: Resend

Build docs developers (and LLMs) love