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 -
userIdforeign key - Organization-owned -
organizationIdforeign 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 exportStep 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:
camelCasewithSchemasuffix - Type names:
PascalCasewithout suffix - Constants:
UPPER_SNAKE_CASE
Step 2: Create Database Table
See the complete Create Database Table guide for detailed instructions.
Quick summary:
- Create
src/db/tables/<feature-name>.ts - Export in
src/db/tables/index.ts - Add cache tag in
src/db/tags.ts(if using pagination) - 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
-
Schema & Types
-
schema.tscreated with base schemas - Validation constants defined
- Upsert, delete, and get schemas defined
- TypeScript types exported
-
-
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:pushsuccessfully
- Table created in
-
Server Logic
-
functions.tscreated with CRUD operations - Custom queries added (if needed)
- Functions added to database facade
-
-
API
- tRPC router created
- Router registered in
root.ts - Rate limiting configured
- Input/output schemas validated
-
Client Logic
-
hooks.tscreated with queries and mutations - Form hook implemented
- Delete hook implemented
-
-
UI (Optional)
-
fields.tsxcreated with form fields -
prompts.tsxcreated with dialogs - Custom components added (if needed)
-
-
Internationalization
- Translations added for all locales
- Translation keys tested in UI
-
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.