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

Design Principles

Core principles guiding the architecture and development practices

Overview

This project is built on a foundation of proven design principles that ensure code quality, maintainability, and scalability. These principles guide every architectural decision and should be followed when extending the application.

1. Type Safety

Everything should be type-safe from database to UI.

Why It Matters

  • Catch errors at compile-time, not runtime
  • Reduce bugs in production
  • Improve IDE support and developer experience
  • Enable confident refactoring
  • Self-document code through types

Implementation

Database Types

// Tables are defined with Drizzle ORM
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

// Types are automatically inferred
type User = typeof users.$inferSelect;
type NewUser = typeof users.$inferInsert;

API Types

// tRPC provides end-to-end type safety
export const userRouter = createTRPCRouter({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return await db.query.users.findFirst({
        where: eq(users.id, input.id),
      });
    }),
});

// Client knows exact return type without code generation
const { data } = api.users.getById.useQuery({ id: "123" });
//    ^? User | undefined

Form Types

// Zod schemas for validation
const userSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().min(18),
});

// Infer TypeScript type from schema
type UserFormData = z.infer<typeof userSchema>;

// Use with React Hook Form
const form = useForm<UserFormData>({
  resolver: zodResolver(userSchema),
});

Best Practices

  • ✅ Use type instead of interface for consistency
  • ✅ Infer types from runtime values when possible
  • ✅ Use Zod for runtime validation + type inference
  • ✅ Enable TypeScript strict mode
  • ❌ Never use any - use unknown and type guards
  • ❌ Don't duplicate types - infer from single source

2. Separation of Concerns

Keep UI, business logic, and data access separate.

Why It Matters

  • Easier to test individual pieces
  • Changes don't cascade across layers
  • Multiple UIs can use the same logic
  • Logic can be reused across features
  • Clearer code organization

Implementation

Feature Structure (5-File Pattern)

src/features/users/
├── schema.ts       # Validation and type definitions
├── functions.ts    # Business logic and database operations
├── hooks.ts        # Client-side React hooks
├── fields.tsx      # Form field components
└── prompts.tsx     # Dialog/modal wrappers

Layer Separation

Data Layer (functions.ts):

// Pure business logic - no React, no UI
export async function createUser(data: CreateUserInput) {
  // Validation
  const validated = createUserSchema.parse(data);
  
  // Business rules
  if (await userOperations.existsByEmail(validated.email)) {
    throw new ValidationError("Email already exists");
  }
  
  // Database operation
  return await userOperations.create(validated);
}

API Layer (tRPC router):

// Connects UI to business logic
export const userRouter = createTRPCRouter({
  create: protectedProcedure
    .input(createUserSchema)
    .mutation(async ({ input }) => {
      return await createUser(input);
    }),
});

UI Layer (React component):

// Only handles presentation and user interaction
export function CreateUserForm() {
  const mutation = api.users.create.useMutation();
  
  const onSubmit = (data: CreateUserInput) => {
    mutation.mutate(data);
  };
  
  return <form onSubmit={handleSubmit(onSubmit)}>...</form>;
}

Best Practices

  • ✅ Keep business logic in functions.ts
  • ✅ Keep React hooks in hooks.ts
  • ✅ Keep UI components presentation-only
  • ✅ Test each layer independently
  • ❌ Don't put business logic in components
  • ❌ Don't access database directly from components

3. Reusability

Build small, composable pieces that can be combined.

Why It Matters

  • Reduce code duplication
  • Faster feature development
  • Consistent UI and behavior
  • Easier to maintain
  • Smaller bundle sizes

Implementation

Reusable Form Fields

