Documentation
Documentation
Introduction

Getting Started

Getting StartedInstallationQuick StartProject Structure

Architecture

Architecture OverviewTech StacktRPC MiddlewareDesign Principles

Patterns

Code Patterns & ConventionsFeature ModulesError HandlingType Safety

Database

DatabaseSchema DefinitionDatabase OperationsMigrationsCaching

API

tRPCProceduresRouterstRPC Proxy Setup
APIsOpenAPIREST Endpoints

Auth & Access

AuthenticationConfigurationOAuth ProvidersRolesSession Management
AuthorizationUser RolesPermissions

Routing & i18n

RoutingDeclarative RoutingNavigation
InternationalizationTranslationsLocale Routing

Components & UI

ComponentsButtonsFormsNavigationDialogs
StylesTailwind CSSThemingTypography

Storage

StorageConfigurationUsageBuckets

Configuration

ConfigurationEnvironment VariablesFeature Flags

Templates

Template GuidesCreate New FeatureCreate New PageCreate Database TableCreate tRPC RouterAdd Translations

Development

DevelopmentCommandsAI AgentsBest Practices

Feature Modules

The 5-file pattern for organizing feature-specific code

Overview

Features in this project follow a standardized 5-file pattern that provides clear separation of concerns and makes the codebase predictable and maintainable. Each feature module encapsulates all logic related to a specific domain.

src/features/<feature-name>/
├── schema.ts         # Zod schemas and TypeScript types
├── functions.ts      # Server-side database operations
├── hooks.ts          # Client-side tRPC hooks
├── fields.tsx        # (Optional) Form field components
├── prompts.tsx       # (Optional) Dialog wrappers
├── components/       # (Optional) Feature-specific UI components
│   └── index.ts      # Component barrel export
└── index.ts          # Feature barrel export

This pattern enables:

  • Predictable file locations - Know exactly where to find schemas, hooks, or functions
  • Clear responsibilities - Each file has a single, well-defined purpose
  • Easy testing - Isolated concerns make unit testing straightforward
  • Reusability - Components and hooks can be imported cleanly via barrel exports

The 5 Core Files

1. schema.ts - Zod Schemas & Types

Purpose: Define data structures, validation rules, and TypeScript types.

Key concepts:

  • Base schemas using baseTableSchema
  • Zod schemas for runtime validation
  • TypeScript types inferred from schemas
  • Validation constants (min/max lengths, etc.)

Example from src/features/api-keys/schema.ts:

import { baseTableSchema } from "@/db/enums";
import { z } from "zod";

// Constants for validation
export const MIN_API_KEY_NAME = 3;
export const MAX_API_KEY_NAME = 50;

// Base schema with all database fields
export const apiKeySchema = baseTableSchema.extend({
  name: z.string().min(MIN_API_KEY_NAME).max(MAX_API_KEY_NAME).nullable(),
  prefix: z.string().min(2).max(20).nullable(),
  key: z.string(),
  userId: z.uuid(),
  permissions: z.record(z.string(), z.array(z.string())).optional(),
  expiresAt: z.date().nullable(),
});

// TypeScript type inferred from schema
export type ApiKey = z.infer<typeof apiKeySchema>;

// Upsert schema (create/update) - omits auto-generated fields
export const apiKeyUpsertSchema = apiKeySchema
  .omit({ 
    id: true, 
    createdAt: true, 
    updatedAt: true,
    key: true, // Generated server-side
  })
  .partial({ userId: true }) // Optional for updates
  .refine(
    (val) => validatePermissions(val.permissions),
    {
      params: { i18n: "invalid_permissions" },
      path: ["permissions"],
    }
  );

export type ApiKeyUpsert = z.infer<typeof apiKeyUpsertSchema>;

Best practices:

  • Keep validation constants at the top
  • Use baseTableSchema for common fields (id, createdAt, updatedAt)
  • Apply .refine() only on final upsert schemas (see Type Safety)
  • Use params.i18n for translatable error messages
  • Export both schemas and inferred types

2. functions.ts - Server-Side Operations

Purpose: Database operations using createDrizzleOperations abstraction.

