Best Practices
Development conventions, patterns, and guidelines
Overview
This guide covers the essential development practices, conventions, and patterns used throughout this template. Following these guidelines ensures code consistency, maintainability, and type safety.
These practices are enforced by configured AI agents and should be followed by all developers.
Type Safety
Use type Instead of interface
Always use TypeScript type declarations, never interface:
// ✅ Good
type User = {
id: string;
name: string;
email: string;
};
type UserWithRole = User & {
role: string;
};
// ❌ Bad
interface User {
id: string;
name: string;
email: string;
}Why: Consistency across the codebase and better intersection type support.
Define Zod Schemas for Data
All data structures should have Zod schemas for runtime validation:
// ✅ Good
import { z } from "zod";
export const userSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
});
export type User = z.infer<typeof userSchema>;
// ❌ Bad - No runtime validation
export type User = {
id: string;
name: string;
email: string;
};Use as const for Literal Types
// ✅ Good
export const USER_ROLES = ["admin", "user", "guest"] as const;
export type UserRole = (typeof USER_ROLES)[number];
// ❌ Bad
export const USER_ROLES = ["admin", "user", "guest"];
export type UserRole = string;Code Style
Use Double Quotes
Always use double quotes for strings:
// ✅ Good
const message = "Hello, world!";
// ❌ Bad
const message = 'Hello, world!';Import Organization
Follow this import order:
// 1. External packages
import { z } from "zod";
import { NextRequest } from "next/server";
// 2. Internal aliases
import { db } from "@/db";
import { userSchema } from "@/features/auth";
// 3. Relative imports
import { Button } from "./button";
import type { Props } from "./types";Server/Client Separation
Mark Client Components Explicitly
Add "use client" directive only when needed:
// ✅ Good - Client Component (needs interactivity)
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
export function Counter() {
const [count, setCount] = useState(0);
return <Button onClick={() => setCount(count + 1)}>{count}</Button>;
}// ✅ Good - Server Component (default)
import { db } from "@/db";
import { UserList } from "./user-list";
export default async function UsersPage() {
const users = await db.query.users.findMany();
return <UserList users={users} />;
}Use server-only for Server Code
Mark server-only modules explicitly:
// src/lib/server/user-service.ts
import "server-only";
import { db } from "@/db";
export async function getUserById(id: string) {
return db.query.users.findFirst({ where: eq(users.id, id) });
}Never import server-only modules in Client Components. TypeScript will error if you try.
Database Operations
Use createDrizzleOperations Abstraction
For standard CRUD operations, always use the abstraction:
// ✅ Good
import { createDrizzleOperations } from "@/db/drizzle-operations";
import { users } from "@/db/tables/users";
export const userOperations = createDrizzleOperations({
table: users,
});
// Usage
const allUsers = await userOperations.listDocuments();
const user = await userOperations.getDocument(id);
const newUser = await userOperations.createDocument(data);
await userOperations.updateDocument(id, data);
await userOperations.deleteDocument(id);// ❌ Bad - Raw Drizzle query (only for complex joins)
const users = await db.query.users.findMany({
where: eq(users.active, true),
});Use Caching Appropriately
When using custom queries, wrap them with unstable_cache and appropriate tags:
// ✅ Good
import { unstable_cache } from "next/cache";
import { users } from "@/db/tables/users";
import { TableTags } from "@/db/tags";
export const getUsers = unstable_cache(
async () => {
return userOperations.listDocuments();
},
["users-list"],
{
tags: [TableTags.users],
}
);Handle Errors Gracefully
Return ActionResponse for consistent error handling:
// ✅ Good
import type { ActionResponse } from "@/types";
export async function createUser(data: NewUser): Promise<ActionResponse<User>> {
try {
const user = await userOperations.createDocument(data);
return {
success: true,
message: "User created successfully",
payload: user,
};
} catch (error) {
return {
success: false,
message: "Failed to create user",
};
}
}tRPC Patterns
Prefetch in Server Components
Prefetch tRPC queries in Server Components for instant data on client:
// ✅ Good - Server Component
import { api } from "@/trpc/server";
export default async function UsersPage() {
await api.users.list.prefetch();
return <UserList />;
}
// Client Component
"use client";
export function UserList() {
const { data: users } = api.users.list.useQuery();
return <div>{users?.map(user => ...)}</div>;
}Use Invalidation, Not revalidatePath
After mutations, invalidate tRPC queries instead of using Next.js revalidatePath:
// ✅ Good
"use client";
export function CreateUserForm() {
const utils = api.useUtils();
const createUser = api.users.create.useMutation({
onSuccess: () => {
utils.users.list.invalidate();
},
});
return <form onSubmit={...} />;
}// ❌ Bad - Don't use revalidatePath with tRPC
import { revalidatePath } from "next/cache";
export async function createUser(data: NewUser) {
await userOperations.createDocument(data);
revalidatePath("/users"); // Don't do this with tRPC
}Return ActionResponse
tRPC procedures should return ActionResponse for consistent error handling:
// ✅ Good
import type { ActionResponse } from "@/types";
export const userRouter = router({
create: protectedProcedure
.input(createUserSchema)
.mutation(async ({ input, ctx }): Promise<ActionResponse<User>> => {
try {
const user = await userOperations.createDocument(input);
return {
success: true,
message: ctx.t("users.created"),
payload: user,
};
} catch (error) {
return {
success: false,
message: ctx.t("users.createFailed"),
};
}
}),
});Styling
Use the cn() Helper
Combine class names with the cn() utility:
// ✅ Good
import { cn } from "@/lib/utils";
export function Button({ className, disabled }: Props) {
return (
<button
className={cn(
"rounded-lg bg-primary px-4 py-2 text-white",
disabled && "opacity-50 cursor-not-allowed",
className
)}
/>
);
}Use Design Tokens
Use Tailwind design tokens from tailwind.config.ts, never arbitrary values:
// ✅ Good
<div className="w-full max-w-md space-y-4 rounded-lg border p-6" />
// ❌ Bad - Arbitrary values
<div className="w-[400px] h-[200px] rounded-[12px] p-[24px]" />Use Component Abstractions
Use project components instead of raw HTML:
// ✅ Good
import { H1, H2, P } from "@/components/ui/typography";
import { Section } from "@/components/section";
import { Icon } from "@/components/ui/icon";
export function HomePage() {
return (
<Section>
<H1>Welcome</H1>
<P>Get started with our app</P>
<Icon iconKey="arrow-right" />
</Section>
);
}// ❌ Bad
export function HomePage() {
return (
<div className="container mx-auto px-4">
<h1 className="text-4xl font-bold">Welcome</h1>
<p className="text-gray-600">Get started with our app</p>
</div>
);
}Never import from lucide-react directly. Use <Icon iconKey="..." /> instead.
Forms
Use React Hook Form + Zod
Always use React Hook Form with Zod for forms:
// ✅ Good
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
export function UserForm() {
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { name: "", email: "" },
});
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.register("name")} />
<input {...form.register("email")} />
</form>
);
}Create Reusable Field Components
Extract reusable form fields:
// src/features/users/fields.tsx
import { useFormContext } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function UserNameField() {
const { register, formState: { errors } } = useFormContext();
return (
<div>
<Label htmlFor="name">Name</Label>
<Input {...register("name")} />
{errors.name && <p className="text-destructive">{errors.name.message}</p>}
</div>
);
}Configuration & Constants
Use Namespaced Config
Import from namespaced config modules:
// ✅ Good
import { AuthConfig, ThemeConfig } from "@/config";
const redirectUrl = AuthConfig.redirectAfterLogin;
const primaryColor = ThemeConfig.colors.primary;Define Constants Centrally
Define magic values in constants:
// ✅ Good
import { VALIDATION, UI, DATE } from "@/constants";
const maxNameLength = VALIDATION.MAX_NAME_LENGTH;
const pageSize = UI.DEFAULT_PAGE_SIZE;// ❌ Bad - Magic numbers
const maxLength = 100;
const pageSize = 20;Error Handling
Use Custom Error Classes
Import and use custom error classes:
// ✅ Good
import { NotFoundError, ValidationError } from "@/lib/errors";
if (!user) {
throw new NotFoundError("User");
}
if (!isValid) {
throw new ValidationError("Invalid input data");
}Provide Translated Error Messages
Use i18n keys for error messages:
// ✅ Good
import { useTranslations } from "next-intl";
export function UserForm() {
const t = useTranslations("users");
return {
success: false,
message: t("errors.notFound"),
};
}Testing
Type Check Before Committing
npm run type-checkAlways run type checking before committing to catch type errors early.
Lint Before Committing
npm run lintEnsure code follows linting rules before pushing.
Test Production Builds Locally
npm run build
npm run startTest production builds locally to catch build-time errors.
Security
Validate All Input
Use Zod schemas to validate all user input:
// ✅ Good
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const result = schema.safeParse(input);
if (!result.success) {
return { success: false, message: "Invalid input" };
}Never Expose Secrets
Never commit sensitive data:
// ✅ Good - Use environment variables
import { env } from "@/env/env-server";
const apiKey = env.API_KEY;
// ❌ Bad
const apiKey = "sk_live_abc123...";Use Server Actions Carefully
Mark server actions with appropriate protections:
// ✅ Good
import { protectedProcedure } from "@/trpc/init";
export const deleteUser = protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
// Only authenticated users can delete
await userOperations.deleteDocument(input.id);
});Performance
Use Server Components by Default
Prefer Server Components for better performance:
// ✅ Good - Server Component (default)
export default async function UsersPage() {
const users = await db.query.users.findMany();
return <UserList users={users} />;
}
// Only add "use client" when needed
"use client";
export function InteractiveUserList() {
const [selected, setSelected] = useState<string[]>([]);
// ...
}Prefetch Data
Prefetch data in Server Components to avoid waterfalls:
// ✅ Good
export default async function DashboardPage() {
await Promise.all([
api.users.list.prefetch(),
api.organizations.list.prefetch(),
api.stats.summary.prefetch(),
]);
return <Dashboard />;
}Use Next.js Caching
Use unstable_cache for expensive operations:
// ✅ Good
import { unstable_cache } from "next/cache";
import { TableTags } from "@/db/tags";
export const getExpensiveData = unstable_cache(
async () => {
// Expensive computation or API call
return await fetchData();
},
["expensive-data"],
{
tags: [TableTags.data],
}
);Common Patterns
Feature Module Structure
Follow the 5-file pattern for features:
src/features/users/
├── schema.ts # Zod schemas and types
├── functions.ts # Server-side database operations
├── hooks.ts # Client-side tRPC hooks
├── fields.tsx # Reusable form fields
└── prompts.tsx # Dialog wrappersPage Creation
- Create page file:
src/app/[locale]/<path>/page.tsx - Create page info:
src/app/[locale]/<path>/page.info.ts - Build routes:
npm run dr:build - Use declarative routing:
<PageUsers.Link />
Database Table Creation
- Create table file:
src/db/tables/my-table.ts - Add cache tag:
src/db/tags.ts - Push schema:
npm run db:push - Create operations with
createDrizzleOperations
Quick Reference
| Practice | ✅ Do | ❌ Don't |
|---|---|---|
| Types | type User = { ... } | interface User { ... } |
| Strings | "double quotes" | 'single quotes' |
| Components | <Icon iconKey="..." /> | import { Icon } from "lucide-react" |
| Typography | <H1>Title</H1> | <h1>Title</h1> |
| Routing | <PageHome.Link /> | <Link href="/home"> |
| Database | createDrizzleOperations | Raw Drizzle queries |
| Styling | className="w-full" | className="w-[100px]" |
| Layout | <Section> | <div className="container"> |
| Validation | Zod schemas | Manual validation |
| Errors | Custom error classes | Generic Error |