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

Create New Feature

Step-by-step guide for creating a complete feature module using the 5-file pattern

Overview

This guide walks you through creating a complete feature module following the 5-file pattern. A feature module encapsulates all logic for a specific domain entity (e.g., api-keys, notifications, projects).

The 5-file pattern provides:

  • schema.ts - Zod schemas and TypeScript types
  • functions.ts - Server-side database operations
  • hooks.ts - Client-side tRPC hooks
  • fields.tsx - Form field components (optional)
  • prompts.tsx - Dialog wrappers (optional)

Complete features typically take 30-60 minutes to scaffold. Follow each step carefully to avoid missing critical pieces.

Prerequisites

Before starting, gather this information:

Step 1: Feature Name

Choose a descriptive name in kebab-case (e.g., api-keys, notifications, project-settings)

Step 2: Main Fields

List the primary fields: name, description, status, settings, etc.

Step 3: Owner Relationship

Determine ownership model:

  • User-owned - userId foreign key
  • Organization-owned - organizationId foreign key
  • Standalone - No owner (rare)

Directory Structure

Create the feature directory:

mkdir -p src/features/<feature-name>

Final structure will be:

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

Step 1: Create Schema (schema.ts)

Create src/features/<feature-name>/schema.ts:

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

// -------------------- CONSTANTS --------------------
export const MIN_<FEATURE>_NAME = 3;
export const MAX_<FEATURE>_NAME = 50;

// -------------------- SCHEMAS --------------------

// Main schema (mirrors database table structure)
export const <feature>Schema = baseTableSchema.extend({
  name: z.string().min(MIN_<FEATURE>_NAME).max(MAX_<FEATURE>_NAME),
  description: z.string().optional(),
  
  // Owner reference
  ownerId: z.uuid(),
  
  // Add your fields here...
  // enabled: z.boolean(),
  // status: z.enum(["active", "inactive"]),
});

// Upsert schema - used for both create and update
// id is optional: present for update, absent for create
export const <feature>UpsertSchema = <feature>Schema
  .omit({ 
    createdAt: true, 
    updatedAt: true,
    ownerId: true, // Set server-side from auth context
  })
  .partial({ id: true });

// Delete schema - only requires id
export const <feature>DeleteSchema = <feature>Schema.pick({ id: true });

// Get schema - for fetching by id
export const <feature>GetSchema = <feature>Schema.pick({ id: true });

// -------------------- TYPES --------------------
export type <Feature> = z.infer<typeof <feature>Schema>;
export type <Feature>Upsert = z.infer<typeof <feature>UpsertSchema>;

Naming Convention:

  • Schema names: camelCase with Schema suffix
  • Type names: PascalCase without suffix
  • Constants: UPPER_SNAKE_CASE

Step 2: Create Database Table

See the complete Create Database Table guide for detailed instructions.

Quick summary:

  1. Create src/db/tables/<feature-name>.ts
  2. Export in src/db/tables/index.ts
  3. Add cache tag in src/db/tags.ts (if using pagination)
  4. Run npm run db:push

Example table:

import { commonColumns, createTable } from "@/db/table-utils";
import { users } from "@/db/tables";
import { authenticatedRole, isOwner, serviceRole, allowAll } from "@/db/rls";
import { pgPolicy, varchar, text, uuid, index } from "drizzle-orm/pg-core";

export const <feature>s = createTable(
  "<feature>s",
  {
    ...commonColumns,
    name: varchar({ length: 255 }).notNull(),
    description: text(),
    ownerId: uuid()
      .notNull()
      .references(() => users.id, { onDelete: "cascade" }),
  },
  (t) => [
    // Indexes
    index("<feature>s_owner_id_idx").on(t.ownerId),
    
    // RLS Policies
    pgPolicy("<feature>s-select-own", {
      for: "select",
      to: authenticatedRole,
      using: isOwner(t.ownerId),
    }),
    pgPolicy("<feature>s-insert-own", {
      for: "insert",
      to: authenticatedRole,
      withCheck: isOwner(t.ownerId),
    }),
    pgPolicy("<feature>s-update-own", {
      for: "update",
      to: authenticatedRole,
      using: isOwner(t.ownerId),
      withCheck: isOwner(t.ownerId),
    }),
    pgPolicy("<feature>s-delete-own", {
      for: "delete",
      to: authenticatedRole,
      using: isOwner(t.ownerId),
    }),
    pgPolicy("<feature>s-all-service", {
      for: "all",
      to: serviceRole,
      using: allowAll,
      withCheck: allowAll,
    }),
  ],
);