Key concepts:

  • Use createDrizzleOperations for standard CRUD
  • Add custom operations as needed (search, pagination, filtering)
  • All functions are server-side only
  • Leverage Next.js unstable_cache for caching when needed

Example from src/features/api-keys/functions.ts:

import { createDrizzleOperations } from "@/db/drizzle-operations";
import { apiKeys } from "@/db/tables/api-keys";
import { eq, like, and } from "drizzle-orm";

// Standard CRUD operations
const operations = createDrizzleOperations(apiKeys);

// Re-export standard operations
export const { create, update, deleteById, getById, list } = operations;

// Custom operations
export async function getByUserId(userId: string) {
  return operations.listFiltered(eq(apiKeys.userId, userId));
}

export async function searchByName(term: string, userId: string) {
  return operations.searchDocuments(
    term,
    ["name", "prefix"],
    and(eq(apiKeys.userId, userId))
  );
}

export async function getByKey(key: string) {
  return operations.getFirst(eq(apiKeys.key, key));
}

// Pagination for tables
export async function listTable(params: TablePagination) {
  return operations.listTable(params);
}

Best practices:

  • Use createDrizzleOperations for standard CRUD
  • Add custom operations only when standard CRUD isn't sufficient
  • Filter by owner (userId) for user-specific resources
  • Export functions individually for tree-shaking

3. hooks.ts - Client-Side tRPC Hooks

Purpose: Client-side data fetching and mutations using tRPC.

Key concepts:

  • Query hooks for reading data
  • Mutation hooks for create/update/delete
  • Form integration with React Hook Form
  • Cache invalidation after mutations

Hook naming conventions:

TypePatternReturns
Queryuse<Feature>List(){ data, loading<Feature>, refetching<Feature>, refetch<Feature>, query }
Mutationuse<Feature>Form(){ form<Feature>, save<Feature>, loading<Feature>, mutation<Feature> }
Deleteuse<Feature>Delete(){ delete<Feature>, loading<Feature>, mutation<Feature> }

Example from src/features/api-keys/hooks.ts:

"use client";

import { api } from "@/trpc/client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import type { ApiKey, ApiKeyUpsert } from "./schema";
import { apiKeyUpsertSchema } from "./schema";

// Query hook - List all API keys
export function useApiKeysList() {
  const query = api.apiKey.list.useQuery();
  
  return {
    data: query.data?.payload,
    loadingApiKeys: query.isLoading,
    refetchingApiKeys: query.isRefetching,
    refetchApiKeys: query.refetch,
    query,
  };
}

// Query hook - Get by ID
export function useApiKey(id: string) {
  const query = api.apiKey.getById.useQuery({ id });
  
  return {
    data: query.data?.payload,
    loadingApiKey: query.isLoading,
    refetchApiKey: query.refetch,
    query,
  };
}

// Mutation hook - Create/Update
export function useApiKeyForm(data: ApiKey | null) {
  const t = useTranslations();
  const utils = api.useUtils();
  
  const formApiKey = useForm<ApiKeyUpsert>({
    resolver: zodResolver(apiKeyUpsertSchema),
    defaultValues: data ?? {},
  });
  
  const mutation = api.apiKey.upsert.useMutation({
    onSuccess: (result) => {
      if (result.success) {
        toast.success(result.message);
        utils.apiKey.list.invalidate();
        formApiKey.reset();
      } else {
        toast.error(result.message);
      }
    },
  });
  
  const saveApiKey = formApiKey.handleSubmit(async (formData) => {
    await mutation.mutateAsync(formData);
  });
  
  return {
    formApiKey,
    saveApiKey,
    loadingApiKey: mutation.isPending,
    mutationApiKey: mutation,
  };
}

// Mutation hook - Delete
export function useApiKeyDelete() {
  const t = useTranslations();
  const utils = api.useUtils();
  
  const mutation = api.apiKey.deleteById.useMutation({
    onSuccess: (result) => {
      if (result.success) {
        toast.success(result.message);
        utils.apiKey.list.invalidate();
      } else {
        toast.error(result.message);
      }
    },
  });
  
  const deleteApiKey = async (id: string) => {
    await mutation.mutateAsync({ id });
  };
  
  return {
    deleteApiKey,
    loadingApiKeyDelete: mutation.isPending,
    mutationApiKeyDelete: mutation,
  };
}

