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 tRPC 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 exportThis 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
baseTableSchemafor common fields (id, createdAt, updatedAt) - Apply
.refine()only on final upsert schemas (see Type Safety) - Use
params.i18nfor translatable error messages - Export both schemas and inferred types
2. functions.ts - Server-Side Operations
Purpose: Database operations using createDrizzleOperations abstraction.
Key concepts:
- Use
createDrizzleOperationsfor standard CRUD - Add custom operations as needed (search, pagination, filtering)
- All functions are server-side only
- Leverage Next.js
unstable_cachefor 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 listTable(params: TablePagination) {
return operations.listTable(params);
}Best practices:
- Use
createDrizzleOperationsfor 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 tRPC Hooks
Purpose: Client-side data fetching and mutations using tRPC.
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:
| Type | Pattern | Returns |
|---|---|---|
| Query | use<Feature>List() | { data, loading<Feature>, refetching<Feature>, refetch<Feature>, query } |
| Mutation | use<Feature>Form() | { form<Feature>, save<Feature>, loading<Feature>, mutation<Feature> } |
| Delete | use<Feature>Delete() | { delete<Feature>, loading<Feature>, mutation<Feature> } |
Example from src/features/api-keys/hooks.ts:
"use client";
import { api } from "@/trpc/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
useFormwithzodResolverfor 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
useFormContextto access form state - Import from
@/components/ui/formfor 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
confirmButtonAPI 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:
| Property | Type | Description |
|---|---|---|
action | () => Promise<void> | Async function to execute on confirm |
variant | "primary" | "destructive" | Button style |
icon | string | Icon key from lucide-react |
i18nButtonKey | string | Translation key for button text |
Best practices:
- Use
usePrompt()for all dialogs - Pass
formto 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
confirmButtonis 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:
- schema.ts - Defines
ApiKeyandApiKeyUpserttypes with Zod validation - functions.ts - Provides
create,update,deleteById,getByUserId,searchByName - hooks.ts - Exports
useApiKeysList,useApiKeyForm,useApiKeyDelete - fields.tsx - Contains
ApiKeyNameField,ApiKeyPrefixField, etc. - 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 - tRPC router created in
src/trpc/procedures/routers/ - Router registered in
src/trpc/procedures/root.ts - Functions added to
src/db/facade.ts - Translations added to
src/messages/dictionaries/<locale>/ - Run
npm run db:pushto 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