Step 3: Implement Functions (functions.ts)

Create src/features/<feature-name>/functions.ts:

import { createDrizzleOperations } from "@/db/drizzle-operations";
import { CommonTableData } from "@/db/enums";
import { <feature>s } from "@/db/tables";
import { <Feature> } from "@/features/<feature-name>/schema";
import type { TablePagination } from "@/forms/table-list/types";
import { eq, type SQL } from "drizzle-orm";

// Core data type (excludes common fields like id, createdAt, updatedAt)
type DataCore = Omit<<Feature>, keyof CommonTableData>;

// Create standard CRUD operations
const operations = createDrizzleOperations<typeof <feature>s, <Feature>>({
  table: <feature>s,
});

// -------------------- QUERIES --------------------

/**
 * List all documents
 */
export async function list() {
  return operations.listDocuments();
}

/**
 * List documents with pagination (for tables)
 */
export async function listTable(params: TablePagination) {
  return operations.listTable(params);
}

/**
 * List documents with custom filter
 */
export async function listFiltered(whereClause?: SQL) {
  return operations.listDocuments(whereClause);
}

/**
 * Get single document by ID
 */
export async function get(id: string) {
  return operations.getDocument(id);
}

// -------------------- MUTATIONS --------------------

/**
 * Create new document
 */
export async function create(data: DataCore) {
  return operations.createDocument(data);
}

/**
 * Update existing document
 */
export async function update(id: string, data: Partial<DataCore>) {
  return operations.updateDocument(id, data);
}

/**
 * Delete document
 */
export async function remove(id: string) {
  return operations.removeDocument(id);
}

// -------------------- FEATURE-SPECIFIC QUERIES --------------------

/**
 * Get all documents for a specific owner
 */
export async function getByOwnerId(ownerId: string) {
  return listFiltered(eq(<feature>s.ownerId, ownerId));
}

// Add more custom queries as needed...

When to add custom queries:

  • Filtering by specific fields (getByStatus, getActive)
  • Complex joins with other tables
  • Aggregations or statistics
  • Special business logic queries

Step 4: Create tRPC Router

See the complete Create tRPC Router guide for detailed instructions.

Create src/trpc/procedures/routers/<feature-name>.ts:

import {
  <feature>DeleteSchema,
  <feature>GetSchema,
  <feature>UpsertSchema,
} from "@/features/<feature-name>/schema";
import { authProcedure, createTRPCRouter } from "@/trpc/procedures/trpc";
import type { ActionResponse } from "@/types";

export const <feature>sRouter = createTRPCRouter({
  // List all for current user
  list: authProcedure
    .meta({ rateLimit: "QUERY" })
    .query(async ({ ctx }) => {
      const items = await ctx.db.<feature>s.getByOwnerId(ctx.user.id);
      
      return {
        success: true,
        payload: items,
      } satisfies ActionResponse;
    }),
  
  // Get by ID
  getById: authProcedure
    .meta({ rateLimit: "QUERY" })
    .input(<feature>GetSchema)
    .query(async ({ ctx, input }) => {
      const item = await ctx.db.<feature>s.get(input.id);
      
      return {
        success: true,
        payload: item,
      } satisfies ActionResponse;
    }),
  
  // Create or update
  upsert: authProcedure
    .meta({ rateLimit: "MUTATION" })
    .input(<feature>UpsertSchema)
    .mutation(async ({ ctx, input }) => {
      const { id, ...data } = input;
      
      if (id) {
        // Update existing
        const updated = await ctx.db.<feature>s.update(id, data);
        return {
          success: true,
          message: ctx.t("toasts.saved"),
          payload: updated,
        } satisfies ActionResponse;
      }
      
      // Create new
      const created = await ctx.db.<feature>s.create({
        ...data,
        ownerId: ctx.user.id,
      });
      
      return {
        success: true,
        message: ctx.t("toasts.saved"),
        payload: created,
      } satisfies ActionResponse;
    }),
  
  // Delete
  delete: authProcedure
    .meta({ rateLimit: "MUTATION" })
    .input(<feature>DeleteSchema)
    .mutation(async ({ ctx, input }) => {
      await ctx.db.<feature>s.remove(input.id);
      
      return {
        success: true,
        message: ctx.t("toasts.deleted"),
      } satisfies ActionResponse;
    }),
});

Register in src/trpc/procedures/root.ts:

import { <feature>sRouter } from "@/trpc/procedures/routers/<feature-name>";

export const appRouter = createTRPCRouter({
  // ...existing routers
  <feature>s: <feature>sRouter,
});

Step 5: Create Hooks (hooks.ts)