Best practices:

  • Follow naming conventions (loadingApiKeys, refetchApiKeys, etc.)
  • Always invalidate relevant queries after mutations
  • Show toast notifications for success/error
  • Use useForm with zodResolver for forms
  • Return consistent object shapes for all hooks

4. fields.tsx - Form Field Components

Purpose: Reusable form field components with consistent styling and validation.

Key concepts:

  • Use useFormContext to access form state
  • Import from @/components/ui/form for form primitives
  • Add translations for labels and placeholders
  • Each field is a standalone component

Example from src/features/api-keys/fields.tsx:

"use client";

import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useFormContext } from "react-hook-form";
import { useTranslations } from "next-intl";
import type { ApiKeyUpsert } from "./schema";

export function ApiKeyNameField() {
  const t = useTranslations("apiKeys");
  const form = useFormContext<ApiKeyUpsert>();

  return (
    <FormField
      control={form.control}
      name="name"
      render={({ field }) => (
        <FormItem>
          <FormLabel>{t("name.label")}</FormLabel>
          <FormControl>
            <Input 
              {...field} 
              value={field.value ?? ""} 
              placeholder={t("name.placeholder")} 
            />
          </FormControl>
          <FormDescription>{t("name.description")}</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
  );
}

export function ApiKeyPrefixField() {
  const t = useTranslations("apiKeys");
  const form = useFormContext<ApiKeyUpsert>();

  return (
    <FormField
      control={form.control}
      name="prefix"
      render={({ field }) => (
        <FormItem>
          <FormLabel>{t("prefix.label")}</FormLabel>
          <FormControl>
            <Input 
              {...field} 
              value={field.value ?? ""} 
              placeholder={t("prefix.placeholder")} 
              maxLength={20}
            />
          </FormControl>
          <FormDescription>{t("prefix.description")}</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
  );
}

// Barrel export for convenience
export function FormFieldsApiKey() {
  return (
    <>
      <ApiKeyNameField />
      <ApiKeyPrefixField />
    </>
  );
}

Best practices:

  • One component per field for maximum reusability
  • Use useFormContext<YourType>() for type safety
  • Always include labels, placeholders, and descriptions
  • Handle nullable values with value={field.value ?? ""}
  • Export a combined FormFields<Feature> component for convenience

5. prompts.tsx - Dialog Wrappers

Purpose: Confirmation dialogs for create, update, and delete operations.

Key concepts:

  • Use usePrompt() hook from @/forms/prompt
  • Leverage confirmButton API for consistency
  • Separate prompts for upsert and delete
  • Return a function that opens the dialog

Example from src/features/api-keys/prompts.tsx:

"use client";

import { usePrompt } from "@/forms/prompt";
import { useTranslations } from "next-intl";
import { useApiKeyForm, useApiKeyDelete } from "./hooks";
import { FormFieldsApiKey } from "./fields";
import type { ApiKey, ApiKeyUpsert } from "./schema";

// Upsert prompt (create or update)
export function useApiKeyUpsertPrompt(data: ApiKey | null) {
  const t = useTranslations("apiKeys");
  const prompt = usePrompt();
  const { formApiKey, saveApiKey } = useApiKeyForm(data);

  return () => {
    prompt({
      form: formApiKey,
      children: <FormFieldsApiKey />,
      title: data ? t("dialog.update") : t("dialog.create"),
      description: data ? t("dialog.updateDescription") : t("dialog.createDescription"),
      confirmButton: {
        action: saveApiKey,
        variant: "primary",
        icon: "save",
        i18nButtonKey: "save",
      },
    });
  };
}

// Delete confirmation prompt
export function useApiKeyDeletePrompt(data: ApiKey) {
  const t = useTranslations("apiKeys");
  const prompt = usePrompt();
  const { deleteApiKey } = useApiKeyDelete();

  return () => {
    prompt({
      title: t("dialog.delete"),
      description: t("dialog.deleteConfirmation", { name: data.name ?? data.prefix }),
      confirmButton: {
        action: async () => deleteApiKey(data.id),
        variant: "destructive",
        icon: "trash",
        i18nButtonKey: "delete",
      },
    });
  };
}

confirmButton API:

PropertyTypeDescription
action() => Promise<void>Async function to execute on confirm
variant"primary" | "destructive"Button style
iconstringIcon key from lucide-react
i18nButtonKeystringTranslation key for button text

Best practices:

  • Use usePrompt() for all dialogs
  • Pass form to prompt for form-based dialogs
  • Separate prompts for different actions (upsert, delete, etc.)
  • Use descriptive titles and descriptions with i18n
  • Set variant: "destructive" for delete actions
  • If confirmButton is undefined, no confirm button is shown

Hook Patterns

Query Hooks

Query hooks fetch data from the server. They return:

{
  data: TData | undefined,
  loading<Feature>: boolean,
  refetching<Feature>: boolean,
  refetch<Feature>: () => void,
  query: UseQueryResult
}

Example usage:

const { data: apiKeys, loadingApiKeys, refetchApiKeys } = useApiKeysList();

if (loadingApiKeys) return <Spinner />;

return (
  <div>
    {apiKeys?.map(key => <ApiKeyCard key={key.id} apiKey={key} />)}
    <Button onClick={refetchApiKeys}>Refresh</Button>
  </div>
);

Mutation Hooks

Mutation hooks create, update, or delete data. They return:

{
  form<Feature>: UseFormReturn<T>,
  save<Feature>: () => Promise<void>,
  loading<Feature>: boolean,
  mutation<Feature>: UseMutationResult
}

Example usage:

const { formApiKey, saveApiKey, loadingApiKey } = useApiKeyForm(null);

return (
  <Form {...formApiKey}>
    <form onSubmit={saveApiKey}>
      <FormFieldsApiKey />
      <Button type="submit" loading={loadingApiKey}>
        Save
      </Button>
    </form>
  </Form>
);

Barrel Exports

Each feature should export all public APIs through index.ts:

// src/features/api-keys/index.ts
export * from "./schema";
export * from "./hooks";
export * from "./fields";
export * from "./prompts";

This allows clean imports:

// ✅ Good
import { useApiKeysList, useApiKeyForm, ApiKeyNameField } from "@/features/api-keys";

// ❌ Bad
import { useApiKeysList } from "@/features/api-keys/hooks";
import { useApiKeyForm } from "@/features/api-keys/hooks";
import { ApiKeyNameField } from "@/features/api-keys/fields";

Real-World Example

The src/features/api-keys feature demonstrates the complete 5-file pattern:

  1. schema.ts - Defines ApiKey and ApiKeyUpsert types with Zod validation
  2. functions.ts - Provides create, update, deleteById, getByUserId, searchByName
  3. hooks.ts - Exports useApiKeysList, useApiKeyForm, useApiKeyDelete
  4. fields.tsx - Contains ApiKeyNameField, ApiKeyPrefixField, etc.
  5. prompts.tsx - Provides useApiKeyUpsertPrompt, useApiKeyDeletePrompt

Post-Creation Checklist

After creating a new feature module:

  • Database table created in src/db/tables/
  • Cache tag added to src/db/tags.ts
  • tRPC router created in src/trpc/procedures/routers/
  • Router registered in src/trpc/procedures/root.ts
  • Functions added to src/db/facade.ts
  • Translations added to src/messages/dictionaries/<locale>/
  • Run npm run db:push to sync database

Next Steps

  • Follow the step-by-step guide: New Feature Template
  • Understand error handling: Error Handling Patterns
  • Learn type safety rules: Type Safety Conventions

On this page

Overview
The 5 Core Files
1. schema.ts - Zod Schemas & Types
2. functions.ts - Server-Side Operations
3. hooks.ts - Client-Side tRPC Hooks
4. fields.tsx - Form Field Components
5. prompts.tsx - Dialog Wrappers
Hook Patterns
Query Hooks
Mutation Hooks
Barrel Exports
Real-World Example
Post-Creation Checklist
Next Steps