Skip to content

Features

The feature-module pattern

Every domain feature lives under src/features/<feature>/. Each module typically contains a subset of:

  • components/ - React components (All client Components need "use client")
  • hooks/ - TanStack Query hooks wrapping tRPC calls and regular React hooks that are feature-specific
  • server/ - tRPC router (server-only, never import in Client Components)
  • schema/ - Zod validation schemas (shared between client and server)
  • model/ - TypeScript types, constants, enums

Server routers are registered centrally in src/trpc/routers/_app.ts (see architecture.md).

Existing features

auth/

Login/register/logout UI, forgot/reset-password forms, and a user-info card, backed by Better Auth (authClient). No server/ - Better Auth's own catch-all route (/api/auth/[...all]) handles the backend. See auth-and-billing.md.

  • login-form.tsx, register-form.tsx, logout-button.tsx, user-info-card.tsx, forgot-password-form.tsx, reset-password-form.tsx

demo/ai/ - streaming + resumable AI chat

Streaming AI chat demo using the Vercel AI SDK against OpenRouter. What it's specifically built to show off: resumable streams - refresh the page mid-response and it keeps going instead of restarting or losing the reply.

Full walkthrough: demos/ai.md

demo/booking/ - multi-step wizard with client-only state

A multi-step, Airbnb-style booking wizard: dates → guests → payment → review. What it's specifically built to show off: a feature-scoped, sessionStorage-persisted Zustand store (separate from the global src/stores/ui-store.ts), per-step Zod validation, and a deliberately flaky backend that forces real error/retry handling.

Full walkthrough: demos/booking.md

demo/customers/ - fully server-driven data table

A searchable, filterable, sortable, paginated data table - no database involved, backed by a randomly generated mock dataset (137 rows). What it's specifically built to show off: every bit of table state (page, search, filter, sort) resolved server-side and mirrored in the URL via nuqs, so the table survives a refresh or a shared link instead of being client-side useState.

Full walkthrough: demos/customers.md

demo/inngest/ - background jobs with live status

Triggers a real background job from the UI and streams its progress live via Inngest Realtime. What it's specifically built to show off: durable multi-step execution (survives a server restart mid-job) and per-run scoped Realtime subscriptions instead of polling.

Full walkthrough: demos/inngest.md

demo/posts/ - canonical CRUD reference

A plain CRUD example against a real Prisma model (Post), plus a premium-gated bulk action. What it's specifically built to show off: this is the module to copy when building your own feature - full ownership-checked CRUD, cache invalidation, all four required UI states, delete confirmation, and a premiumProcedure reference example.

Full walkthrough: demos/posts.md

subscriptions/

