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

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

See the complete Create oRPC Router guide for detailed instructions.

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

import {
  <feature>DeleteSchema,
  <feature>GetSchema,
  <feature>UpsertSchema,
} from "@/features/<feature-name>/schema";
import { authProcedure, createRPCRouter } from "@/rpc/procedures/rpc";
import { type ActionResponse } from "@/lib/utils/schema-utils";

export const <feature>sRouter = createRPCRouter({
  // 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/rpc/procedures/root.ts:

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

export const appRouter = createRPCRouter({
  // ...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 "@/rpc/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 oRPC 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

    • oRPC 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 oRPC 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