Create src/features/<feature-name>/hooks.ts:

"use client";

import { <feature>UpsertSchema, <Feature>Upsert } from "@/features/<feature-name>/schema";
import { useStrictForm } from "@/lib/forms/defaults";
import { api } from "@/trpc/react";

// -------------------- QUERIES --------------------

/**
 * Hook to list all items for current user
 */
export function use<Feature>sList() {
  const query = api.<feature>s.list.useQuery();
  
  return {
    <feature>s: query.data?.payload || [],
    loading<Feature>s: query.isLoading,
    query, // Expose full query for advanced usage
  };
}

/**
 * Hook to get single item by ID
 */
export function use<Feature>(id: string | undefined) {
  const query = api.<feature>s.getById.useQuery(
    { id: id! },
    { enabled: !!id }
  );
  
  return {
    <feature>: query.data?.payload,
    loading<Feature>: query.isLoading,
    query,
  };
}

// -------------------- MUTATIONS --------------------

/**
 * Hook for create/update form
 */
export function use<Feature>Form(data: <Feature>Upsert | null = null) {
  const utils = api.useUtils();
  const isUpdate = !!data?.id;
  
  const mutation = api.<feature>s.upsert.useMutation({
    onSuccess: (result) => {
      if (result.success) {
        // Invalidate queries to refetch data
        utils.<feature>s.invalidate();
        
        // Reset form after create (not update)
        if (!isUpdate) form.reset();
      }
    },
  });
  
  const { form, execute } = useStrictForm({
    schema: <feature>UpsertSchema,
    defaultValues: {
      id: data?.id,
      name: data?.name || "",
      description: data?.description || "",
      // Add your fields...
    },
    mutation,
  });
  
  return {
    form<Feature>: form,
    save<Feature>: execute,
    loading<Feature>: mutation.isPending,
    isUpdate,
  };
}

/**
 * Hook for delete operation
 */
export function use<Feature>Delete() {
  const utils = api.useUtils();
  
  const mutation = api.<feature>s.delete.useMutation({
    onSuccess: (data) => {
      if (data.success) {
        utils.<feature>s.invalidate();
      }
    },
  });
  
  return {
    delete<Feature>: mutation.mutateAsync,
    isDeleting<Feature>: mutation.isPending,
  };
}

Step 6: Create Form Fields (fields.tsx)

Create src/features/<feature-name>/fields.tsx (optional):

"use client";