hooks/use-subscription.ts exposes useSubscription() (raw Polar customer state) and useHasActiveSubscription() (the one to actually use - treats everyone as premium when Polar isn't configured). See auth-and-billing.md.

user/

server/router.ts exposes user.me (current user's id/name/email/image/emailVerified/createdAt) and user.isPolarEnabled (a public procedure exposing the billing-configured flag to the client). hooks/use-user.ts wraps user.me as useCurrentUser(). hooks/use-update-profile.ts, hooks/use-change-password.ts, and hooks/use-resend-verification-email.ts wrap the corresponding authClient calls (updateUser, changePassword, sendVerificationEmail) with toasts and user.me cache invalidation - no custom tRPC mutations needed since Better Auth's client already exposes these directly.

settings/

The UI for /settings - a Tabs-based shell (components/settings-tabs.tsx) with the active tab synced to a ?tab= URL param via nuqs, so links (e.g. the post-email-verification redirect) can deep-link to a specific category. Currently two categories: account-settings.tsx (profile form, email verification badge, change-password form - composes user/'s hooks) and general-settings.tsx (the interface language toggle). To add another category later: add one more TabsTrigger/TabsContent pair in settings-tabs.tsx and a new component file alongside the existing ones.

Adding a new feature

Use demo/posts as your reference implementation (see demos/posts.md). A new feature you build should live under src/features/<feature>/. Example: adding a projects feature.

1. Schema - src/features/projects/schema/project-schema.ts

typescript
import { z } from "zod";
export const createProjectSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().optional(),
});

2. Prisma model - add to prisma/schema.prisma

prisma
model Project {
  id          String   @id @default(cuid())
  name        String
  description String?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  userId      String
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  @@index([userId])
  @@map("project")
}

Then run bun run prisma:migrate && bun run prisma:generate (see database.md).

3. Router - src/features/projects/server/router.ts

typescript
import { createTRPCRouter, protectedProcedure } from "@/trpc/init";
import prisma from "@/lib/db";
import { createProjectSchema } from "@/features/projects/schema/project-schema";

export const projectsRouter = createTRPCRouter({
  getMany: protectedProcedure.query(async ({ ctx }) => {
    return prisma.project.findMany({ where: { userId: ctx.auth.user.id } });
  }),
  create: protectedProcedure.input(createProjectSchema).mutation(async ({ ctx, input }) => {
    return prisma.project.create({ data: { ...input, userId: ctx.auth.user.id } });
  }),
});

4. Register - add to src/trpc/routers/_app.ts

typescript
import { projectsRouter } from "@/features/projects/server/router";
export const appRouter = createTRPCRouter({
  // ...existing routers
  projects: projectsRouter,
});

5. Hooks - src/features/projects/hooks/use-projects.ts

typescript
import { useTRPC } from "@/trpc/client";
import { useQuery } from "@tanstack/react-query";
export const useProjects = () => {
  const trpc = useTRPC();
  return useQuery(trpc.projects.getMany.queryOptions());
};

6. Components - src/features/projects/components/projects-container.tsx

Every fetch needs all four UI states - see Frontend - UI states. This is the minimum a real container component implements:

tsx
"use client";

import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { useProjects } from "@/features/projects/hooks/use-projects";

export function ProjectsContainer() {
  const { data: projects, isLoading, isError, refetch } = useProjects();

  if (isLoading) {
    return (
      <div className="space-y-2">
        {["a", "b", "c"].map((key) => (
          <Skeleton key={key} className="h-16 w-full" />
        ))}
      </div>
    );
  }

  if (isError) {
    return (
      <div className="rounded-lg border border-destructive/50 p-4 text-sm text-destructive">
        Failed to load projects.
        <Button variant="outline" size="sm" className="mt-2" onClick={() => refetch()}>
          Try again
        </Button>
      </div>
    );
  }

  if (projects.length === 0) {
    return (
      <Empty>
        <EmptyHeader>
          <EmptyTitle>No projects yet</EmptyTitle>
          <EmptyDescription>Create your first project to get started.</EmptyDescription>
        </EmptyHeader>
        <EmptyContent>
          <Button>New Project</Button>
        </EmptyContent>
      </Empty>
    );
  }

  return (
    <ul className="space-y-2">
      {projects.map((project) => (
        <li key={project.id} className="rounded-lg border p-4">
          <p className="font-medium text-sm">{project.name}</p>
        </li>
      ))}
    </ul>
  );
}

Wire up create/delete the same way demo/posts does - a dialog for creating (post-create-dialog.tsx), and ConfirmDialog for deleting (see Frontend - Confirm dialogs).

7. Page - src/app/(dashboard)/projects/page.tsx

tsx
import { ProjectsContainer } from "@/features/projects/components/projects-container";

export default function ProjectsPage() {
  return (
    <main className="flex flex-col gap-6 p-6">
      <h1 className="font-bold text-2xl">Projects</h1>
      <ProjectsContainer />
    </main>
  );
}

The (dashboard) route group's layout.tsx already calls requireAuth() and wraps every page in AppShell - a new page under src/app/(dashboard)/<feature>/page.tsx gets auth protection and the sidebar shell for free, no extra wiring needed. Add the page's copy to messages/en.json and messages/de.json per Frontend - Internationalization rather than hardcoding English strings.