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

Quick Start

Create your first feature and understand the development workflow

Your First Feature

Let's create a simple "tasks" feature to understand the development workflow.

Step 1: Create the Feature Directory

mkdir -p src/features/tasks

Step 2: Define the Schema

Create src/features/tasks/schema.ts:

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

export const MIN_TASK_TITLE = 3;
export const MAX_TASK_TITLE = 100;

// Main schema
export const taskSchema = baseTableSchema.extend({
  title: z.string().min(MIN_TASK_TITLE).max(MAX_TASK_TITLE),
  description: z.string().optional(),
  completed: z.boolean().default(false),
  ownerId: z.string().uuid(),
});

// Upsert schema
export const taskUpsertSchema = taskSchema
  .omit({ createdAt: true, updatedAt: true })
  .partial({ id: true });

// Types
export type Task = z.infer<typeof taskSchema>;
export type TaskUpsert = z.infer<typeof taskUpsertSchema>;

Step 3: Create the Database Table

Create src/db/tables/tasks.ts:

import { createTable } from "@/db/table-utils";
import { relations } from "drizzle-orm";
import { boolean, text, uuid } from "drizzle-orm/pg-core";
import { users } from "./auth";

export const tasks = createTable("tasks", {
  title: text("title").notNull(),
  description: text("description"),
  completed: boolean("completed").notNull().default(false),
  ownerId: uuid("owner_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
});

export const tasksRelations = relations(tasks, ({ one }) => ({
  owner: one(users, {
    fields: [tasks.ownerId],
    references: [users.id],
  }),
}));

Export in src/db/tables/index.ts:

export * from "./tasks";

Push to database:

npm run db:push

Step 4: Create Functions

Create src/features/tasks/functions.ts:

import { createDrizzleOperations } from "@/db/drizzle-operations";
import { CommonTableData } from "@/db/enums";
import { tasks } from "@/db/tables";
import { Task } from "./schema";
import { eq } from "drizzle-orm";

type DataCore = Omit<Task, keyof CommonTableData>;

const operations = createDrizzleOperations<typeof tasks, Task>({
  table: tasks,
});

export async function list() {
  return operations.listDocuments();
}

export async function get(id: string) {
  return operations.getDocument(id);
}

export async function create(data: DataCore) {
  return operations.createDocument(data);
}

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

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

export async function getByOwnerId(ownerId: string) {
  return operations.listDocuments(eq(tasks.ownerId, ownerId));
}

Step 5: Add to Database Facade

Add to src/db/facade.ts:

import * as tasks from "@/features/tasks/functions";

export const dbFacade = {
  // ... existing
  tasks,
};

Step 6: Create oRPC Router

Create src/rpc/procedures/routers/tasks.ts:

import { taskUpsertSchema } from "@/features/tasks/schema";
import { authProcedure, createRPCRouter } from "@/rpc/procedures/rpc";
import { ActionResponse } from "@/types";
import { z } from "zod";

export const tasksRouter = createRPCRouter({
  list: authProcedure
    .meta({ rateLimit: "QUERY" })
    .input(z.object({}))
    .query(async ({ ctx }) => {
      const tasks = await ctx.db.tasks.getByOwnerId(ctx.user.id);
      return {
        success: true,
        payload: tasks,
      } satisfies ActionResponse;
    }),

  upsert: authProcedure
    .meta({ rateLimit: "MUTATION" })
    .input(taskUpsertSchema)
    .mutation(async ({ ctx, input }) => {
      const { id, ...data } = input;
      
      const task = id
        ? await ctx.db.tasks.update(id, data)
        : await ctx.db.tasks.create({ ...data, ownerId: ctx.user.id });

      return {
        success: true,
        message: ctx.t("toasts.saved"),
        payload: task,
      } satisfies ActionResponse;
    }),

  delete: authProcedure
    .meta({ rateLimit: "MUTATION" })
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      await ctx.db.tasks.remove(input.id);
      return {
        success: true,
        message: ctx.t("toasts.deleted"),
      } satisfies ActionResponse;
    }),
});

Add to src/rpc/procedures/root.ts:

import { tasksRouter } from "./routers/tasks";

export const appRouter = createRPCRouter({
  // ... existing
  tasks: tasksRouter,
});

Step 7: Create Client Hooks

Create src/features/tasks/hooks.ts:

"use client";

import { api } from "@/rpc/react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { taskUpsertSchema, TaskUpsert } from "./schema";

export function useTasksQuery() {
  const query = api.tasks.list.useQuery({});
  return {
    tasks: query.data?.payload ?? [],
    loading: query.isLoading,
    refetch: query.refetch,
  };
}

export function useTaskForm(defaultValues: TaskUpsert | null) {
  const utils = api.useUtils();
  const form = useForm<TaskUpsert>({
    resolver: zodResolver(taskUpsertSchema),
    defaultValues: defaultValues ?? { title: "", completed: false },
  });

  const mutation = api.tasks.upsert.useMutation({
    onSuccess: () => {
      utils.tasks.list.invalidate();
    },
  });

  const save = form.handleSubmit(async (data) => {
    await mutation.mutateAsync(data);
  });

  return {
    form,
    save,
    loading: mutation.isPending,
  };
}

export function useTaskDelete() {
  const utils = api.useUtils();
  const mutation = api.tasks.delete.useMutation({
    onSuccess: () => {
      utils.tasks.list.invalidate();
    },
  });

  return {
    deleteTask: (id: string) => mutation.mutateAsync({ id }),
    loading: mutation.isPending,
  };
}

Step 8: Create a Simple Component

Create src/features/tasks/components/task-list.tsx:

"use client";

import { Button } from "@/components/ui/button";
import { Icon } from "@/lib/icons";
import { useTasksQuery, useTaskDelete } from "../hooks";

export function TaskList() {
  const { tasks, loading } = useTasksQuery();
  const { deleteTask } = useTaskDelete();

  if (loading) return <div>Loading...</div>;

  return (
    <div className="space-y-2">
      {tasks.map((task) => (
        <div key={task.id} className="flex items-center gap-2 p-4 border rounded">
          <span>{task.title}</span>
          <Button
            size="sm"
            variant="destructive"
            onClick={() => deleteTask(task.id)}
          >
            <Icon iconKey="trash" />
          </Button>
        </div>
      ))}
    </div>
  );
}

Testing Your Feature

  1. Start the dev server: npm run dev
  2. Import and use <TaskList /> in any page
  3. The tasks will be filtered by the current user automatically

What You Learned

  • Schema definition with Zod
  • Database table creation with Drizzle
  • Server functions using createDrizzleOperations
  • oRPC router with type-safe procedures
  • Client hooks for queries and mutations
  • Component integration with React Query

Next Steps

For more complex features with forms, dialogs, and field components:

  • Feature Modules - Complete 5-file pattern
  • Templates - Step-by-step feature creation guide
  • Database Operations - Advanced queries and caching

On this page

Your First Feature
Step 1: Create the Feature Directory
Step 2: Define the Schema
Step 3: Create the Database Table
Step 4: Create Functions
Step 5: Add to Database Facade
Step 6: Create oRPC Router
Step 7: Create Client Hooks
Step 8: Create a Simple Component
Testing Your Feature
What You Learned
Next Steps