import {
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { <Feature>Upsert } from "@/features/<feature-name>/schema";
import { useTranslations } from "next-intl";
import { UseFormReturn } from "react-hook-form";

type FormFields<Feature>Props = {
  form: UseFormReturn<<Feature>Upsert>;
};

export function FormFields<Feature>({ form }: FormFields<Feature>Props) {
  const t = useTranslations("page<Feature>s");
  
  return (
    <>
      {/* Name Field */}
      <FormField
        control={form.control}
        name="name"
        render={({ field }) => (
          <FormItem>
            <FormLabel>{t("form.fields.name.label")}</FormLabel>
            <FormControl>
              <Input 
                placeholder={t("form.fields.name.placeholder")} 
                {...field} 
              />
            </FormControl>
            <FormMessage />
          </FormItem>
        )}
      />
      
      {/* Description Field */}
      <FormField
        control={form.control}
        name="description"
        render={({ field }) => (
          <FormItem>
            <FormLabel>{t("form.fields.description.label")}</FormLabel>
            <FormControl>
              <Textarea 
                placeholder={t("form.fields.description.placeholder")}
                {...field}
                value={field.value || ""}
              />
            </FormControl>
            <FormDescription>
              {t("form.fields.description.hint")}
            </FormDescription>
            <FormMessage />
          </FormItem>
        )}
      />
      
      {/* Add more fields as needed */}
    </>
  );
}

Important: Always use field.value || "" for optional string fields to prevent React uncontrolled/controlled component warnings.

Step 7: Create Prompts (prompts.tsx)

Create src/features/<feature-name>/prompts.tsx (optional):

"use client";

import { FormFields<Feature> } from "@/features/<feature-name>/fields";
import {
  use<Feature>Delete,
  use<Feature>Form,
} from "@/features/<feature-name>/hooks";
import type { <Feature> } from "@/features/<feature-name>/schema";
import { usePrompt } from "@/forms/prompt";
import { useTranslations } from "next-intl";

/**
 * Dialog for create/update
 */
export function use<Feature>UpsertPrompt(data: <Feature> | null = null) {
  const t = useTranslations("page<Feature>s");
  const prompt = usePrompt();
  const { form<Feature>, save<Feature> } = use<Feature>Form(data);
  
  return () =>
    prompt({
      form: form<Feature>,
      children: <FormFields<Feature> form={form<Feature>} />,
      title: data ? t("form.dialog.update") : t("form.dialog.create"),
      confirmButton: {
        action: save<Feature>,
        variant: "primary",
        icon: "save",
        i18nButtonKey: "save",
      },
    });
}

/**
 * Dialog for delete confirmation
 */
export function use<Feature>DeletePrompt(data: <Feature>) {
  const t = useTranslations("page<Feature>s");
  const prompt = usePrompt();
  const { delete<Feature> } = use<Feature>Delete();
  
  return () =>
    prompt({
      title: t("form.dialog.delete"),
      description: t("form.dialog.deleteConfirmation", { name: data.name }),
      confirmButton: {
        action: () => delete<Feature>({ id: data.id }),
        variant: "destructive",
        icon: "trash",
        i18nButtonKey: "delete",
      },
    });
}

Step 8: Create Barrel Export

Create src/features/<feature-name>/index.ts:

// Schemas and types
export * from "./schema";

// Server functions
export * from "./functions";

// Client hooks
export * from "./hooks";

Step 9: Add to Database Facade

In src/db/facade.ts:

import * as <feature>s from "@/features/<feature-name>/functions";

export const db = {
  // ...existing features
  <feature>s,
};

This makes functions available as ctx.db.<feature>s.* in tRPC procedures.

Step 10: Add Translations

See the complete Add Translations guide for detailed instructions.

Create src/messages/dictionaries/en/page<Feature>s.json:

{
  "seo": {
    "title": "<Feature>s",
    "description": "Manage your <feature>s"
  },
  "heading": {
    "title": "<Feature>s",
    "description": "View and manage your <feature>s"
  },
  "form": {
    "fields": {
      "name": {
        "label": "Name",
        "placeholder": "Enter <feature> name..."
      },
      "description": {
        "label": "Description",
        "placeholder": "Enter description...",
        "hint": "Optional description for this <feature>"
      }
    },
    "dialog": {
      "create": "Create <Feature>",
      "update": "Update <Feature>",
      "delete": "Delete <Feature>",
      "deleteConfirmation": "Are you sure you want to delete {name}?"
    }
  },
  "list": {
    "empty": {
      "title": "No <feature>s yet",
      "description": "Create your first <feature> to get started"
    }
  },
  "toasts": {
    "created": "<Feature> created successfully",
    "updated": "<Feature> updated successfully",
    "deleted": "<Feature> deleted successfully"
  }
}

Repeat for other locales (e.g., it/page<Feature>s.json).

Post-Creation Checklist

  1. Schema & Types

    • schema.ts created with base schemas
    • Validation constants defined
    • Upsert, delete, and get schemas defined
    • TypeScript types exported
  2. Database

    • Table created in src/db/tables/<feature-name>.ts
    • Table exported in src/db/tables/index.ts
    • Cache tag added in src/db/tags.ts (if using pagination)
    • Run npm run db:push successfully
  3. Server Logic

    • functions.ts created with CRUD operations
    • Custom queries added (if needed)
    • Functions added to database facade
  4. API

    • tRPC router created
    • Router registered in root.ts
    • Rate limiting configured
    • Input/output schemas validated
  5. Client Logic

    • hooks.ts created with queries and mutations
    • Form hook implemented
    • Delete hook implemented
  6. UI (Optional)

    • fields.tsx created with form fields
    • prompts.tsx created with dialogs
    • Custom components added (if needed)
  7. Internationalization

    • Translations added for all locales
    • Translation keys tested in UI
  8. Testing

    • Create operation works
    • Update operation works
    • Delete operation works
    • List operation works
    • RLS policies enforced

Real-World Example

See src/features/api-keys for a complete implementation following this exact pattern.

Congratulations! You've created a complete feature module. The feature is now ready to be used in pages and components.

On this page

Overview
Prerequisites
Step 1: Feature Name
Step 2: Main Fields
Step 3: Owner Relationship
Directory Structure
Step 1: Create Schema (schema.ts)
Step 2: Create Database Table
Step 3: Implement Functions (functions.ts)
Step 4: Create tRPC Router
Step 5: Create Hooks (hooks.ts)
Step 6: Create Form Fields (fields.tsx)
Step 7: Create Prompts (prompts.tsx)
Step 8: Create Barrel Export
Step 9: Add to Database Facade
Step 10: Add Translations
Post-Creation Checklist
Real-World Example