Documentation
Documentation
Introduction

Getting Started

Getting StartedInstallationQuick StartProject Structure

Architecture

Architecture OverviewTech StacktRPC MiddlewareDesign Principles

Patterns

Code Patterns & ConventionsFeature ModulesError HandlingType Safety

Database

DatabaseSchema DefinitionDatabase OperationsMigrationsCaching

API

tRPCProceduresRouterstRPC Proxy Setup
APIsOpenAPIREST Endpoints

Auth & Access

AuthenticationConfigurationOAuth ProvidersRolesSession Management
AuthorizationUser RolesPermissions

Routing & i18n

RoutingDeclarative RoutingNavigation
InternationalizationTranslationsLocale Routing

Components & UI

ComponentsButtonsFormsNavigationDialogs
StylesTailwind CSSThemingTypography

Storage

StorageConfigurationUsageBuckets

Configuration

ConfigurationEnvironment VariablesFeature Flags

Templates

Template GuidesCreate New FeatureCreate New PageCreate Database TableCreate tRPC RouterAdd Translations

Development

DevelopmentCommandsAI AgentsBest Practices

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-check

Always run type checking before committing to catch type errors early.

Lint Before Committing

npm run lint

Ensure code follows linting rules before pushing.

Test Production Builds Locally

npm run build
npm run start

Test 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 wrappers

Page Creation

  1. Create page file: src/app/[locale]/<path>/page.tsx
  2. Create page info: src/app/[locale]/<path>/page.info.ts
  3. Build routes: npm run dr:build
  4. Use declarative routing: <PageUsers.Link />

Database Table Creation

  1. Create table file: src/db/tables/my-table.ts
  2. Add cache tag: src/db/tags.ts
  3. Push schema: npm run db:push
  4. Create operations with createDrizzleOperations

Quick Reference

Practice✅ Do❌ Don't
Typestype 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">
DatabasecreateDrizzleOperationsRaw Drizzle queries
StylingclassName="w-full"className="w-[100px]"
Layout<Section><div className="container">
ValidationZod schemasManual validation
ErrorsCustom error classesGeneric Error

Next Steps

  • Commands - Learn available CLI commands
  • AI Agents - AI coding assistant setup
  • Templates - Step-by-step task guides
  • Patterns - Detailed pattern documentation

On this page

Overview
Type Safety
Use type Instead of interface
Define Zod Schemas for Data
Use as const for Literal Types
Code Style
Use Double Quotes
Import Organization
Server/Client Separation
Mark Client Components Explicitly
Use server-only for Server Code
Database Operations
Use createDrizzleOperations Abstraction
Use Caching Appropriately
Handle Errors Gracefully
tRPC Patterns
Prefetch in Server Components
Use Invalidation, Not revalidatePath
Return ActionResponse
Styling
Use the cn() Helper
Use Design Tokens
Use Component Abstractions
Forms
Use React Hook Form + Zod
Create Reusable Field Components
Configuration & Constants
Use Namespaced Config
Define Constants Centrally
Error Handling
Use Custom Error Classes
Provide Translated Error Messages
Testing
Type Check Before Committing
Lint Before Committing
Test Production Builds Locally
Security
Validate All Input
Never Expose Secrets
Use Server Actions Carefully
Performance
Use Server Components by Default
Prefetch Data
Use Next.js Caching
Common Patterns
Feature Module Structure
Page Creation
Database Table Creation
Quick Reference
Next Steps