// fields.tsx - Reusable field components
export function UserNameField() {
  const { control } = useFormContext<UserFormData>();
  
  return (
    <FormField
      control={control}
      name="name"
      render={({ field }) => (
        <FormItem>
          <FormLabel>Name</FormLabel>
          <FormControl>
            <Input {...field} placeholder="John Doe" />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
  );
}

// Use in multiple forms
export function CreateUserForm() {
  return (
    <Form {...form}>
      <UserNameField />
      {/* Other fields */}
    </Form>
  );
}

export function EditUserForm() {
  return (
    <Form {...form}>
      <UserNameField />
      {/* Same field, different form */}
    </Form>
  );
}

Reusable Database Operations

// Use createDrizzleOperations for CRUD
import { createDrizzleOperations } from "@/db/drizzle-operations";

export const userOperations = createDrizzleOperations({
  table: users,
  primaryKey: "id",
});

// Now available everywhere:
userOperations.getById(id);
userOperations.list({ limit: 10 });
userOperations.create(data);
userOperations.update(id, data);
userOperations.delete(id);

Reusable UI Components

// Shared components in src/components/ui/
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";

// Use consistently across features
<Button variant="default" size="lg">
  Submit
</Button>

<Button variant="destructive" size="sm">
  Delete
</Button>

Best Practices

  • ✅ Create reusable field components
  • ✅ Use createDrizzleOperations for CRUD
  • ✅ Build generic UI components
  • ✅ Extract common patterns
  • ❌ Don't duplicate form fields
  • ❌ Don't write raw CRUD operations repeatedly

4. Performance

Optimize for speed using server-side rendering and caching.

Why It Matters

  • Better user experience
  • Improved SEO rankings
  • Lower hosting costs
  • Higher conversion rates
  • Reduced client bundle size

Implementation

Server Components

// Render on server - no JavaScript sent to client
export default async function UsersPage() {
  const users = await api.users.list.query({ limit: 10 });
  
  return (
    <div>
      {users.map((user) => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

Data Caching

// Cache expensive database queries with unstable_cache
import { unstable_cache } from "next/cache";
import { TableTags } from "@/db/tags";

export const getPopularPosts = unstable_cache(
  async () => {
    const posts = await db.query.posts.findMany({
      where: gt(posts.views, 1000),
      orderBy: desc(posts.views),
      limit: 10,
    });
    
    return posts;
  },
  ["popular-posts"],
  {
    tags: [TableTags.posts],
    revalidate: 3600, // Cache for 1 hour
  }
);

#### Prefetching

```typescript
// Prefetch data in Server Components
export default async function PostPage({ params }: Props) {
  // Start prefetching immediately
  void api.posts.getById.prefetch({ id: params.id });
  void api.comments.list.prefetch({ postId: params.id });
  
  return (
    <div>
      <PostContent id={params.id} />
      <CommentsList postId={params.id} />
    </div>
  );
}

Lazy Loading

// Load components only when needed
const AdminPanel = dynamic(() => import("@/features/admin/components/admin-panel"), {
  loading: () => <Skeleton />,
});

Best Practices

  • ✅ Use Server Components by default
  • ✅ Cache expensive operations with unstable_cache
  • ✅ Prefetch data in Server Components
  • ✅ Lazy load heavy components
  • ✅ Optimize images with Next.js Image
  • ❌ Don't fetch data in Client Components
  • ❌ Don't send large bundles to the client

5. Modularity

Organize code by feature, not by technical layer.

Why It Matters

  • Easier to find related code
  • Teams can own specific features
  • Reduce merge conflicts
  • Enable independent deployment
  • Scale development team

Implementation

Feature-Based Structure

src/features/
├── auth/           # Authentication feature
│   ├── schema.ts
│   ├── functions.ts
│   ├── hooks.ts
│   └── components/
├── organizations/  # Organizations feature
│   ├── schema.ts
│   ├── functions.ts
│   ├── hooks.ts
│   └── components/
└── subscriptions/  # Subscriptions feature
    ├── schema.ts
    ├── functions.ts
    ├── hooks.ts
    └── components/

NOT organized by technical layer:

❌ src/
   ├── components/     # All components mixed together
   ├── services/       # All services mixed together
   └── types/          # All types mixed together

Feature Independence

// Each feature is self-contained
import { createUser } from "@/features/users/functions";
import { sendWelcomeEmail } from "@/features/emails/functions";
import { createOrganization } from "@/features/organizations/functions";

// Features compose together
async function onboardUser(data: OnboardingData) {
  const user = await createUser(data.user);
  const org = await createOrganization(data.organization, user.id);
  await sendWelcomeEmail(user.email, user.name);
  
  return { user, org };
}

Best Practices

  • ✅ Group by feature, not by type
  • ✅ Keep feature code together
  • ✅ Make features independent
  • ✅ Share code through imports, not globals
  • ❌ Don't scatter feature code across folders
  • ❌ Don't create deep folder nesting

6. DRY Principle

Don't Repeat Yourself - Use abstractions to avoid duplication.

Why It Matters

  • Single source of truth
  • Easier to update behavior
  • Reduce bugs from inconsistency
  • Smaller codebase
  • Faster development

Implementation

Database Operations

// ❌ Don't repeat CRUD operations
async function getUser(id: string) {
  return await db.query.users.findFirst({ where: eq(users.id, id) });
}

async function getPost(id: string) {
  return await db.query.posts.findFirst({ where: eq(posts.id, id) });
}

// ✅ Use abstraction
import { createDrizzleOperations } from "@/db/drizzle-operations";

export const userOperations = createDrizzleOperations({ 
  table: users, 
  primaryKey: "id" 
});

export const postOperations = createDrizzleOperations({ 
  table: posts, 
  primaryKey: "id" 
});

// Both have: getById, list, create, update, delete

Form Patterns

// ❌ Don't repeat form setup
const form1 = useForm({ resolver: zodResolver(schema1) });
const form2 = useForm({ resolver: zodResolver(schema2) });

// ✅ Use helper
function useZodForm<T extends z.ZodType>(schema: T) {
  return useForm<z.infer<T>>({
    resolver: zodResolver(schema),
  });
}

const form1 = useZodForm(schema1);
const form2 = useZodForm(schema2);

Component Patterns

// ❌ Don't repeat dialog structure
<Dialog>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Delete User</DialogTitle>
    </DialogHeader>
    {/* Content */}
  </DialogContent>
</Dialog>

// ✅ Use prompt wrapper (prompts.tsx)
export function DeleteUserPrompt({ userId }: Props) {
  return (
    <PromptDialog
      title="Delete User"
      description="Are you sure?"
      trigger={<Button variant="destructive">Delete</Button>}
    >
      <DeleteUserForm userId={userId} />
    </PromptDialog>
  );
}

Best Practices

  • ✅ Extract repeated patterns
  • ✅ Use helper functions
  • ✅ Create wrapper components
  • ✅ Share configurations
  • ❌ Don't copy-paste code
  • ❌ Don't create slightly different versions

Applying These Principles

Code Review Checklist

When reviewing code, ask:

  1. Type Safety: Are all types explicit? No any?
  2. Separation: Is business logic separate from UI?
  3. Reusability: Could this be used elsewhere?
  4. Performance: Is data fetched on the server?
  5. Modularity: Is code in the right feature folder?
  6. DRY: Is there duplicated code?

Refactoring Guide

When refactoring, follow this order:

  1. Add types - Make it type-safe first
  2. Extract logic - Move business logic out of components
  3. Create abstractions - DRY up repeated patterns
  4. Optimize - Add caching and server rendering
  5. Modularize - Move to appropriate feature folder

Testing Strategy

Each principle enables better testing:

  • Type Safety → Compiler catches errors
  • Separation → Unit test each layer
  • Reusability → Test once, use everywhere
  • Performance → Measure with profiler
  • Modularity → Test features in isolation
  • DRY → Change behavior in one place

Related Documentation

  • Features Guide - Creating feature modules
  • Database Guide - Using Drizzle operations
  • tRPC Guide - Type-safe API patterns
  • Architecture Overview - System design

Summary

These six principles work together to create:

  • ✅ Reliable - Types catch errors early
  • ✅ Maintainable - Clear separation of concerns
  • ✅ Efficient - Reusable components and functions
  • ✅ Fast - Server-first with caching
  • ✅ Scalable - Feature-based organization
  • ✅ Clean - No duplication

Follow these principles consistently and your code will be easier to understand, test, and evolve.

On this page

Overview
1. Type Safety
Why It Matters
Implementation
Database Types
API Types
Form Types
Best Practices
2. Separation of Concerns
Why It Matters
Implementation
Feature Structure (5-File Pattern)
Layer Separation
Best Practices
3. Reusability
Why It Matters
Implementation
Reusable Form Fields
Reusable Database Operations
Reusable UI Components
Best Practices
4. Performance
Why It Matters
Implementation
Server Components
Data Caching
Lazy Loading
Best Practices
5. Modularity
Why It Matters
Implementation
Feature-Based Structure
Feature Independence
Best Practices
6. DRY Principle
Why It Matters
Implementation
Database Operations
Form Patterns
Component Patterns
Best Practices
Applying These Principles
Code Review Checklist
Refactoring Guide
Testing Strategy
Related Documentation
Summary