Documentation
Documentation
Introduction

Getting Started

Getting started
Getting StartedInstallationQuick StartProject Structure

Configuration

Configuration
ConfigurationEnvironment ConfigurationEdge ConfigDatabaseAuth SecretStripeFirebaseStorageGoogle Maps And Cloud Service AccountOAuth ProvidersEmail DeliverySentryFeature Flags

Architecture

Architecture
Architecture OverviewTech StackoRPC MiddlewareDesign Principles

Patterns

Patterns
Code Patterns & ConventionsFeature ModulesError HandlingType Safety

Database

Database
DatabaseSetupSchema DefinitionDatabase OperationsMigrationsCaching
Data Tables

API

oRPCProceduresRoutersoRPC Proxy Setup
APIsOpenAPIREST Endpoints

Auth & Access

AuthenticationConfigurationOAuth ProvidersRolesSession Management
AuthorizationUser RolesPermissions

Routing & i18n

RoutingDeclarative RoutingNavigation
InternationalizationTranslationsLocale Routing

Components & UI

ComponentsButtonsFormsNavigationDialogs
StylesTailwind CSSThemingTypography

Storage

Storage
StorageConfigurationUsageBuckets
Stripe Billing

Extra

Caching

Templates

Templates
Template GuidesCreate New FeatureCreate New PageCreate Database TableCreate oRPC RouterAdd Translations

Development

Development
DevelopmentCommandsAI AgentsBest Practices
Pulling Updates

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 oRPC 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 table(params: TablePagination) {
  return operations.table(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 oRPC Hooks

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

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 "@/rpc/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
  • oRPC router created in src/rpc/procedures/routers/
  • Router registered in src/rpc/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 oRPC 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