docs: add Trellis planning and project specs

This commit is contained in:
2026-07-01 06:27:55 -07:00
parent 7f227c5f2a
commit aafd9caaac
349 changed files with 55801 additions and 0 deletions

77
.trellis/spec/README.md Normal file
View File

@@ -0,0 +1,77 @@
# Next.js Full-Stack Development Guidelines
Universal development guidelines for production Next.js applications with oRPC API layer and PostgreSQL.
## Structure
### [Frontend](./frontend/index.md)
React 19 + Next.js 15 App Router frontend development patterns:
- [Directory Structure](./frontend/directory-structure.md)
- [Components](./frontend/components.md)
- [State Management](./frontend/state-management.md)
- [Hooks](./frontend/hooks.md)
- [API Integration](./frontend/api-integration.md)
- [oRPC Usage](./frontend/orpc-usage.md)
- [Authentication](./frontend/authentication.md)
- [AI SDK Integration](./frontend/ai-sdk-integration.md)
- [CSS & Layout](./frontend/css-layout.md)
- [Type Safety](./frontend/type-safety.md)
- [Quality Checklist](./frontend/quality.md)
### [Backend](./backend/index.md)
oRPC + Drizzle ORM backend development patterns:
- [Directory Structure](./backend/directory-structure.md)
- [oRPC Usage](./backend/orpc-usage.md)
- [Authentication](./backend/authentication.md)
- [Database](./backend/database.md)
- [AI SDK Integration](./backend/ai-sdk-integration.md)
- [Logging](./backend/logging.md)
- [Performance](./backend/performance.md)
- [Type Safety](./backend/type-safety.md)
- [Quality Checklist](./backend/quality.md)
### [Shared](./shared/index.md)
Cross-cutting concerns:
- [Dependencies](./shared/dependencies.md)
- [Code Quality](./shared/code-quality.md)
- [TypeScript Conventions](./shared/typescript.md)
### [Guides](./guides/index.md)
Development thinking guides:
- [Pre-Implementation Checklist](./guides/pre-implementation-checklist.md)
- [Cross-Layer Thinking Guide](./guides/cross-layer-thinking-guide.md)
### [Common Issues / Pitfalls](./big-question/index.md)
Common issues and solutions:
- [PostgreSQL JSON vs JSONB](./big-question/postgres-json-jsonb.md)
- [WebKit Tap Highlight](./big-question/webkit-tap-highlight.md)
- [Sentry & next-intl Conflict](./big-question/sentry-nextintl-conflict.md)
- [Turbopack vs Webpack Flexbox](./big-question/turbopack-webpack-flexbox.md)
## Tech Stack
- **Frontend**: Next.js 15, React 19, TailwindCSS 4, Radix UI, React Query
- **Backend**: oRPC, Drizzle ORM, PostgreSQL, better-auth
- **AI**: Vercel AI SDK (multi-provider)
- **Real-time**: Ably / WebSocket / SSE
- **Build**: Turborepo + pnpm (monorepo)
- **Monitoring**: Sentry + OpenTelemetry
## Usage
These guidelines can be used as:
1. **New Project Template** - Copy the entire structure for new Next.js projects
2. **Reference Documentation** - Consult specific guides when implementing features
3. **Code Review Checklist** - Verify implementations against established patterns
4. **Onboarding Material** - Help new developers understand project conventions

View File

@@ -0,0 +1,351 @@
# AI SDK Backend Integration Guidelines
## 1. Overview
This document covers backend integration patterns using the Vercel AI SDK (`ai` package) for AI-powered features.
### Supported Providers
- **OpenAI**: GPT-4o, GPT-4o-mini, GPT-4-turbo
- **Google Gemini**: gemini-1.5-pro, gemini-1.5-flash
- **Anthropic**: Claude 3.5 Sonnet, Claude 3 Opus
### Package Dependencies
```bash
pnpm add ai @ai-sdk/openai @ai-sdk/google @ai-sdk/anthropic
```
## 2. Basic Usage
### generateText
Use `generateText` for simple text generation tasks where you need a complete response.
```typescript
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
const { text } = await generateText({
model: openai("gpt-4o-mini"),
prompt: "Summarize this document...",
});
```
### generateObject (Structured Output with Zod)
Use `generateObject` when you need type-safe structured output. The AI SDK validates the response against your Zod schema automatically.
```typescript
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const classificationSchema = z.object({
category: z.enum(["urgent", "normal", "low"]),
confidence: z.number().min(0).max(1),
reasoning: z.string(),
});
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: classificationSchema,
prompt: "Classify the priority of this task...",
});
// object is typed as { category: "urgent" | "normal" | "low", confidence: number, reasoning: string }
```
### streamText (For SSE/Streaming)
Use `streamText` for real-time streaming responses, ideal for chat interfaces and long-form content generation.
```typescript
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
const result = streamText({
model: openai("gpt-4o"),
messages: conversationHistory,
system: "You are a helpful assistant.",
});
// Return as SSE stream
return result.toDataStreamResponse();
```
## 3. Telemetry Configuration
**IMPORTANT**: Always enable telemetry for token tracking and performance monitoring.
```typescript
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: mySchema,
prompt,
experimental_telemetry: {
isEnabled: true,
functionId: "orders.classify", // Module.function naming
metadata: {
orderId,
userId,
},
},
});
```
### Telemetry Naming Convention
Use dot-separated format for `functionId`: `module.function`
| Module | Example functionId |
|--------|-------------------|
| Orders | `orders.classify`, `orders.summarize` |
| Support | `support.generateReply`, `support.categorize` |
| Content | `content.summarize`, `content.translate` |
| Users | `users.analyzePreferences` |
### Auto-recorded Metrics
When telemetry is enabled, these metrics are automatically tracked:
| Metric | Description |
|--------|-------------|
| `ai.model.id` | Model identifier (e.g., gpt-4o-mini) |
| `ai.model.provider` | Provider name (e.g., openai) |
| `ai.usage.prompt_tokens` | Input tokens consumed |
| `ai.usage.completion_tokens` | Output tokens generated |
| `ai.usage.total_tokens` | Total tokens used |
| `ai.response.finish_reason` | Completion reason (stop, length, etc.) |
## 4. Tool Calling
Define tools that the AI model can invoke to perform actions in your system.
```typescript
import { generateText, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const result = await generateText({
model: openai("gpt-4o"),
prompt: "Create a task for the user...",
tools: {
createTask: tool({
description: "Create a new task in the system",
parameters: z.object({
title: z.string(),
dueDate: z.string().optional(),
priority: z.enum(["high", "medium", "low"]),
}),
execute: async ({ title, dueDate, priority }) => {
const task = await db.insert(tasks).values({
title,
dueDate: dueDate ? new Date(dueDate) : null,
priority,
}).returning();
return { success: true, taskId: task[0].id };
},
}),
searchOrders: tool({
description: "Search for orders by criteria",
parameters: z.object({
query: z.string(),
status: z.enum(["pending", "completed", "cancelled"]).optional(),
limit: z.number().default(10),
}),
execute: async ({ query, status, limit }) => {
const orders = await db.query.orders.findMany({
where: and(
like(orders.title, `%${query}%`),
status ? eq(orders.status, status) : undefined
),
limit,
});
return { orders };
},
}),
},
});
// Access tool results
if (result.toolCalls) {
for (const toolCall of result.toolCalls) {
console.log(`Tool: ${toolCall.toolName}`, toolCall.result);
}
}
```
## 5. Error Handling
Always implement graceful error handling for AI operations.
```typescript
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { logger } from "@your-app/logs";
async function classifyOrder(orderData: OrderData) {
try {
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: classificationSchema,
prompt: buildClassificationPrompt(orderData),
experimental_telemetry: {
isEnabled: true,
functionId: "orders.classify",
},
});
return { success: true, data: object };
} catch (error) {
logger.error("AI generation failed", {
error,
orderId: orderData.id,
prompt: buildClassificationPrompt(orderData).slice(0, 100)
});
// Return graceful fallback
return {
success: false,
reason: "AI processing failed",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
```
### Common Error Types
| Error | Cause | Resolution |
|-------|-------|------------|
| Rate limit exceeded | Too many requests | Implement exponential backoff |
| Context length exceeded | Prompt too long | Truncate or summarize input |
| Invalid API key | Missing/wrong credentials | Check environment variables |
| Schema validation failed | AI output doesn't match schema | Adjust schema or prompt |
## 6. Prompt Engineering Best Practices
### Use XML Structure for Complex Prompts
XML tags help the AI model better understand the structure of your request.
```typescript
const prompt = `
<context>
${contextData}
</context>
<task>
Analyze the above context and extract key information.
</task>
<output_format>
Return a JSON object with the following fields:
- summary: A brief summary
- keyPoints: Array of key points
- sentiment: positive, negative, or neutral
</output_format>
`;
```
### System Prompts
Define consistent behavior with system prompts.
```typescript
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
const result = await generateText({
model: openai("gpt-4o"),
system: `You are a professional assistant.
Always respond in a structured format.
Be concise and accurate.
Never make up information - if unsure, say so.`,
messages: userMessages,
});
```
### Multi-step Prompts
For complex tasks, break down into multiple AI calls.
```typescript
// Step 1: Extract entities
const { object: entities } = await generateObject({
model: openai("gpt-4o-mini"),
schema: entitiesSchema,
prompt: `Extract entities from: ${document}`,
});
// Step 2: Classify based on entities
const { object: classification } = await generateObject({
model: openai("gpt-4o-mini"),
schema: classificationSchema,
prompt: `
<entities>
${JSON.stringify(entities, null, 2)}
</entities>
<task>
Based on these entities, classify the document category.
</task>
`,
});
```
## 7. Provider-Specific Configuration
### OpenAI
```typescript
import { openai } from "@ai-sdk/openai";
const model = openai("gpt-4o-mini", {
// Optional: custom configuration
});
```
### Google Gemini
```typescript
import { google } from "@ai-sdk/google";
const model = google("gemini-1.5-flash");
```
### Anthropic
```typescript
import { anthropic } from "@ai-sdk/anthropic";
const model = anthropic("claude-3-5-sonnet-20241022");
```
## 8. Best Practices Summary
| Rule | Description |
|------|-------------|
| Always enable telemetry | Track token usage and performance for cost monitoring |
| Use generateObject for structured output | Leverage Zod schemas for type safety and validation |
| Use XML prompts for complex tasks | Better structure improves AI understanding |
| Handle errors gracefully | Return fallback responses, never crash |
| Log AI failures | Include context (truncated prompt, IDs) for debugging |
| Use appropriate model sizes | Use mini models for simple tasks, larger for complex |
| Implement rate limiting | Protect against API quota exhaustion |
| Cache responses when appropriate | Reduce costs for repeated queries |
## 9. Environment Variables
Required environment variables for AI providers:
```bash
# OpenAI
OPENAI_API_KEY=sk-...
# Google Gemini
GOOGLE_GENERATIVE_AI_API_KEY=...
# Anthropic
ANTHROPIC_API_KEY=sk-ant-...
```

View File

@@ -0,0 +1,723 @@
# Authentication Guidelines
This document covers backend authentication integration using better-auth, including session management, protected procedures, and OAuth configuration.
## 1. Overview
### What is better-auth
better-auth is a modern authentication library for TypeScript applications that provides:
- Session-based authentication with secure cookie management
- Multiple authentication methods (email/password, OAuth, magic links, passkeys)
- Built-in support for organizations and multi-tenancy
- Database adapter integration (Drizzle ORM)
- Two-factor authentication (2FA)
- Admin functionality
### Session-based Authentication
The authentication system uses secure, HTTP-only cookies to manage user sessions:
- Sessions are stored in the database and cached in Redis for performance
- Session tokens are automatically validated on each request
- Cookie names are prefixed with `__Secure-` in production (HTTPS)
### Supported Providers
| Provider | Type | Description |
|----------|------|-------------|
| Email/Password | Credential | Traditional email and password authentication |
| Google | OAuth | Social login with Google account |
| GitHub | OAuth | Social login with GitHub account |
| Magic Link | Passwordless | Email-based one-time login links |
| Passkey | Passwordless | WebAuthn/FIDO2 biometric authentication |
## 2. Auth Configuration
### Server-side Auth Setup
The auth configuration is defined in the auth package:
```typescript
// packages/auth/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@your-app/database";
import {
admin,
magicLink,
organization,
passkey,
twoFactor,
username,
} from "better-auth/plugins";
export const auth = betterAuth({
baseURL: process.env.APP_URL,
appName: "Your App Name",
// Database adapter
database: drizzleAdapter(db, {
provider: "pg",
}),
// Session configuration
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days in seconds
freshAge: 0,
},
// Account linking for OAuth providers
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "github"],
},
},
// Plugins
plugins: [
username(),
admin(),
passkey(),
magicLink({
sendMagicLink: async ({ email, url }, request) => {
// Send magic link email
await sendEmail({
to: email,
templateId: "magicLink",
context: { url },
});
},
}),
organization({
sendInvitationEmail: async ({ email, id, organization }, request) => {
// Send organization invitation email
},
}),
twoFactor(),
],
});
// Export session type
export type Session = typeof auth.$Infer.Session;
```
### Database Adapter (Drizzle)
better-auth uses Drizzle ORM for database operations. The required tables are automatically created:
- `user` - User accounts
- `session` - Authentication sessions
- `account` - OAuth provider accounts (Google, GitHub, etc.)
- `verification` - Email verification tokens
### Session Configuration
```typescript
session: {
// Session lifetime (default: 7 days)
expiresIn: 60 * 60 * 24 * 7,
// Fresh session age for sensitive operations (0 = always require re-auth)
freshAge: 0,
}
```
## 3. Protected Procedures
### Procedure Types
The API layer provides three procedure types with different authentication levels:
```typescript
// packages/api/orpc/procedures.ts
import { ORPCError, os } from "@orpc/server";
// Public procedure - no authentication required
export const publicProcedure = os
.$context<{ headers: Headers }>()
.use(logIdMiddleware);
// Protected procedure - requires authenticated user
export const protectedProcedure = publicProcedure.use(
async ({ context, next }) => {
const { session } = await getSessionWithCache(context.headers);
if (!session) {
throw new ORPCError("UNAUTHORIZED");
}
return await next({
context: {
session: session.session,
user: session.user,
},
});
},
);
// Admin procedure - requires admin role
export const adminProcedure = protectedProcedure.use(
async ({ context, next }) => {
if (context.user.role !== "admin") {
throw new ORPCError("FORBIDDEN");
}
return await next();
},
);
```
### Using Protected Procedures
**Basic protected endpoint:**
```typescript
// procedures/get-profile.ts
import { protectedProcedure } from "../../../orpc/procedures";
export const getProfile = protectedProcedure
.route({
method: "GET",
path: "/users/profile",
tags: ["Users"],
summary: "Get current user profile",
})
.handler(async ({ context }) => {
// Access authenticated user from context
const { user, session } = context;
return {
success: true,
reason: "Profile retrieved",
user: {
id: user.id,
email: user.email,
name: user.name,
},
};
});
```
**Admin-only endpoint:**
```typescript
// procedures/list-users.ts
import { adminProcedure } from "../../../orpc/procedures";
import { z } from "zod";
export const listUsers = adminProcedure
.route({
method: "GET",
path: "/admin/users",
tags: ["Administration"],
summary: "List all users",
})
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
offset: z.number().min(0).default(0),
}),
)
.handler(async ({ input: { limit, offset } }) => {
const users = await getUsers({ limit, offset });
return { users };
});
```
### Accessing User Session in Context
The protected procedure middleware injects session data into the context:
```typescript
interface ProtectedContext {
session: {
id: string;
userId: string;
expiresAt: Date;
// ... other session fields
};
user: {
id: string;
email: string;
name: string;
role: "user" | "admin";
// ... other user fields
};
}
```
**Accessing context in handlers:**
```typescript
.handler(async ({ context, input }) => {
const { user, session } = context;
// Use user.id for database queries
const userOrders = await getOrdersByUserId(user.id);
// Check user role
if (user.role === "admin") {
// Admin-specific logic
}
return { success: true, reason: "Success", orders: userOrders };
});
```
### Role-based Access Control
**Custom role middleware:**
```typescript
// Create a middleware for specific roles
const organizationAdminProcedure = protectedProcedure.use(
async ({ context, input, next }) => {
const { organizationId } = input as { organizationId: string };
const membership = await getOrganizationMembership(
organizationId,
context.user.id
);
if (!membership || membership.role !== "owner") {
throw new ORPCError("FORBIDDEN", {
message: "Organization admin access required",
});
}
return await next({
context: {
...context,
organization: membership.organization,
},
});
},
);
```
**Verifying organization membership:**
```typescript
// lib/membership.ts
import { getOrganizationMembership } from "@your-app/database";
export async function verifyOrganizationMembership(
organizationId: string,
userId: string,
) {
const membership = await getOrganizationMembership(organizationId, userId);
if (!membership) {
return null;
}
return {
organization: membership.organization,
role: membership.role,
};
}
```
## 4. Session Management
### Session Caching
Sessions are cached in Redis to reduce database load:
```typescript
// lib/session-cache.ts
import { auth } from "@your-app/auth";
import { redis } from "./redis";
const SESSION_CACHE_PREFIX = "session";
const SESSION_TTL = 60 * 60 * 24 * 7; // 7 days
export async function getSessionWithCache(
headers: Headers,
): Promise<{ session: Session | null; fromCache: boolean }> {
const sessionToken = getSessionTokenFromHeaders(headers);
if (!sessionToken) {
const fresh = await fetchSession(headers);
return { session: fresh, fromCache: false };
}
// Try cache first
const cached = await redis.get(`${SESSION_CACHE_PREFIX}:${sessionToken}`);
if (cached) {
return { session: JSON.parse(cached), fromCache: true };
}
// Fetch from database
const fresh = await auth.api.getSession({ headers });
if (fresh) {
// Cache the session
await redis.set(
`${SESSION_CACHE_PREFIX}:${sessionToken}`,
JSON.stringify(fresh),
{ ex: SESSION_TTL }
);
}
return { session: fresh, fromCache: false };
}
```
### Getting Session Token from Headers
```typescript
export function getSessionTokenFromHeaders(headers: Headers): string | null {
// Check Authorization header first
const authHeader = headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) {
return authHeader.slice("Bearer ".length);
}
// Fall back to cookie
const cookieHeader = headers.get("cookie");
if (!cookieHeader) {
return null;
}
const cookies = parseCookie(cookieHeader);
const cookieName = process.env.NODE_ENV === "production"
? "__Secure-better-auth.session_token"
: "better-auth.session_token";
return cookies[cookieName] ?? null;
}
```
### Session Invalidation
```typescript
// Delete session cache on logout or session change
export async function deleteSessionCache(sessionToken: string): Promise<void> {
await redis.del(`${SESSION_CACHE_PREFIX}:${sessionToken}`);
}
```
## 5. OAuth Integration
### Google OAuth Setup
**Configuration:**
```typescript
// auth.ts
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
scope: [
"email",
"profile",
"openid",
// Add additional scopes as needed
// "https://www.googleapis.com/auth/calendar",
],
// Get refresh token for offline access
accessType: "offline",
prompt: "consent",
},
},
```
**Environment variables:**
```bash
# .env
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
```
### GitHub OAuth Setup
```typescript
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
scope: ["user:email"],
},
},
```
### Accessing OAuth Tokens
To access stored OAuth tokens for API calls:
```typescript
import { db } from "@your-app/database";
import { account } from "@your-app/database/drizzle/schema";
import { eq, and } from "drizzle-orm";
export async function getOAuthToken(userId: string, provider: string) {
const accountRecord = await db.query.account.findFirst({
where: and(
eq(account.userId, userId),
eq(account.providerId, provider)
),
});
if (!accountRecord) {
return null;
}
return {
accessToken: accountRecord.accessToken,
refreshToken: accountRecord.refreshToken,
expiresAt: accountRecord.accessTokenExpiresAt,
};
}
```
### Token Refresh
better-auth handles token refresh automatically. For manual refresh:
```typescript
import { auth } from "@your-app/auth";
export async function refreshOAuthToken(userId: string, provider: string) {
// Use auth API to refresh token
const result = await auth.api.refreshAccessToken({
userId,
providerId: provider,
});
return result;
}
```
## 6. Error Handling
### Standard Auth Errors
Use oRPC error codes for authentication failures:
```typescript
import { ORPCError } from "@orpc/server";
// User not authenticated
throw new ORPCError("UNAUTHORIZED");
// User authenticated but lacks permission
throw new ORPCError("FORBIDDEN", {
message: "Admin access required",
});
// Session expired
throw new ORPCError("UNAUTHORIZED", {
message: "Session expired, please login again",
});
```
### Error Response Pattern
```typescript
// Consistent error response structure
export const authErrorSchema = z.object({
success: z.literal(false),
reason: z.string(),
code: z.enum(["UNAUTHORIZED", "FORBIDDEN", "SESSION_EXPIRED"]).optional(),
});
// In handler
if (!hasPermission) {
return {
success: false,
reason: "You do not have permission to perform this action",
code: "FORBIDDEN",
};
}
```
### Handling Session Expiration
```typescript
// Graceful session expiration handling
export async function handleSessionExpiration(sessionToken: string) {
// Clear cache
await deleteSessionCache(sessionToken);
// Log the event
logger.info("Session expired", { sessionToken: sessionToken.slice(0, 10) });
throw new ORPCError("UNAUTHORIZED", {
message: "Your session has expired. Please login again.",
});
}
```
## 7. Best Practices
### Always Validate Session in Protected Routes
```typescript
// GOOD - Use protectedProcedure for authenticated endpoints
export const updateProfile = protectedProcedure
.route({ method: "PATCH", path: "/users/profile" })
.handler(async ({ context }) => {
// context.user is guaranteed to exist
});
// BAD - Manual session check in public procedure
export const updateProfile = publicProcedure
.handler(async ({ context }) => {
const session = await getSession(context.headers);
if (!session) throw new ORPCError("UNAUTHORIZED");
// Error-prone and inconsistent
});
```
### Use Middleware for Reusable Auth Checks
```typescript
// Create reusable middleware for common patterns
const withOrganization = async ({ context, input, next }) => {
const { organizationId } = input;
const membership = await verifyOrganizationMembership(
organizationId,
context.user.id
);
if (!membership) {
throw new ORPCError("FORBIDDEN", {
message: "Not a member of this organization",
});
}
return next({
context: { ...context, organization: membership.organization },
});
};
// Use in procedures
export const getOrganizationData = protectedProcedure
.use(withOrganization)
.handler(async ({ context }) => {
// context.organization is now available
});
```
### Proper Error Responses
```typescript
// Always return meaningful error messages
.handler(async ({ context, input }) => {
try {
const result = await performAction(input);
return { success: true, reason: "Action completed", data: result };
} catch (error) {
if (error instanceof ORPCError) {
throw error; // Re-throw oRPC errors
}
logger.error("Action failed", { error, userId: context.user.id });
return {
success: false,
reason: "An unexpected error occurred",
};
}
});
```
### Secure Session Token Handling
```typescript
// Never log full session tokens
logger.info("Session validated", {
sessionToken: `${token.substring(0, 10)}...`,
userId: session.user.id,
});
// Clear sensitive data from responses
const sanitizedUser = {
id: user.id,
email: user.email,
name: user.name,
// Don't include: passwordHash, sessionTokens, etc.
};
```
### Cache Invalidation on Auth Events
```typescript
// In auth hooks
hooks: {
after: createAuthMiddleware(async (ctx) => {
if (ctx.path.startsWith("/sign-out")) {
const sessionToken = getSessionTokenFromHeaders(ctx.headers);
if (sessionToken) {
await deleteSessionCache(sessionToken);
}
}
}),
}
```
## Client-side Auth Usage
For client-side authentication, use the auth client:
```typescript
// packages/auth/client.ts
import { createAuthClient } from "better-auth/react";
import {
adminClient,
magicLinkClient,
organizationClient,
passkeyClient,
twoFactorClient,
} from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [
magicLinkClient(),
organizationClient(),
adminClient(),
passkeyClient(),
twoFactorClient(),
],
});
```
**Usage in React components:**
```typescript
import { authClient } from "@your-app/auth/client";
// Sign in
await authClient.signIn.email({
email: "user@example.com",
password: "password",
});
// Sign out
await authClient.signOut();
// Get current session
const session = await authClient.getSession();
// Use hooks
const { data: session, isPending } = authClient.useSession();
```
## Quick Reference
| Task | Solution |
|------|----------|
| Require authentication | Use `protectedProcedure` |
| Require admin role | Use `adminProcedure` |
| Get current user | Access `context.user` in handler |
| Get session data | Access `context.session` in handler |
| Check organization membership | Use `verifyOrganizationMembership` helper |
| Throw auth error | `throw new ORPCError("UNAUTHORIZED")` |
| Throw permission error | `throw new ORPCError("FORBIDDEN")` |

View File

@@ -0,0 +1,419 @@
# Database Operations
This document covers database best practices using Drizzle ORM with PostgreSQL.
## Critical Rules
### 1. NO `await` in Loops (N+1 Problem)
Never use `await` inside a loop. This creates the N+1 query problem, causing severe performance degradation.
```typescript
// BAD - N+1 queries (1 query per iteration)
const orders = await db.select().from(orderTable).where(eq(orderTable.userId, userId));
for (const order of orders) {
const items = await db.select().from(orderItemTable).where(eq(orderItemTable.orderId, order.id));
order.items = items;
}
// GOOD - 2 queries total using inArray
const orders = await db.select().from(orderTable).where(eq(orderTable.userId, userId));
const orderIds = orders.map(o => o.id);
// Single query for all items
const allItems = await db
.select()
.from(orderItemTable)
.where(inArray(orderItemTable.orderId, orderIds));
// Group items by orderId in memory
const itemsByOrder = new Map<string, typeof allItems>();
for (const item of allItems) {
const existing = itemsByOrder.get(item.orderId) || [];
existing.push(item);
itemsByOrder.set(item.orderId, existing);
}
// Attach to orders
const ordersWithItems = orders.map(order => ({
...order,
items: itemsByOrder.get(order.id) || [],
}));
```
### 2. Batch Insert Pattern
Use batch inserts for multiple records instead of individual inserts.
```typescript
// BAD - Multiple insert statements
for (const item of items) {
await db.insert(orderItemTable).values(item);
}
// GOOD - Single batch insert
await db.insert(orderItemTable).values(items);
// With returning clause
const insertedItems = await db
.insert(orderItemTable)
.values(items)
.returning();
```
### 3. Conflict Handling with `onConflictDoUpdate`
Handle upserts efficiently with conflict resolution.
```typescript
// Upsert single record
await db
.insert(userSettingsTable)
.values({
userId,
theme: "dark",
notifications: true,
})
.onConflictDoUpdate({
target: userSettingsTable.userId,
set: {
theme: sql`excluded.theme`,
notifications: sql`excluded.notifications`,
updatedAt: sql`NOW()`,
},
});
// Batch upsert with composite key
const upsertedRecords = await db
.insert(inventoryTable)
.values(inventoryData)
.onConflictDoUpdate({
target: [inventoryTable.warehouseId, inventoryTable.productId],
set: {
quantity: sql`excluded.quantity`,
updatedAt: sql`NOW()`,
},
})
.returning({
id: inventoryTable.id,
productId: inventoryTable.productId,
});
```
## Query Organization
Database queries should be organized in `packages/database/drizzle/queries/`.
```
packages/database/drizzle/queries/
├── index.ts # Re-exports all query modules
├── types.ts # Shared query types
├── users.ts # User-related queries
├── orders.ts # Order-related queries
└── products.ts # Product-related queries
```
**Example: `queries/orders.ts`**
```typescript
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "../client";
import { order as orderTable, orderItem as orderItemTable } from "../schema/postgres";
/**
* Bulk upsert orders with conflict handling
*/
export async function bulkUpsertOrders(
ordersData: Array<{
externalId: string;
customerId: string;
status: string;
total: number;
}>,
) {
if (ordersData.length === 0) {
return [];
}
const upserted = await db
.insert(orderTable)
.values(ordersData)
.onConflictDoUpdate({
target: [orderTable.externalId],
set: {
status: sql`excluded.status`,
total: sql`excluded.total`,
updatedAt: sql`NOW()`,
},
})
.returning({
id: orderTable.id,
externalId: orderTable.externalId,
});
return upserted;
}
/**
* Get orders with items for a user
*/
export async function getOrdersWithItems(params: {
userId: string;
limit?: number;
}) {
const { userId, limit = 20 } = params;
const orders = await db
.select()
.from(orderTable)
.where(eq(orderTable.userId, userId))
.orderBy(desc(orderTable.createdAt))
.limit(limit);
if (orders.length === 0) {
return [];
}
const orderIds = orders.map(o => o.id);
const items = await db
.select()
.from(orderItemTable)
.where(inArray(orderItemTable.orderId, orderIds));
const itemsByOrder = groupBy(items, "orderId");
return orders.map(order => ({
...order,
items: itemsByOrder.get(order.id) || [],
}));
}
```
## Advanced SQL Patterns
### JSON Column Operations
When using PostgreSQL JSON/JSONB columns, proper casting is required for JSON functions.
```typescript
// BAD - Missing cast for jsonb functions
const result = await db
.select()
.from(productTable)
.where(sql`${productTable.metadata}->>'category' = 'electronics'`);
// GOOD - Explicit cast for jsonb operations
const result = await db
.select()
.from(productTable)
.where(sql`${productTable.metadata}::jsonb->>'category' = 'electronics'`);
// JSON array contains check
const withTag = await db
.select()
.from(productTable)
.where(sql`${productTable.tags}::jsonb ? 'featured'`);
// JSON array length
const withMultipleTags = await db
.select()
.from(productTable)
.where(sql`jsonb_array_length(${productTable.tags}::jsonb) > 3`);
```
### Raw SQL Column Names (camelCase)
When using raw SQL with Drizzle, column names must use double quotes for camelCase names.
```typescript
// BAD - PostgreSQL will lowercase unquoted identifiers
await db.execute(sql`
UPDATE order
SET lastUpdatedAt = NOW()
WHERE userId = ${userId}
`);
// GOOD - Double quotes preserve camelCase
await db.execute(sql`
UPDATE "order"
SET "lastUpdatedAt" = NOW()
WHERE "userId" = ${userId}
`);
// Complex raw SQL example
await db.execute(sql`
UPDATE "order" AS o
SET
"totalAmount" = sub."calculatedTotal",
"updatedAt" = NOW()
FROM (
SELECT
"orderId",
SUM("price" * "quantity") AS "calculatedTotal"
FROM "orderItem"
WHERE "orderId" = ANY(${sql.raw(arrayLiteral)})
GROUP BY "orderId"
) AS sub
WHERE o.id = sub."orderId"
`);
```
### Enum Comparison
When comparing enum columns in raw SQL, cast the column to text.
```typescript
// BAD - Direct enum comparison may fail
await db.execute(sql`
SELECT * FROM "order"
WHERE status != 'DRAFT'
`);
// GOOD - Cast enum column to text
await db.execute(sql`
SELECT * FROM "order"
WHERE status::text != 'DRAFT'
`);
// In Drizzle query builder (works correctly)
const orders = await db
.select()
.from(orderTable)
.where(ne(orderTable.status, "DRAFT"));
```
### Aggregation with Filtering
Use FILTER clause for conditional aggregation.
```typescript
await db.execute(sql`
UPDATE "category" AS c
SET
"productCount" = sub."count",
"activeProductCount" = sub."activeCount",
"updatedAt" = NOW()
FROM (
SELECT
"categoryId",
COUNT(*)::int AS "count",
COUNT(*) FILTER (WHERE "status" = 'ACTIVE')::int AS "activeCount"
FROM "product"
WHERE "categoryId" = ANY(${sql.raw(categoryIds)})
GROUP BY "categoryId"
) AS sub
WHERE c.id = sub."categoryId"
`);
```
## Transaction Patterns
### Basic Transaction
```typescript
import { db } from "@your-app/database";
const result = await db.transaction(async (tx) => {
// All operations use tx instead of db
const [order] = await tx
.insert(orderTable)
.values({ userId, total: 0 })
.returning();
await tx.insert(orderItemTable).values(
items.map(item => ({
orderId: order.id,
...item,
}))
);
// Update order total
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
await tx
.update(orderTable)
.set({ total })
.where(eq(orderTable.id, order.id));
return order;
});
```
### Transaction with Rollback
```typescript
try {
await db.transaction(async (tx) => {
await tx.insert(orderTable).values(orderData);
// This will cause rollback if payment fails
const paymentResult = await processPayment(orderData.total);
if (!paymentResult.success) {
throw new Error("Payment failed");
}
await tx.update(orderTable)
.set({ paymentId: paymentResult.id })
.where(eq(orderTable.id, orderData.id));
});
} catch (error) {
// Transaction automatically rolled back
logger.error("Order creation failed", { error });
}
```
## Query Performance Tips
### Use Indexes
Ensure your queries use appropriate indexes:
```typescript
// Good for index on (userId, createdAt DESC)
const recentOrders = await db
.select()
.from(orderTable)
.where(eq(orderTable.userId, userId))
.orderBy(desc(orderTable.createdAt))
.limit(10);
```
### Select Only Needed Columns
```typescript
// BAD - Selects all columns including large text fields
const orders = await db.select().from(orderTable);
// GOOD - Select only needed columns
const orders = await db
.select({
id: orderTable.id,
status: orderTable.status,
total: orderTable.total,
})
.from(orderTable);
```
### Use Relations for Complex Queries
```typescript
// Using Drizzle relations for nested data
const ordersWithDetails = await db.query.order.findMany({
where: eq(orderTable.userId, userId),
with: {
items: {
with: {
product: true,
},
},
customer: {
columns: {
id: true,
name: true,
email: true,
},
},
},
orderBy: (orders, { desc }) => desc(orders.createdAt),
limit: 20,
});
```

View File

@@ -0,0 +1,252 @@
# Directory Structure
This document describes the module organization pattern for backend API development.
## Module Structure
Each API module follows a consistent directory structure:
```
packages/api/modules/[module]/
├── types.ts # Zod schemas and TypeScript types
├── router.ts # Hono router with route definitions
├── lib/ # Core business logic (shared across procedures)
│ ├── client.ts # External service clients
│ └── helpers.ts # Helper functions
├── procedures/ # HTTP endpoint handlers
│ ├── create.ts
│ ├── update.ts
│ ├── delete.ts
│ └── list.ts
└── api/ # API documentation (optional)
├── create.md
└── list.md
```
## File Responsibilities
### `types.ts` - Schemas and Types
Define all Zod schemas and TypeScript types for the module.
```typescript
// types.ts
import { z } from "zod";
// Input Schemas
export const createOrderInputSchema = z.object({
customerId: z.string(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().min(1),
})).min(1),
});
// Output Schemas
export const orderResponseSchema = z.object({
success: z.boolean(),
reason: z.string(),
order: z.object({
id: z.string(),
status: z.string(),
total: z.number(),
}).optional(),
});
// Type exports
export type CreateOrderInput = z.infer<typeof createOrderInputSchema>;
export type OrderResponse = z.infer<typeof orderResponseSchema>;
```
### `router.ts` - Route Definitions
The router aggregates all procedures and defines the API routes.
```typescript
// router.ts
import { Hono } from "hono";
import { createOrder } from "./procedures/create";
import { listOrders } from "./procedures/list";
import { updateOrderStatus } from "./procedures/update";
export const ordersRouter = new Hono()
.basePath("/orders")
.post("/", createOrder)
.get("/", listOrders)
.patch("/:id/status", updateOrderStatus);
```
### `lib/` - Business Logic
Contains reusable business logic shared across procedures.
```
lib/
├── client.ts # External API clients (payment gateway, etc.)
├── helpers.ts # Pure helper functions
├── validators.ts # Business rule validators
└── transformers.ts # Data transformation utilities
```
**Example: `lib/helpers.ts`**
```typescript
// lib/helpers.ts
import type { Order } from "../types";
/**
* Calculate order total with tax
*/
export function calculateOrderTotal(
items: Array<{ price: number; quantity: number }>,
taxRate: number = 0.1,
): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return subtotal * (1 + taxRate);
}
/**
* Generate order reference number
*/
export function generateOrderReference(timestamp: Date): string {
const year = timestamp.getFullYear();
const month = String(timestamp.getMonth() + 1).padStart(2, "0");
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
return `ORD-${year}${month}-${random}`;
}
```
### `procedures/` - Endpoint Handlers
Each procedure handles a single API endpoint with clear responsibilities.
```typescript
// procedures/create.ts
import { db } from "@your-app/database";
import { order as orderTable } from "@your-app/database/drizzle/schema/postgres";
import { logger } from "@your-app/logs";
import { protectedProcedure } from "../../../orpc/procedures";
import { calculateOrderTotal, generateOrderReference } from "../lib/helpers";
import { createOrderInputSchema, orderResponseSchema } from "../types";
export const createOrder = protectedProcedure
.route({
method: "POST",
path: "/orders",
tags: ["Orders"],
summary: "Create a new order",
})
.input(createOrderInputSchema)
.output(orderResponseSchema)
.handler(async ({ input, context: { user } }) => {
const { customerId, items } = input;
// Business logic
const total = calculateOrderTotal(items);
const reference = generateOrderReference(new Date());
// Database operation
const [newOrder] = await db
.insert(orderTable)
.values({
userId: user.id,
customerId,
reference,
total,
status: "PENDING",
})
.returning();
logger.info("Order created", {
orderId: newOrder.id,
userId: user.id,
total,
});
return {
success: true,
reason: "Order created successfully",
order: {
id: newOrder.id,
status: newOrder.status,
total: newOrder.total,
},
};
});
```
### `api/` - Documentation (Optional)
Markdown documentation for each endpoint, useful for complex APIs.
```markdown
<!-- api/create.md -->
# Create Order
## Schema
### Input
- `customerId`: string - Customer identifier
- `items`: array - Order items
- `productId`: string - Product identifier
- `quantity`: number - Quantity (min: 1)
### Output
- `success`: boolean
- `reason`: string
- `order`: object (optional)
## Logic
1. Validate user has permission to create orders for the customer
2. Verify all products exist and are in stock
3. Calculate total with applicable discounts
4. Create order record
5. Reserve inventory
6. Return order details
## Usage Example
```typescript
const result = await api.orders.create({
customerId: "cust_123",
items: [
{ productId: "prod_456", quantity: 2 },
],
});
```
```
## Naming Conventions
### Files
| Type | Convention | Example |
|------|------------|---------|
| Procedures | Verb-based | `create.ts`, `list.ts`, `update-status.ts` |
| Lib files | Noun-based | `helpers.ts`, `validators.ts`, `client.ts` |
| Types | Always `types.ts` | `types.ts` |
| Router | Always `router.ts` | `router.ts` |
### Exports
| Type | Convention | Example |
|------|------------|---------|
| Schemas | `{name}Schema` suffix | `createOrderInputSchema` |
| Types | PascalCase | `CreateOrderInput` |
| Procedures | camelCase verb | `createOrder`, `listOrders` |
| Helpers | camelCase verb | `calculateTotal`, `generateReference` |
## When to Create New Modules
Create a new module when:
1. The feature represents a distinct domain entity (users, orders, products)
2. The feature has multiple related operations (CRUD + custom actions)
3. The feature will be reused across multiple routes
Avoid creating modules for:
1. Single-use utility functions (place in existing `lib/`)
2. Simple helpers (place in `@your-app/utils`)
3. Database queries only (place in `packages/database/drizzle/queries/`)

View File

@@ -0,0 +1,154 @@
# Backend Development Guidelines Index
> **Tech Stack**: Next.js 15 API Routes + oRPC + Drizzle ORM + PostgreSQL
## Related Guidelines
| Guideline | Location | When to Read |
| ------------------------- | ------------ | ---------------------------- |
| **Shared Code Standards** | `../shared/` | Always - applies to all code |
---
## Documentation Files
| File | Description | When to Read |
| ---------------------------------------------------- | -------------------------------------------------- | ---------------------------------- |
| [directory-structure.md](./directory-structure.md) | Module organization and directory layout | Starting a new feature |
| [orpc-usage.md](./orpc-usage.md) | oRPC router, procedures, middleware patterns | Creating/modifying API endpoints |
| [type-safety.md](./type-safety.md) | Zod schemas, type narrowing, response patterns | Type-related decisions |
| [database.md](./database.md) | Drizzle ORM, queries, transactions, SQL patterns | Database operations |
| [authentication.md](./authentication.md) | better-auth, sessions, OAuth, protected procedures | Auth-related features |
| [logging.md](./logging.md) | Structured logging, Sentry tracing, telemetry | Debugging, observability |
| [local-json-mvp.md](./local-json-mvp.md) | Local JSON persistence/session/API contracts before database adoption | Single-repo MVP features without DB/oRPC deps |
| [performance.md](./performance.md) | Concurrency, caching, batch processing, streaming | Performance optimization |
| [ai-sdk-integration.md](./ai-sdk-integration.md) | Vercel AI SDK, tool calling, prompt patterns | AI-powered features |
| [quality.md](./quality.md) | Pre-commit checklist for backend code | Before committing |
---
## Quick Navigation
### Service Module Structure
| Task | File |
| ----------------------------- | -------------------------------------------------- |
| Project structure | [directory-structure.md](./directory-structure.md) |
| Domain module pattern | [directory-structure.md](./directory-structure.md) |
| Write types.ts | [directory-structure.md](./directory-structure.md) |
| Write procedure | [directory-structure.md](./directory-structure.md) |
| Write lib/ helpers | [directory-structure.md](./directory-structure.md) |
| Router setup | [orpc-usage.md](./orpc-usage.md) |
| Middleware composition | [orpc-usage.md](./orpc-usage.md) |
| Naming conventions | [directory-structure.md](./directory-structure.md) |
### Type Safety
| Task | File |
| -------------------- | ---------------------------------- |
| Type safety patterns | [type-safety.md](./type-safety.md) |
| Discriminated unions | [type-safety.md](./type-safety.md) |
| Zod-first types | [type-safety.md](./type-safety.md) |
| Zod error handling | [type-safety.md](./type-safety.md) |
| Standard response | [type-safety.md](./type-safety.md) |
### Database (Drizzle + PostgreSQL)
| Task | File |
| ----------------------- | ---------------------------- |
| Query organization | [database.md](./database.md) |
| Batch queries (inArray) | [database.md](./database.md) |
| Conflict handling | [database.md](./database.md) |
| Transactions | [database.md](./database.md) |
| JSON column operations | [database.md](./database.md) |
| Raw SQL camelCase | [database.md](./database.md) |
| Enum comparison | [database.md](./database.md) |
### Error Handling / Logging
| Task | File |
| --------------------------- | ------------------------------ |
| Structured logging | [logging.md](./logging.md) |
| Sentry span tracing | [logging.md](./logging.md) |
| Error capture | [logging.md](./logging.md) |
| oRPC error codes | [orpc-usage.md](./orpc-usage.md) |
| Batch operation logging | [logging.md](./logging.md) |
### Performance
| Task | File |
| ----------------------------- | ---------------------------------- |
| Parallel execution | [performance.md](./performance.md) |
| Concurrency control (p-limit) | [performance.md](./performance.md) |
| Exponential backoff retry | [performance.md](./performance.md) |
| Redis caching | [performance.md](./performance.md) |
| Distributed locks | [performance.md](./performance.md) |
| Chunked batch processing | [performance.md](./performance.md) |
| Streaming large datasets | [performance.md](./performance.md) |
### Authentication
| Task | File |
| --------------------------- | -------------------------------------- |
| Protected procedures | [authentication.md](./authentication.md) |
| Admin procedures | [authentication.md](./authentication.md) |
| Session caching (Redis) | [authentication.md](./authentication.md) |
| OAuth integration | [authentication.md](./authentication.md) |
| Role-based access control | [authentication.md](./authentication.md) |
| Client-side auth | [authentication.md](./authentication.md) |
### AI Integration
| Task | File |
| --------------------------- | ---------------------------------------------- |
| generateText / generateObject | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| Streaming (streamText) | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| Tool calling | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| Telemetry configuration | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| Prompt engineering | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| AI error handling | [ai-sdk-integration.md](./ai-sdk-integration.md) |
---
## Core Rules Summary
| Rule | Reference |
| ---------------------------------------------------------------- | -------------------------------------------------- |
| **No `await` in loops** - use `inArray` for batch queries | [database.md](./database.md) |
| **No `console.log`** - use structured logger | [logging.md](./logging.md) |
| **No non-null assertions `!`** - use type narrowing | [type-safety.md](./type-safety.md) |
| **All API inputs/outputs have Zod schemas** | [type-safety.md](./type-safety.md) |
| **Import enums from utils** - not from database package | [type-safety.md](./type-safety.md) |
| **Standard response format** - always include `success`/`reason` | [type-safety.md](./type-safety.md) |
| **Use `protectedProcedure`** for authenticated endpoints | [authentication.md](./authentication.md) |
| **One procedure per file** - keep procedures focused | [orpc-usage.md](./orpc-usage.md) |
| **Service modules follow domain layout** | [directory-structure.md](./directory-structure.md) |
| **Use `Promise.all`** for independent parallel operations | [performance.md](./performance.md) |
| **Use `p-limit`** for external API concurrency control | [performance.md](./performance.md) |
| **Always enable AI telemetry** for token tracking | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| **Cast `::jsonb`** for PostgreSQL JSON operations | [database.md](./database.md) |
| **Double-quote camelCase** column names in raw SQL | [database.md](./database.md) |
| **Use structured context** in logs - no string interpolation | [logging.md](./logging.md) |
| **Run pre-commit checklist** before committing | [quality.md](./quality.md) |
---
## Reference Files
| Feature | Typical Location |
| -------------------- | --------------------------------------- |
| Drizzle Client | `packages/database/drizzle/client.ts` |
| Schema Definition | `packages/database/drizzle/schema/` |
| Database Queries | `packages/database/drizzle/queries/` |
| oRPC Router | `packages/api/orpc/router.ts` |
| Base Procedures | `packages/api/orpc/procedures.ts` |
| Middleware | `packages/api/orpc/middleware/` |
| Service Module | `packages/api/modules/{domain}/` |
| Module Types (Zod) | `packages/api/modules/{domain}/types.ts`|
| Auth Configuration | `packages/auth/auth.ts` |
| Auth Client | `packages/auth/client.ts` |
| Shared Utils/Enums | `packages/utils/` |
---
**Language**: All documentation must be written in **English**.

View File

@@ -0,0 +1,86 @@
# Local JSON MVP Persistence and API Contracts
## Scenario: Single-Repo MVP Before Database Adoption
### 1. Scope / Trigger
- Trigger: implementing full-stack CRUD, auth, RBAC, or audit logging before Drizzle/Postgres/oRPC/better-auth are installed.
- Applies to this single-package Next.js app when the feature must be real and durable in local/runtime execution, but production database infrastructure is not yet available.
- Store boundary: `modules/core/server/store.ts`.
### 2. Signatures
- `readData(): Promise<AppData>`
- `writeData(data: AppData): Promise<void>`
- `updateData<T>(mutator: (data: AppData) => T): Promise<T>`
- `getCurrentAuthContext(): Promise<AuthContext | null>`
- `requirePermission(permission: Permission, auditContext: DeniedAuditContext): Promise<PermissionCheckSuccess | PermissionCheckFailure>`
- Route Handlers use standard `GET`, `POST`, `PATCH`, and `DELETE` exports and return `Response`.
### 3. Contracts
- Default data file: `.data/teatea.json`.
- Override key: `TEATEA_DATA_DIR` points to the directory containing `teatea.json`.
- `.data/` must stay ignored; it may contain account hashes and session IDs.
- API response shape:
```ts
type ApiResult<T extends Record<string, unknown>> =
| ({ success: true; reason: string } & T)
| { success: false; reason: string };
```
- Session cookie:
- Name: `teatea_session`
- Flags: `httpOnly`, `sameSite: "lax"`, `path: "/"`
- Lifetime: 7 days
- Passwords:
- Never persist plaintext.
- Use Node crypto salt + scrypt hash until a dedicated auth library is introduced.
### 4. Validation & Error Matrix
- Missing/expired session -> `401` with `{ success: false, reason: "未登录或会话已过期" }`.
- Missing permission -> `403` with `{ success: false, reason: "权限不足" }` and a denied audit log.
- Invalid JSON body -> `400` with a Chinese user-facing `reason`.
- Missing record by ID -> `404` with `{ success: false, reason: "<entity>不存在" }`.
- Duplicate account email -> `409` with `{ success: false, reason: "账号已存在" }`.
### 5. Good/Base/Bad Cases
- Good: UI submits to Route Handler, Route Handler validates input, calls `requirePermission`, mutates data through `updateData`, writes audit log, returns `ApiResult`.
- Base: Server Component reads data directly via `readData` after session/permission checks.
- Bad: UI or route handler reads `.data/teatea.json` directly, bypasses `requirePermission`, or stores auth state in localStorage.
### 6. Tests Required
- `pnpm lint`
- `pnpm type-check`
- `pnpm build`
- Manual or automated integration assertions:
- first setup creates admin and cookie session
- protected app route redirects without cookie
- CRUD mutation persists across reload/API list
- denied permission returns 403 and writes an audit log
### 7. Wrong vs Correct
#### Wrong
```ts
window.localStorage.setItem("teatea.session", JSON.stringify(session));
```
#### Correct
```ts
const cookieStore = await cookies();
cookieStore.set({
name: "teatea_session",
value: sessionId,
httpOnly: true,
sameSite: "lax",
path: "/",
});
```

View File

@@ -0,0 +1,340 @@
# Logging and Monitoring
This document covers structured logging, error tracking with Sentry, and observability patterns.
## Critical Rules
### NO `console.log` - Use Structured Logger
Never use `console.log` in production code. Always use the structured logger from `@your-app/logs`.
```typescript
// BAD - Unstructured console logging
console.log("Order created:", orderId);
console.error("Failed to process:", error);
// GOOD - Structured logging
import { logger } from "@your-app/logs";
logger.info("Order created", {
orderId,
userId,
total: order.total,
});
logger.error("Failed to process order", {
orderId,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
```
## Logger API
```typescript
import { logger } from "@your-app/logs";
// Log levels
logger.debug("Debug message", { context: "value" });
logger.info("Info message", { orderId, status });
logger.warn("Warning message", { userId, reason: "quota exceeded" });
logger.error("Error message", { error: err.message, stack: err.stack });
```
## Sentry Integration
### Span Tracing
Use the tracing system to monitor performance and track operations.
```typescript
import { SpanPrefix, span } from "../../../lib/tracer";
// Database operations
const orders = await span(
`${SpanPrefix.DB}GetUserOrders`,
() => db.select().from(orderTable).where(eq(orderTable.userId, userId)),
{ userId, limit: 20 }
);
// External API calls
const response = await span(
`${SpanPrefix.Http}FetchInventory`,
() => inventoryClient.getStock(productIds),
{ productCount: productIds.length }
);
// Redis cache operations
const cached = await span(
`${SpanPrefix.Redis}GetSession`,
() => redis.get(sessionKey),
{ sessionKey }
);
```
### SpanPrefix Constants
Use standardized prefixes for consistent Sentry categorization:
```typescript
import { SpanPrefix } from "../../../lib/tracer";
const SpanPrefix = {
/** Database operations - maps to Sentry op: db.query */
DB: "DB.",
/** External HTTP API calls - maps to Sentry op: http.client */
Http: "Http.",
/** Redis cache operations - maps to Sentry op: db.redis */
Redis: "Redis.",
/** AI model invocations - maps to Sentry op: ai.run */
AI: "AI.",
/** Generic cache operations - maps to Sentry op: cache */
Cache: "Cache.",
/** Queue/message operations - maps to Sentry op: queue */
Queue: "Queue.",
} as const;
```
### Naming Convention
```typescript
// Pattern: ${SpanPrefix.Type}${Action}${Resource}
// Database
`${SpanPrefix.DB}GetUserOrders`
`${SpanPrefix.DB}BatchUpdateProducts`
`${SpanPrefix.DB}CreateOrder`
// External APIs
`${SpanPrefix.Http}FetchPaymentStatus`
`${SpanPrefix.Http}SendNotification`
// Redis
`${SpanPrefix.Redis}GetSession`
`${SpanPrefix.Redis}SetCache`
// AI
`${SpanPrefix.AI}ClassifyContent`
`${SpanPrefix.AI}GenerateResponse`
```
### Error Capture
```typescript
import { captureError } from "../../../lib/tracer";
try {
await processOrder(orderId);
} catch (error) {
captureError(error, {
tags: {
operation: "processOrder",
orderId,
},
extra: {
userId: context.user.id,
orderStatus: order.status,
},
});
throw error; // Re-throw if needed
}
```
### Trace Context
For complex operations, use trace context to correlate logs:
```typescript
import { runWithTrace, getLogId } from "../../../lib/tracer";
export async function processOrderBatch(orderIds: string[]) {
return runWithTrace(`batch-${Date.now()}`, async () => {
const logId = getLogId();
logger.info("Starting batch processing", {
logId,
orderCount: orderIds.length
});
for (const orderId of orderIds) {
await span(
`${SpanPrefix.DB}ProcessOrder`,
() => processSingleOrder(orderId),
{ orderId }
);
}
logger.info("Batch processing complete", { logId });
});
}
```
## AI SDK Telemetry
When using the Vercel AI SDK, enable telemetry for token tracking:
```typescript
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
const result = await generateText({
model: openai("gpt-4o"),
prompt: userPrompt,
experimental_telemetry: {
isEnabled: true,
functionId: "classify-content",
metadata: {
userId,
contentLength: content.length,
},
},
});
```
### Telemetry Metadata
Include relevant context in telemetry:
```typescript
experimental_telemetry: {
isEnabled: true,
functionId: "generate-response", // Unique identifier for this AI function
metadata: {
// User context
userId: context.user.id,
// Input metrics
promptTokens: estimatedTokens,
// Business context
feature: "auto-reply",
priority: "high",
},
}
```
## Error Handling Patterns
### Structured Error Logging
```typescript
async function processPayment(orderId: string) {
try {
const result = await paymentGateway.charge(orderId);
logger.info("Payment processed", {
orderId,
transactionId: result.transactionId,
amount: result.amount,
});
return result;
} catch (error) {
logger.error("Payment processing failed", {
orderId,
error: error instanceof Error ? error.message : String(error),
errorCode: (error as any).code,
});
// Capture to Sentry with context
captureError(error, {
tags: { service: "payment", operation: "charge" },
extra: { orderId },
});
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Payment processing failed",
});
}
}
```
### Batch Operation Logging
```typescript
async function batchUpdateInventory(updates: InventoryUpdate[]) {
const results: ProcessResult[] = [];
logger.info("Starting batch inventory update", {
updateCount: updates.length,
});
const processed = await Promise.allSettled(
updates.map(update => processUpdate(update))
);
const successful = processed.filter(r => r.status === "fulfilled").length;
const failed = processed.filter(r => r.status === "rejected").length;
logger.info("Batch inventory update complete", {
total: updates.length,
successful,
failed,
});
if (failed > 0) {
logger.warn("Some inventory updates failed", {
failedCount: failed,
errors: processed
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
.map(r => r.reason?.message || "Unknown error"),
});
}
return { successful, failed };
}
```
## Logging Best Practices
### What to Log
**Always log:**
- Request/response for external API calls
- Database write operations (create, update, delete)
- Authentication events
- Business-critical operations
- Errors and exceptions
**Log with care (avoid sensitive data):**
- User inputs (sanitize PII)
- Request payloads (redact secrets)
**Never log:**
- Passwords or tokens
- Credit card numbers
- Personal identification numbers
- API keys or secrets
### Log Levels Guide
| Level | Use Case | Example |
|-------|----------|---------|
| `debug` | Development diagnostics | Variable values, flow tracing |
| `info` | Normal operations | Order created, user logged in |
| `warn` | Recoverable issues | Rate limit approaching, retry attempted |
| `error` | Failures requiring attention | API call failed, database error |
### Structured Context
Always include relevant context as structured data:
```typescript
// BAD - String interpolation
logger.info(`User ${userId} created order ${orderId} for $${total}`);
// GOOD - Structured context
logger.info("Order created", {
userId,
orderId,
total,
currency: "USD",
itemCount: items.length,
});
```

View File

@@ -0,0 +1,805 @@
# oRPC Backend Usage Guidelines
## 1. Overview
### What is oRPC
oRPC (OpenAPI RPC) is a type-safe RPC framework for TypeScript that provides end-to-end type safety from your backend to frontend. It combines the best aspects of REST APIs and RPC frameworks while generating OpenAPI specifications automatically.
### Why oRPC over tRPC or plain REST
| Feature | oRPC | tRPC | REST |
|---------|------|------|------|
| Type Safety | End-to-end | End-to-end | Manual |
| OpenAPI Generation | Built-in | Plugin required | Manual |
| HTTP Method Control | Full control | Limited | Full control |
| Learning Curve | Low | Low | Medium |
| Middleware Support | Native | Native | Framework-dependent |
| Schema Validation | Zod native | Zod native | Manual |
Key advantages of oRPC:
- **OpenAPI-first**: Automatic OpenAPI spec generation for documentation and client generation
- **HTTP semantics**: Full control over HTTP methods, paths, and tags
- **Type inference**: Automatic TypeScript types from Zod schemas
- **Middleware composition**: Chainable middleware for auth, logging, etc.
### Project Structure with oRPC
```
packages/api/
├── orpc/
│ ├── router.ts # Main router composition
│ ├── procedures.ts # Base procedure definitions
│ └── middleware/ # Reusable middleware
│ ├── log-id-middleware.ts
│ └── locale-middleware.ts
├── modules/
│ └── [module]/
│ ├── router.ts # Module router exports
│ ├── types.ts # Zod schemas and TypeScript types
│ └── procedures/ # Individual procedure implementations
│ ├── create-item.ts
│ ├── list-items.ts
│ └── update-item.ts
└── lib/ # Shared utilities
```
## 2. Router Setup
### Main Router Structure
The main router composes all module routers under a common prefix:
```typescript
// orpc/router.ts
import type { RouterClient } from "@orpc/server";
import { usersRouter } from "../modules/users/router";
import { itemsRouter } from "../modules/items/router";
import { publicProcedure } from "./procedures";
export const router = publicProcedure
// Prefix for OpenAPI paths
.prefix("/api")
.router({
users: usersRouter,
items: itemsRouter,
// Add more module routers here
});
// Export type for frontend client
export type ApiRouterClient = RouterClient<typeof router>;
```
### Module Router Composition
Each module exports a router object that groups related procedures:
```typescript
// modules/items/router.ts
import { createItem } from "./procedures/create-item";
import { deleteItem } from "./procedures/delete-item";
import { findItem } from "./procedures/find-item";
import { listItems } from "./procedures/list-items";
import { updateItem } from "./procedures/update-item";
export const itemsRouter = {
list: listItems,
find: findItem,
create: createItem,
update: updateItem,
delete: deleteItem,
// Nested routes are supported
drafts: {
list: listDrafts,
save: saveDraft,
},
};
```
### Base Procedures with Middleware
Define base procedures with common middleware:
```typescript
// orpc/procedures.ts
import { ORPCError, os } from "@orpc/server";
import { logIdMiddleware } from "./middleware/log-id-middleware";
// Public procedure - no authentication required
export const publicProcedure = os
.$context<{
headers: Headers;
}>()
.use(logIdMiddleware);
// Protected procedure - requires authentication
export const protectedProcedure = publicProcedure.use(
async ({ context, next }) => {
const session = await getSession(context.headers);
if (!session) {
throw new ORPCError("UNAUTHORIZED");
}
return await next({
context: {
session: session.session,
user: session.user,
},
});
},
);
// Admin procedure - requires admin role
export const adminProcedure = protectedProcedure.use(
async ({ context, next }) => {
if (context.user.role !== "admin") {
throw new ORPCError("FORBIDDEN");
}
return await next();
},
);
```
## 3. Procedure Definition
### Query Procedures (GET-like)
Use GET method for read operations that don't modify data:
```typescript
// modules/items/procedures/list-items.ts
import { z } from "zod";
import { protectedProcedure } from "../../../orpc/procedures";
// Define input schema
const listItemsInputSchema = z.object({
limit: z.number().min(1).max(100).default(50),
cursor: z.object({
createdAt: z.string(),
id: z.string(),
}).optional(),
filters: z.object({
status: z.enum(["active", "archived"]).optional(),
category: z.string().optional(),
}).optional(),
});
// Define output schema
const listItemsOutputSchema = z.object({
items: z.array(z.object({
id: z.string(),
name: z.string(),
status: z.string(),
createdAt: z.date(),
})),
nextCursor: z.object({
createdAt: z.string(),
id: z.string(),
}).nullable(),
hasMore: z.boolean(),
});
export const listItems = protectedProcedure
.route({
method: "GET",
path: "/items",
tags: ["Items"],
summary: "List items with cursor pagination",
description: "Retrieve a paginated list of items for the current user",
})
.input(listItemsInputSchema)
.output(listItemsOutputSchema)
.handler(async ({ input, context }) => {
const { limit, cursor, filters } = input;
const { user } = context;
// Query implementation
const items = await db.query.items.findMany({
where: { userId: user.id, ...filters },
limit: limit + 1, // Fetch one extra to check hasMore
orderBy: [desc(items.createdAt), desc(items.id)],
});
const hasMore = items.length > limit;
const resultItems = hasMore ? items.slice(0, limit) : items;
return {
items: resultItems,
nextCursor: hasMore && resultItems.length > 0
? {
createdAt: resultItems[resultItems.length - 1].createdAt.toISOString(),
id: resultItems[resultItems.length - 1].id,
}
: null,
hasMore,
};
});
```
### Mutation Procedures (POST/PUT/DELETE-like)
Use POST for create operations, PUT/PATCH for updates, DELETE for removals:
```typescript
// modules/items/procedures/create-item.ts
import { ORPCError } from "@orpc/client";
import { z } from "zod";
import { protectedProcedure } from "../../../orpc/procedures";
const createItemInputSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
categoryId: z.string().optional(),
});
const createItemOutputSchema = z.object({
item: z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
createdAt: z.date(),
}),
});
export const createItem = protectedProcedure
.route({
method: "POST",
path: "/items",
tags: ["Items"],
summary: "Create a new item",
})
.input(createItemInputSchema)
.output(createItemOutputSchema)
.handler(async ({ input, context }) => {
const { name, description, categoryId } = input;
const { user } = context;
// Validate category if provided
if (categoryId) {
const category = await db.query.categories.findFirst({
where: { id: categoryId, userId: user.id },
});
if (!category) {
throw new ORPCError("NOT_FOUND", {
message: "Category not found",
});
}
}
const item = await db.insert(items).values({
name,
description,
categoryId,
userId: user.id,
}).returning();
return { item: item[0] };
});
```
### Update Procedure Example
```typescript
// modules/items/procedures/update-item.ts
import { ORPCError } from "@orpc/client";
import { z } from "zod";
import { protectedProcedure } from "../../../orpc/procedures";
const updateItemInputSchema = z.object({
itemId: z.string(),
name: z.string().min(1).max(255).optional(),
description: z.string().optional(),
status: z.enum(["active", "archived"]).optional(),
});
export const updateItem = protectedProcedure
.route({
method: "PUT",
path: "/items/{itemId}",
tags: ["Items"],
summary: "Update an item",
})
.input(updateItemInputSchema)
.handler(async ({ input, context }) => {
const { itemId, ...updates } = input;
const { user } = context;
// Verify ownership
const existingItem = await db.query.items.findFirst({
where: { id: itemId },
});
if (!existingItem) {
throw new ORPCError("NOT_FOUND", { message: "Item not found" });
}
if (existingItem.userId !== user.id) {
throw new ORPCError("FORBIDDEN", {
message: "You don't have permission to modify this item",
});
}
const updated = await db.update(items)
.set({ ...updates, updatedAt: new Date() })
.where(eq(items.id, itemId))
.returning();
return { item: updated[0] };
});
```
### Input Validation with Zod
oRPC uses Zod for input validation. Define schemas in a separate `types.ts` file for reusability:
```typescript
// modules/items/types.ts
import { z } from "zod";
// Input Schemas
export const createItemInputSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().max(1000).optional(),
tags: z.array(z.string()).max(10).optional(),
});
export const updateItemInputSchema = z.object({
itemId: z.string(),
name: z.string().min(1).max(255).optional(),
description: z.string().max(1000).optional(),
});
export const listItemsInputSchema = z.object({
limit: z.number().int().min(1).max(100).default(50),
cursor: z.object({
createdAt: z.string(),
id: z.string(),
}).optional(),
});
// Output Schemas
export const itemSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
status: z.enum(["active", "archived"]),
createdAt: z.date(),
updatedAt: z.date(),
});
export const operationResultSchema = z.object({
success: z.boolean(),
});
export const batchOperationResultSchema = z.object({
success: z.boolean(),
successCount: z.number(),
failedCount: z.number(),
failedIds: z.array(z.string()).optional(),
});
// Type exports (inferred from schemas)
export type CreateItemInput = z.infer<typeof createItemInputSchema>;
export type UpdateItemInput = z.infer<typeof updateItemInputSchema>;
export type ListItemsInput = z.infer<typeof listItemsInputSchema>;
export type Item = z.infer<typeof itemSchema>;
export type OperationResult = z.infer<typeof operationResultSchema>;
```
## 4. Middleware
### Authentication Middleware
Built into `protectedProcedure`:
```typescript
// orpc/procedures.ts
export const protectedProcedure = publicProcedure.use(
async ({ context, next }) => {
const session = await getSession(context.headers);
if (!session) {
throw new ORPCError("UNAUTHORIZED");
}
// Add user info to context for downstream handlers
return await next({
context: {
session: session.session,
user: session.user,
},
});
},
);
```
### Logging Middleware
Generate and propagate request IDs for tracing:
```typescript
// orpc/middleware/log-id-middleware.ts
import { os } from "@orpc/server";
function generateLogId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
}
function getOrGenerateLogId(headers: Headers): string {
// Prefer client-provided x-log-id for distributed tracing
const existingLogId = headers.get("x-log-id");
if (existingLogId) {
return existingLogId;
}
return generateLogId();
}
export const logIdMiddleware = os
.$context<{
headers: Headers;
}>()
.middleware(async ({ context, next }) => {
const logId = getOrGenerateLogId(context.headers);
// Run with tracing context
return await runWithTrace(logId, async () => {
return await next({
context: {
logId,
},
});
});
});
```
### Locale Middleware
Extract locale from cookies for i18n:
```typescript
// orpc/middleware/locale-middleware.ts
import { os } from "@orpc/server";
import { getCookie } from "@orpc/server/helpers";
import { config } from "@your-app/config";
import type { Locale } from "@your-app/i18n";
export const localeMiddleware = os
.$context<{
headers: Headers;
}>()
.middleware(async ({ context, next }) => {
const locale = (getCookie(
context.headers,
config.i18n.localeCookieName,
) as Locale) ?? config.i18n.defaultLocale;
return await next({
context: {
locale,
},
});
});
```
### Using Middleware in Procedures
Apply middleware to specific procedures:
```typescript
// modules/contact/procedures/submit-contact-form.ts
import { localeMiddleware } from "../../../orpc/middleware/locale-middleware";
import { publicProcedure } from "../../../orpc/procedures";
export const submitContactForm = publicProcedure
.route({
method: "POST",
path: "/contact",
tags: ["Contact"],
summary: "Submit contact form",
})
.input(contactFormSchema)
.use(localeMiddleware) // Apply locale middleware
.handler(async ({ input, context: { locale } }) => {
// locale is now available in context
await sendEmail({
to: config.contactForm.to,
locale,
subject: config.contactForm.subject,
text: `Name: ${input.name}\n\nEmail: ${input.email}\n\nMessage: ${input.message}`,
});
});
```
### Error Handling Middleware
Create custom error handling:
```typescript
// orpc/middleware/error-middleware.ts
import { ORPCError, os } from "@orpc/server";
import { logger } from "@your-app/logs";
export const errorMiddleware = os.middleware(async ({ next, path }) => {
try {
return await next();
} catch (error) {
// Log error with context
logger.error("Procedure error", {
path,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
// Re-throw oRPC errors as-is
if (error instanceof ORPCError) {
throw error;
}
// Wrap unknown errors
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "An unexpected error occurred",
});
}
});
```
## 5. Context
### How to Access User Session
The session is available in context after `protectedProcedure`:
```typescript
export const getProfile = protectedProcedure
.route({ method: "GET", path: "/users/profile", tags: ["Users"] })
.handler(async ({ context }) => {
// context.user contains the authenticated user
const { user, session } = context;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
sessionId: session.id,
};
});
```
### How to Access Logger
Use the logger from the shared logs package:
```typescript
import { logger } from "@your-app/logs";
export const createItem = protectedProcedure
.route({ method: "POST", path: "/items", tags: ["Items"] })
.input(createItemInputSchema)
.handler(async ({ input, context }) => {
logger.info("Creating item", {
userId: context.user.id,
itemName: input.name,
});
try {
const item = await db.insert(items).values({
...input,
userId: context.user.id,
}).returning();
logger.info("Item created successfully", { itemId: item[0].id });
return { item: item[0] };
} catch (error) {
logger.error("Failed to create item", {
userId: context.user.id,
error: error instanceof Error ? error.message : String(error),
});
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Failed to create item",
});
}
});
```
### How to Access Database
Import the database client and use it directly:
```typescript
import { db } from "@your-app/database";
import { items, categories } from "@your-app/database/drizzle/schema";
import { eq, and, desc } from "drizzle-orm";
export const listItems = protectedProcedure
.route({ method: "GET", path: "/items", tags: ["Items"] })
.handler(async ({ context }) => {
// Using Drizzle query builder
const userItems = await db.query.items.findMany({
where: eq(items.userId, context.user.id),
orderBy: desc(items.createdAt),
with: {
category: true, // Include relations
},
});
// Or using raw select
const itemsWithCategory = await db
.select({
id: items.id,
name: items.name,
categoryName: categories.name,
})
.from(items)
.leftJoin(categories, eq(items.categoryId, categories.id))
.where(eq(items.userId, context.user.id));
return { items: userItems };
});
```
## 6. Best Practices
### Input/Output Schema Naming Conventions
Follow consistent naming patterns:
```typescript
// Input schemas: [action][Entity]InputSchema
export const createItemInputSchema = z.object({ ... });
export const updateItemInputSchema = z.object({ ... });
export const listItemsInputSchema = z.object({ ... });
export const deleteItemInputSchema = z.object({ ... });
// Output schemas: [action][Entity]OutputSchema or [entity]Schema
export const itemSchema = z.object({ ... });
export const listItemsOutputSchema = z.object({ ... });
export const operationResultSchema = z.object({ ... });
// Shared/reusable schemas: [entity]Schema or [concept]Schema
export const paginationSchema = z.object({
limit: z.number().int().min(1).max(100).default(50),
cursor: z.object({
createdAt: z.string(),
id: z.string(),
}).optional(),
});
```
### Error Handling Patterns
Use appropriate error codes and messages:
```typescript
import { ORPCError } from "@orpc/client";
// Resource not found
throw new ORPCError("NOT_FOUND", {
message: "Item not found",
});
// Permission denied
throw new ORPCError("FORBIDDEN", {
message: "You don't have permission to access this resource",
});
// Authentication required
throw new ORPCError("UNAUTHORIZED", {
message: "Please sign in to continue",
});
// Validation error (usually handled by Zod, but for custom validation)
throw new ORPCError("BAD_REQUEST", {
message: "Invalid email format",
});
// Conflict (e.g., duplicate entry)
throw new ORPCError("CONFLICT", {
message: "An item with this name already exists",
});
// Server error (wrap internal errors)
try {
await externalService.call();
} catch (error) {
logger.error("External service failed", { error });
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Service temporarily unavailable",
});
}
```
### Procedure Organization
1. **One procedure per file**: Keep procedures focused and testable
2. **Group related procedures**: Use module routers to organize by domain
3. **Reuse schemas**: Define common schemas in `types.ts`
4. **Consistent file naming**: Use kebab-case matching the procedure name
```
modules/items/
├── router.ts # Exports all procedures
├── types.ts # Shared schemas and types
└── procedures/
├── create-item.ts # createItem procedure
├── delete-item.ts # deleteItem procedure
├── find-item.ts # findItem procedure
├── list-items.ts # listItems procedure
└── update-item.ts # updateItem procedure
```
### Performance Tips
1. **Use cursor pagination** instead of offset for large datasets
2. **Batch database queries** to avoid N+1 problems
3. **Add appropriate indexes** for filtered/sorted columns
4. **Use select** to fetch only needed columns
```typescript
// Avoid N+1 queries - fetch related data in batch
const items = await db.query.items.findMany({
where: eq(items.userId, user.id),
limit,
});
// Batch fetch labels for all items
const itemIds = items.map(i => i.id);
const labels = await db.query.itemLabels.findMany({
where: inArray(itemLabels.itemId, itemIds),
});
// Group labels by itemId
const labelsByItemId = new Map();
for (const label of labels) {
const existing = labelsByItemId.get(label.itemId) || [];
existing.push(label);
labelsByItemId.set(label.itemId, existing);
}
// Combine results
const itemsWithLabels = items.map(item => ({
...item,
labels: labelsByItemId.get(item.id) || [],
}));
```
### Testing Procedures
Structure tests to cover various scenarios:
```typescript
import { describe, expect, it } from "vitest";
import { createCaller } from "../test-utils";
describe("createItem", () => {
it("creates an item successfully", async () => {
const caller = createCaller({ user: testUser });
const result = await caller.items.create({
name: "Test Item",
description: "A test item",
});
expect(result.item.name).toBe("Test Item");
expect(result.item.id).toBeDefined();
});
it("throws UNAUTHORIZED for unauthenticated users", async () => {
const caller = createCaller({ user: null });
await expect(
caller.items.create({ name: "Test" })
).rejects.toThrow("UNAUTHORIZED");
});
it("validates input schema", async () => {
const caller = createCaller({ user: testUser });
await expect(
caller.items.create({ name: "" }) // Empty name
).rejects.toThrow();
});
});
```

View File

@@ -0,0 +1,498 @@
# Performance Patterns
This document covers performance optimization patterns for backend development.
## Parallel Execution with Promise.all
When operations are independent, execute them in parallel.
```typescript
// BAD - Sequential execution (slow)
const user = await getUser(userId);
const orders = await getOrders(userId);
const preferences = await getPreferences(userId);
// GOOD - Parallel execution
const [user, orders, preferences] = await Promise.all([
getUser(userId),
getOrders(userId),
getPreferences(userId),
]);
```
### Promise.allSettled for Partial Failures
When some operations can fail without blocking others:
```typescript
const results = await Promise.allSettled([
processOrderA(),
processOrderB(),
processOrderC(),
]);
const successful = results
.filter((r): r is PromiseFulfilledResult<Order> => r.status === "fulfilled")
.map(r => r.value);
const failed = results
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
.map(r => r.reason);
logger.info("Batch processing complete", {
successful: successful.length,
failed: failed.length,
});
```
## Concurrency Control with p-limit
When calling external APIs, limit concurrent requests to avoid rate limiting.
```typescript
import pLimit from "p-limit";
// Create limiter with max 20 concurrent requests
const limit = pLimit(20);
const orderIds = ["order1", "order2", /* ... hundreds more */];
// Process all with controlled concurrency
const results = await Promise.all(
orderIds.map(orderId =>
limit(() => fetchOrderDetails(orderId))
)
);
```
### Shared Limiter Pattern
For module-wide concurrency control:
```typescript
// lib/api-client.ts
import pLimit from "p-limit";
// External API concurrency limit
const API_CONCURRENCY = 20;
export function createApiLimiter(): ReturnType<typeof pLimit> {
return pLimit(API_CONCURRENCY);
}
// Usage in procedure
const limiter = createApiLimiter();
const results = await Promise.allSettled(
items.map(item =>
limiter(async () => {
try {
const result = await externalApi.process(item);
return { itemId: item.id, success: true, result };
} catch (error) {
return {
itemId: item.id,
success: false,
error: error instanceof Error ? error.message : "Unknown error"
};
}
})
)
);
```
## Rate Limit Retry with Exponential Backoff
Handle rate limits gracefully with automatic retry.
```typescript
const MAX_RETRIES = 3;
async function fetchWithRetry<T>(
fn: () => Promise<T>,
context: { operation: string; itemId: string }
): Promise<T> {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (error: any) {
const isRateLimited = error?.code === 429 || error?.status === 429;
if (isRateLimited && attempt < MAX_RETRIES) {
// Exponential backoff: 2^attempt seconds + random jitter
const delay = 2 ** attempt * 1000 + Math.random() * 1000;
logger.warn("Rate limited, retrying", {
operation: context.operation,
itemId: context.itemId,
attempt,
delay: Math.round(delay),
});
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
throw new Error(`Failed after ${MAX_RETRIES} attempts`);
}
// Usage
const result = await fetchWithRetry(
() => externalApi.getResource(resourceId),
{ operation: "getResource", itemId: resourceId }
);
```
### Backoff Configuration
```typescript
interface RetryConfig {
maxRetries: number;
baseDelay: number; // Base delay in ms
maxDelay: number; // Maximum delay cap
jitterFactor: number; // Random jitter (0-1)
}
const defaultConfig: RetryConfig = {
maxRetries: 3,
baseDelay: 1000,
maxDelay: 30000,
jitterFactor: 0.5,
};
function calculateDelay(attempt: number, config: RetryConfig): number {
const exponentialDelay = config.baseDelay * 2 ** (attempt - 1);
const cappedDelay = Math.min(exponentialDelay, config.maxDelay);
const jitter = cappedDelay * config.jitterFactor * Math.random();
return cappedDelay + jitter;
}
```
## Redis Caching (Cache-Aside Pattern)
Implement caching for expensive operations.
```typescript
import { redis } from "../../../lib/redis";
import { SpanPrefix, span } from "../../../lib/tracer";
const CACHE_TTL = 3600; // 1 hour in seconds
interface CachedUserProfile {
id: string;
name: string;
preferences: Record<string, unknown>;
}
async function getUserProfile(userId: string): Promise<CachedUserProfile> {
const cacheKey = `user:profile:${userId}`;
// 1. Try cache first
const cached = await span(
`${SpanPrefix.Redis}GetUserProfile`,
async () => {
const data = await redis.get<string>(cacheKey);
return data ? JSON.parse(data) as CachedUserProfile : null;
},
{ userId }
);
if (cached) {
return cached;
}
// 2. Cache miss - fetch from database
const profile = await span(
`${SpanPrefix.DB}FetchUserProfile`,
() => db.query.user.findFirst({
where: eq(userTable.id, userId),
with: { preferences: true },
}),
{ userId }
);
if (!profile) {
throw new ORPCError("NOT_FOUND", { message: "User not found" });
}
const cacheValue: CachedUserProfile = {
id: profile.id,
name: profile.name,
preferences: profile.preferences,
};
// 3. Store in cache
await span(
`${SpanPrefix.Redis}SetUserProfile`,
() => redis.set(cacheKey, JSON.stringify(cacheValue), { ex: CACHE_TTL }),
{ userId }
);
return cacheValue;
}
```
### Cache Invalidation
```typescript
async function updateUserProfile(
userId: string,
updates: Partial<UserProfile>
): Promise<void> {
// 1. Update database
await db.update(userTable)
.set(updates)
.where(eq(userTable.id, userId));
// 2. Invalidate cache
const cacheKey = `user:profile:${userId}`;
await redis.del(cacheKey);
logger.info("User profile updated and cache invalidated", { userId });
}
```
### Cache Key Patterns
```typescript
// User-specific data
`user:profile:${userId}`
`user:settings:${userId}`
`user:orders:${userId}:page:${page}`
// Resource-specific data
`product:${productId}`
`inventory:${warehouseId}:${productId}`
// Aggregated data
`stats:daily:${date}`
`leaderboard:${category}`
```
## Background Tasks with Distributed Locks
Prevent duplicate processing in distributed environments.
```typescript
const LOCK_KEY = "task:process-orders";
const LOCK_TTL = 300; // 5 minutes
async function processScheduledOrders(): Promise<void> {
// 1. Try to acquire lock
const lockResult = await redis.set(LOCK_KEY, Date.now(), {
ex: LOCK_TTL,
nx: true, // Only set if not exists
});
if (!lockResult) {
logger.info("Another instance is processing orders, skipping");
return;
}
try {
// 2. Process with lock held
logger.info("Acquired lock, processing scheduled orders");
const pendingOrders = await db
.select()
.from(orderTable)
.where(and(
eq(orderTable.status, "SCHEDULED"),
lte(orderTable.scheduledAt, new Date())
))
.limit(100);
for (const order of pendingOrders) {
await processOrder(order);
}
logger.info("Scheduled orders processed", {
count: pendingOrders.length
});
} finally {
// 3. Release lock
await redis.del(LOCK_KEY);
}
}
```
### Lock with Heartbeat
For long-running tasks, extend the lock periodically:
```typescript
async function processLongRunningTask(): Promise<void> {
const LOCK_KEY = "task:long-running";
const LOCK_TTL = 30;
const HEARTBEAT_INTERVAL = 10000; // 10 seconds
const lockResult = await redis.set(LOCK_KEY, Date.now(), {
ex: LOCK_TTL,
nx: true,
});
if (!lockResult) {
return;
}
// Heartbeat to extend lock
const heartbeat = setInterval(async () => {
await redis.expire(LOCK_KEY, LOCK_TTL);
}, HEARTBEAT_INTERVAL);
try {
await doExpensiveWork();
} finally {
clearInterval(heartbeat);
await redis.del(LOCK_KEY);
}
}
```
## Batch Processing Patterns
### Chunked Processing
For large datasets, process in chunks:
```typescript
const CHUNK_SIZE = 100;
async function processAllOrders(orderIds: string[]): Promise<void> {
// Split into chunks
const chunks: string[][] = [];
for (let i = 0; i < orderIds.length; i += CHUNK_SIZE) {
chunks.push(orderIds.slice(i, i + CHUNK_SIZE));
}
logger.info("Processing orders in chunks", {
totalOrders: orderIds.length,
chunkCount: chunks.length,
chunkSize: CHUNK_SIZE,
});
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
if (!chunk) continue;
await processOrderChunk(chunk);
logger.info("Chunk processed", {
chunkIndex: i + 1,
totalChunks: chunks.length,
});
}
}
async function processOrderChunk(orderIds: string[]): Promise<void> {
// Batch database query
const orders = await db
.select()
.from(orderTable)
.where(inArray(orderTable.id, orderIds));
// Parallel processing with concurrency limit
const limiter = pLimit(10);
await Promise.all(
orders.map(order => limiter(() => processOrder(order)))
);
}
```
### Progress Reporting
Track and report progress for long operations:
```typescript
interface ProgressTracker {
total: number;
processed: number;
failed: number;
startTime: number;
}
async function batchProcessWithProgress(
items: string[],
progressCallback?: (progress: ProgressTracker) => void
): Promise<void> {
const progress: ProgressTracker = {
total: items.length,
processed: 0,
failed: 0,
startTime: Date.now(),
};
const UPDATE_INTERVAL = 20; // Report every 20 items
for (const item of items) {
try {
await processItem(item);
progress.processed++;
} catch {
progress.failed++;
}
// Report progress periodically
if ((progress.processed + progress.failed) % UPDATE_INTERVAL === 0) {
progressCallback?.(progress);
logger.info("Batch progress", {
processed: progress.processed,
failed: progress.failed,
total: progress.total,
elapsedMs: Date.now() - progress.startTime,
});
}
}
}
```
## Memory Optimization
### Streaming Large Datasets
For very large datasets, use streaming:
```typescript
async function* streamOrders(userId: string): AsyncGenerator<Order> {
let cursor: string | undefined;
const PAGE_SIZE = 100;
while (true) {
const orders = await db
.select()
.from(orderTable)
.where(and(
eq(orderTable.userId, userId),
cursor ? gt(orderTable.id, cursor) : undefined
))
.orderBy(asc(orderTable.id))
.limit(PAGE_SIZE);
if (orders.length === 0) {
break;
}
for (const order of orders) {
yield order;
}
const lastOrder = orders[orders.length - 1];
cursor = lastOrder?.id;
if (orders.length < PAGE_SIZE) {
break;
}
}
}
// Usage
for await (const order of streamOrders(userId)) {
await processOrder(order);
}
```

View File

@@ -0,0 +1,81 @@
# Pre-commit Checklist
Run through this checklist before committing backend code.
## Type Safety
- [ ] **No non-null assertions (`!`)** - Use local variables and conditionals for type narrowing
- [ ] **All API inputs have Zod schemas** - Defined in `types.ts`
- [ ] **All API outputs have Zod schemas** - Including `success` and `reason` fields
- [ ] **Enums imported from `@your-app/utils`** - Not from database package
## Database Operations
- [ ] **No `await` in loops** - Use `inArray` for batch queries
- [ ] **Batch inserts used** - Not individual inserts in loops
- [ ] **Conflict handling considered** - Use `onConflictDoUpdate` when appropriate
- [ ] **JSON columns cast properly** - `::jsonb` for jsonb functions
- [ ] **Raw SQL column names quoted** - Double quotes for camelCase columns
## Logging
- [ ] **No `console.log`** - Use `logger` from `@your-app/logs`
- [ ] **Structured logging used** - Pass objects, not string interpolation
- [ ] **Errors logged with context** - Include relevant IDs and stack traces
- [ ] **Sensitive data excluded** - No passwords, tokens, or PII in logs
## Performance
- [ ] **Parallel execution where possible** - Use `Promise.all` for independent operations
- [ ] **Concurrency control for external APIs** - Use `p-limit` for rate-limited APIs
- [ ] **Retry logic for rate limits** - Exponential backoff implemented
- [ ] **Caching considered** - For expensive or frequently accessed data
## Error Handling
- [ ] **Errors properly caught and logged** - With Sentry context when applicable
- [ ] **Appropriate error codes returned** - `NOT_FOUND`, `FORBIDDEN`, `BAD_REQUEST`, etc.
- [ ] **Batch operations handle partial failures** - Return detailed error information
## Code Organization
- [ ] **Code in correct location** - Procedures, lib, types in right directories
- [ ] **Reusable logic extracted** - Shared code in `lib/` directory
- [ ] **Naming conventions followed** - Schemas, types, functions named correctly
## Quick Reference
### Response Format
```typescript
return {
success: true,
reason: "Operation completed successfully",
// additional fields
};
```
### Batch Query Pattern
```typescript
const items = await db
.select()
.from(itemTable)
.where(inArray(itemTable.parentId, parentIds));
const itemsByParent = groupBy(items, "parentId");
```
### Logging Pattern
```typescript
logger.info("Operation completed", {
operationId,
userId,
itemCount: items.length,
});
```
### Error Pattern
```typescript
if (!resource) {
throw new ORPCError("NOT_FOUND", { message: "Resource not found" });
}
```

View File

@@ -0,0 +1,316 @@
# Type Safety Guidelines
This document covers TypeScript best practices and type safety patterns for backend development.
## Critical Rules
### 1. NO Non-null Assertions (`!`)
Never use the non-null assertion operator (`!`). It bypasses TypeScript's null checking and can lead to runtime errors.
```typescript
// BAD - Non-null assertion
const user = users.find(u => u.id === id);
await processUser(user!); // Dangerous!
// GOOD - Use local variable for type narrowing
const user = users.find(u => u.id === id);
if (!user) {
return { success: false, reason: "User not found" };
}
// TypeScript now knows user is defined
await processUser(user);
```
**Why this matters:**
- Non-null assertions (`!`) tell TypeScript to trust you, but runtime doesn't care
- If the value is actually `null` or `undefined`, you get a runtime crash
- Local variable narrowing is verifiable at both compile-time and runtime
### 2. All Inputs/Outputs Must Have Zod Schemas
Every API endpoint must define explicit input and output schemas using Zod.
```typescript
// types.ts
import { z } from "zod";
// Input Schema
export const updateUserInputSchema = z.object({
userId: z.string(),
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
settings: z.object({
notifications: z.boolean(),
theme: z.enum(["light", "dark"]),
}).optional(),
});
// Output Schema
export const updateUserOutputSchema = z.object({
success: z.boolean(),
reason: z.string(),
user: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}).optional(),
});
// Type exports (inferred from schemas)
export type UpdateUserInput = z.infer<typeof updateUserInputSchema>;
export type UpdateUserOutput = z.infer<typeof updateUserOutputSchema>;
```
**Using schemas in procedures:**
```typescript
// procedures/update.ts
export const updateUser = protectedProcedure
.route({
method: "PATCH",
path: "/users/:userId",
tags: ["Users"],
summary: "Update user profile",
})
.input(updateUserInputSchema)
.output(updateUserOutputSchema)
.handler(async ({ input, context }) => {
// input is fully typed as UpdateUserInput
const { userId, name, email, settings } = input;
// ... implementation
return {
success: true,
reason: "User updated successfully",
user: { id: userId, name: updatedName, email: updatedEmail },
};
});
```
### 3. Import Enums from Shared Utils Package
**Never import enums directly from the database package.** The database package includes the PostgreSQL client, which can cause issues in certain environments (edge runtime, client-side code).
```typescript
// BAD - Imports database client as side effect
import { messageCategoryEnum } from "@your-app/database/drizzle/schema/postgres";
// GOOD - Import from utils (no database client dependency)
import {
messageCategoryZodSchema,
type MessageCategory,
MESSAGE_CATEGORY_VALUES,
} from "@your-app/utils";
```
**How enums are organized in the utils package:**
```typescript
// packages/utils/lib/enum-types.ts
import { z } from "zod";
// Import enum values from schema (not the full database package)
import {
statusEnum
} from "@your-app/database/drizzle/schema";
// Export as Zod schema and TypeScript type
export const ORDER_STATUS_VALUES = statusEnum.enumValues;
export const orderStatusZodSchema = z.enum(ORDER_STATUS_VALUES);
export type OrderStatus = z.infer<typeof orderStatusZodSchema>;
```
### 4. Standard Response Format
All API responses must include `success` and `reason` fields for consistent error handling.
```typescript
// Output schema pattern
export const operationResultSchema = z.object({
success: z.boolean(),
reason: z.string(),
// Additional fields as needed
data: z.unknown().optional(),
});
// Success response
return {
success: true,
reason: "Operation completed successfully",
data: result,
};
// Error response
return {
success: false,
reason: "Insufficient permissions to perform this action",
};
```
**Batch operation response pattern:**
```typescript
export const batchOperationResultSchema = z.object({
success: z.boolean(),
total: z.number(),
processed: z.number(),
failed: z.number(),
errors: z.array(z.object({
itemId: z.string(),
error: z.string(),
})).optional(),
});
```
## Type Narrowing Patterns
### Array Operations
```typescript
// BAD - Assumes array has elements
const firstOrder = orders[0];
await processOrder(firstOrder!);
// GOOD - Check first
const firstOrder = orders[0];
if (!firstOrder) {
return { success: false, reason: "No orders found" };
}
await processOrder(firstOrder);
```
### Optional Chaining with Fallback
```typescript
// BAD - Non-null assertion on optional property
const userName = user.profile!.name!;
// GOOD - Safe access with fallback
const userName = user.profile?.name ?? "Unknown";
// GOOD - When value is required, validate first
const profile = user.profile;
if (!profile?.name) {
throw new ORPCError("BAD_REQUEST", { message: "Profile name is required" });
}
const userName = profile.name;
```
### Map/Find Operations
```typescript
// BAD - Assuming find always succeeds
const account = accounts.find(a => a.id === accountId)!;
// GOOD - Handle the undefined case
const account = accounts.find(a => a.id === accountId);
if (!account) {
throw new ORPCError("NOT_FOUND", { message: "Account not found" });
}
// account is now guaranteed to be defined
```
## Zod Schema Best Practices
### Reusable Base Schemas
```typescript
// Define reusable schemas
const paginationSchema = z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
});
const timestampSchema = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
// Compose into larger schemas
export const listOrdersInputSchema = paginationSchema.extend({
status: orderStatusZodSchema.optional(),
customerId: z.string().optional(),
});
export const orderSchema = z.object({
id: z.string(),
status: orderStatusZodSchema,
total: z.number(),
}).merge(timestampSchema);
```
### Discriminated Unions
```typescript
// For polymorphic responses
export const notificationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("email"),
recipient: z.string().email(),
subject: z.string(),
}),
z.object({
type: z.literal("sms"),
phoneNumber: z.string(),
message: z.string(),
}),
z.object({
type: z.literal("push"),
deviceToken: z.string(),
title: z.string(),
body: z.string(),
}),
]);
```
### Transform and Refine
```typescript
// Transform input data
export const createProductInputSchema = z.object({
name: z.string().transform(s => s.trim()),
price: z.string().transform(s => parseFloat(s)),
tags: z.string().transform(s => s.split(",").map(t => t.trim())),
});
// Add custom validation
export const dateRangeSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
}).refine(
data => new Date(data.endDate) > new Date(data.startDate),
{ message: "End date must be after start date" }
);
```
## Error Handling Types
Use typed errors with oRPC:
```typescript
import { ORPCError } from "@orpc/server";
// Standard error codes
throw new ORPCError("NOT_FOUND", { message: "Resource not found" });
throw new ORPCError("FORBIDDEN", { message: "Access denied" });
throw new ORPCError("BAD_REQUEST", { message: "Invalid input" });
throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" });
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Unexpected error" });
```
## Type Inference Helpers
```typescript
// Infer types from Drizzle tables
type User = typeof userTable.$inferSelect;
type NewUser = typeof userTable.$inferInsert;
// Infer from Zod schemas
type CreateOrderInput = z.infer<typeof createOrderInputSchema>;
// Utility types for partial updates
type UpdateOrderInput = Partial<Omit<CreateOrderInput, "id">>;
```

View File

@@ -0,0 +1,34 @@
# Common Issues and Solutions
> Documented pitfalls discovered while building production Next.js fullstack applications.
> These issues apply to any project using Next.js with PostgreSQL, Drizzle ORM, Tailwind CSS, and i18n.
## Severity Levels
| Level | Description |
| -------- | ---------------------------------------------------- |
| Critical | Build fails or data corruption |
| Warning | Degraded experience, workaround exists |
| Info | Minor visual issue, easy to fix once identified |
---
## Issue Index
| Issue | Category | Severity |
| ---------------------------------------------------------------- | ---------------- | -------- |
| [postgres-json-jsonb.md](./postgres-json-jsonb.md) | Database/ORM | Critical |
| [sentry-nextintl-conflict.md](./sentry-nextintl-conflict.md) | Plugin Conflicts | Critical |
| [turbopack-webpack-flexbox.md](./turbopack-webpack-flexbox.md) | Build System | Warning |
| [webkit-tap-highlight.md](./webkit-tap-highlight.md) | Mobile/CSS | Info |
---
## How to Contribute
Found a new pitfall? Add it to this directory:
1. Create a new `.md` file with a descriptive kebab-case name
2. Follow the existing format: Problem, Root Cause, Solution, Key Takeaways
3. Update this index table with the correct category and severity
4. Include reproducible code examples whenever possible

View File

@@ -0,0 +1,192 @@
# PostgreSQL JSON vs JSONB Type Issues with Drizzle ORM
## Problem
Database queries using PostgreSQL's `jsonb_*` functions fail with type errors, even though the column appears to store JSON data correctly.
**Error message:**
```
function jsonb_array_elements(json) does not exist
HINT: No function matches the given name and argument types.
You might need to add explicit type casts.
```
**Additional symptoms:**
- Raw SQL queries fail to find columns with camelCase names
- JSON aggregation functions return unexpected results
## Root Cause
### Issue 1: Drizzle's `json()` Maps to PostgreSQL `json`, Not `jsonb`
When defining a JSON column in Drizzle ORM:
```typescript
// Drizzle schema definition
export const orders = pgTable("orders", {
id: text("id").primaryKey(),
metadata: json("metadata"), // Creates PostgreSQL 'json' type, NOT 'jsonb'
});
```
PostgreSQL has two JSON types with different characteristics:
| Feature | `json` | `jsonb` |
|---------|--------|---------|
| Storage | Text (preserves whitespace, key order) | Binary (normalized) |
| Functions | `json_*` functions only | `jsonb_*` functions only |
| Indexing | Limited | GIN indexes supported |
| Performance | Slower for operations | Faster for operations |
The `jsonb_*` functions (like `jsonb_array_elements`, `jsonb_extract_path`) **only work with `jsonb` type**.
### Issue 2: Column Name Case Sensitivity
PostgreSQL treats unquoted identifiers as lowercase. If your column uses camelCase:
```sql
-- This fails (looks for column named 'metadata' in lowercase)
SELECT metadata->>'userId' FROM orders;
-- Column is actually named "metaData" with exact case
SELECT "metaData"->>'userId' FROM orders;
```
## Solution
### Solution 1: Use Type Cast for jsonb Functions
Add `::jsonb` type cast before using jsonb functions:
```typescript
// Before (fails)
const result = await db.execute(sql`
SELECT jsonb_array_elements(items) as item
FROM orders
WHERE id = ${orderId}
`);
// After (works)
const result = await db.execute(sql`
SELECT jsonb_array_elements(items::jsonb) as item
FROM orders
WHERE id = ${orderId}
`);
```
### Solution 2: Define Column as `jsonb` in Schema
If you need jsonb functionality frequently, define the column as jsonb:
```typescript
import { pgTable, text, jsonb } from "drizzle-orm/pg-core";
export const orders = pgTable("orders", {
id: text("id").primaryKey(),
metadata: jsonb("metadata"), // Now uses PostgreSQL 'jsonb' type
});
```
**Note:** This requires a migration if the column already exists.
### Solution 3: Quote camelCase Column Names in Raw SQL
Always use double quotes for camelCase column names:
```typescript
// Before (fails - column not found)
const result = await db.execute(sql`
SELECT "userId", createdAt
FROM orders
`);
// After (works)
const result = await db.execute(sql`
SELECT "userId", "createdAt"
FROM orders
`);
```
### Complete Example: Querying JSON Array Data
```typescript
import { sql } from "drizzle-orm";
// Table with json column storing an array of items
// items: [{ "productId": "123", "quantity": 2 }, ...]
// Query to find orders containing a specific product
async function findOrdersWithProduct(productId: string) {
const result = await db.execute(sql`
SELECT
o.id,
o."createdAt",
item->>'productId' as "productId",
(item->>'quantity')::int as quantity
FROM orders o,
jsonb_array_elements(o.items::jsonb) as item
WHERE item->>'productId' = ${productId}
`);
return result.rows;
}
// Query to aggregate JSON array data
async function getOrderItemStats(orderId: string) {
const result = await db.execute(sql`
SELECT
COUNT(*) as "itemCount",
SUM((item->>'quantity')::int) as "totalQuantity"
FROM orders o,
jsonb_array_elements(o.items::jsonb) as item
WHERE o.id = ${orderId}
`);
return result.rows[0];
}
```
### Best Practice: Create a Helper for JSON Queries
```typescript
// utils/db-helpers.ts
import { sql, SQL } from "drizzle-orm";
/**
* Wraps a column reference with ::jsonb cast for use with jsonb functions
*/
export function asJsonb(column: SQL | string): SQL {
if (typeof column === "string") {
return sql.raw(`"${column}"::jsonb`);
}
return sql`${column}::jsonb`;
}
// Usage
const result = await db.execute(sql`
SELECT jsonb_array_elements(${asJsonb("items")}) as item
FROM orders
`);
```
## Key Takeaways
1. **Know the difference between `json` and `jsonb`**
- `json`: Text storage, use `json_*` functions
- `jsonb`: Binary storage, use `jsonb_*` functions, better performance
2. **Drizzle's `json()` creates PostgreSQL `json` type** - use `jsonb()` if you need jsonb functionality
3. **Always add `::jsonb` cast** when using jsonb functions with json columns
4. **Quote camelCase identifiers** in raw SQL queries with double quotes
5. **Prefer Drizzle's query builder** over raw SQL when possible to avoid these issues
6. **Test raw SQL queries** directly in a PostgreSQL client before using in code
## Related Resources
- [PostgreSQL JSON Types Documentation](https://www.postgresql.org/docs/current/datatype-json.html)
- [PostgreSQL JSON Functions](https://www.postgresql.org/docs/current/functions-json.html)
- [Drizzle ORM PostgreSQL Column Types](https://orm.drizzle.team/docs/column-types/pg)

View File

@@ -0,0 +1,223 @@
# Sentry and next-intl Build Configuration Conflict
## Problem
Production builds fail with the error:
```
Error: Couldn't find next-intl config file
```
or
```
Error: Failed to collect page data for /[locale]/page
```
The build works fine in development mode but fails during `next build`.
## Root Cause
The `withSentryConfig` wrapper in `next.config.js` interferes with other Next.js plugins, particularly `next-intl`'s plugin (`createNextIntlPlugin`).
### Technical Details
When you chain multiple config wrappers:
```javascript
// next.config.js - Problematic configuration
import { withSentryConfig } from "@sentry/nextjs";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig = {
// your config
};
// This chaining causes conflicts
export default withSentryConfig(withNextIntl(nextConfig), {
// Sentry options
});
```
The issue occurs because:
1. **Plugin execution order matters** - Sentry's wrapper modifies the webpack configuration in ways that can break other plugins' assumptions
2. **Build-time vs runtime** - Some plugins expect to run at specific build phases
3. **Config mutation** - Wrappers may mutate the config object in incompatible ways
Specifically, `withSentryConfig`:
- Modifies webpack configuration extensively
- Adds custom loaders and plugins
- May interfere with `next-intl`'s message loading mechanism
## Solution
### Solution 1: Remove withSentryConfig Wrapper (Recommended)
Sentry's runtime features still work via `instrumentation.ts` without the config wrapper:
```javascript
// next.config.js - Fixed configuration
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig = {
// your config
};
// Only use next-intl wrapper, no Sentry wrapper
export default withNextIntl(nextConfig);
```
**Why Sentry still works:**
The `withSentryConfig` wrapper is primarily for:
- Source map uploading
- Build-time instrumentation
- Release management
However, Sentry's core error tracking works through `instrumentation.ts`:
```typescript
// instrumentation.ts
import * as Sentry from "@sentry/nextjs";
export function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
// ... other options
});
}
if (process.env.NEXT_RUNTIME === "edge") {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
});
}
}
```
### Solution 2: Use Sentry Without Source Maps
If you need some Sentry build features but want to avoid conflicts:
```javascript
// next.config.js
import { withSentryConfig } from "@sentry/nextjs";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig = {
// your config
};
// Apply next-intl first
const configWithIntl = withNextIntl(nextConfig);
// Conditionally apply Sentry only if not causing issues
const finalConfig = process.env.SKIP_SENTRY_BUILD
? configWithIntl
: withSentryConfig(configWithIntl, {
silent: true,
disableSourceMapUpload: true, // Disable problematic feature
});
export default finalConfig;
```
### Solution 3: Alternative Plugin Order
Sometimes reversing the wrapper order helps:
```javascript
// Try applying Sentry first, then next-intl
const configWithSentry = withSentryConfig(nextConfig, sentryOptions);
export default withNextIntl(configWithSentry);
```
**Note:** This may or may not work depending on your specific versions.
### Solution 4: Separate Sentry Configuration
Use Sentry CLI for source map upload instead of the webpack plugin:
```bash
# In your CI/CD pipeline after build
npx @sentry/cli sourcemaps upload ./next/static --org your-org --project your-project
```
```javascript
// next.config.js - Clean configuration
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig = {
productionBrowserSourceMaps: true, // Enable source maps for Sentry CLI
};
export default withNextIntl(nextConfig);
```
## Verification Steps
After applying the fix:
1. **Clean build artifacts:**
```bash
rm -rf .next node_modules/.cache
```
2. **Test production build:**
```bash
pnpm build
```
3. **Verify Sentry works in production:**
```bash
pnpm start
# Trigger a test error and check Sentry dashboard
```
4. **Test i18n routing:**
```bash
# Visit different locale routes
curl http://localhost:3000/en/page
curl http://localhost:3000/de/page
```
## Key Takeaways
1. **Plugin wrappers can conflict** - Be cautious when combining multiple Next.js config wrappers
2. **Sentry works without withSentryConfig** - Core error tracking functions via `instrumentation.ts`
3. **Order matters** - Try different wrapper orders if you must use multiple plugins
4. **Source maps are optional** - You can still get stack traces without source map upload
5. **Test builds locally** - Always run `pnpm build` before deploying
6. **Keep dependencies updated** - Plugin compatibility issues are often fixed in newer versions
## Version Information
This issue was observed with:
- Next.js 14.x / 15.x
- @sentry/nextjs 7.x / 8.x
- next-intl 3.x
Check the respective changelogs for compatibility updates.
## Related Resources
- [Sentry Next.js SDK Documentation](https://docs.sentry.io/platforms/javascript/guides/nextjs/)
- [next-intl Plugin Documentation](https://next-intl-docs.vercel.app/docs/getting-started/app-router)
- [Next.js Configuration](https://nextjs.org/docs/app/api-reference/next-config-js)

View File

@@ -0,0 +1,141 @@
# Turbopack vs Webpack Flexbox Layout Differences
## Problem
Layout works correctly in development mode (Turbopack) but breaks in production (Webpack). Specifically, flex containers and their children behave differently between the two bundlers.
**Symptoms:**
- Components have correct height in dev mode but collapse or overflow in production
- Scrollable areas work in dev but fail in prod
- Nested flex layouts display differently between environments
## Root Cause
Turbopack (Next.js dev mode default) and Webpack (production build) have subtle differences in how they process CSS, particularly regarding flexbox behavior:
1. **Turbopack is stricter** about explicit flexbox properties
2. **Webpack may auto-infer** certain flex child behaviors that Turbopack does not
3. The difference lies in how CSS is compiled and applied, not in the CSS specification itself
### Technical Details
When a flex container has `flex-direction: column` and children that need to fill available space, the behavior depends on:
- The `align-items` property (defaults to `stretch` but may not be consistently applied)
- Whether children have explicit `height` or `flex` properties
- The interaction between nested flex containers
**Example of problematic layout:**
```tsx
// Parent component
<div className="flex flex-col h-screen">
<Header /> {/* Fixed height */}
<main className="flex-1 flex"> {/* Should fill remaining space */}
<Sidebar />
<Content /> {/* Should scroll internally */}
</main>
</div>
```
In Turbopack, the `main` element might not properly pass its height to children without explicit `items-stretch`.
## Solution
### 1. Explicitly Set `items-stretch` on Flex Containers
Add `items-stretch` to main flex containers that need children to fill available space:
```tsx
// Before (inconsistent between Turbopack/Webpack)
<div className="flex flex-col h-screen">
<main className="flex-1 flex">
{/* children */}
</main>
</div>
// After (consistent behavior)
<div className="flex flex-col h-screen items-stretch">
<main className="flex-1 flex items-stretch">
{/* children */}
</main>
</div>
```
### 2. Apply Parent/Child Responsibility Separation
Follow a clear pattern for layout responsibilities:
**Parent's Responsibility:**
- Define the flex container (`flex`, `flex-col`, `flex-row`)
- Set alignment (`items-stretch`, `justify-between`)
- Control overall dimensions (`h-screen`, `w-full`)
**Child's Responsibility:**
- Define its own flex behavior (`flex-1`, `flex-shrink-0`)
- Handle internal overflow (`overflow-auto`, `overflow-hidden`)
- Set min/max constraints (`min-h-0`, `max-w-full`)
### 3. Use `min-h-0` for Scrollable Flex Children
When a flex child needs internal scrolling:
```tsx
<div className="flex flex-col h-full items-stretch">
<div className="flex-shrink-0">Fixed Header</div>
<div className="flex-1 min-h-0 overflow-auto">
{/* Scrollable content */}
</div>
</div>
```
The `min-h-0` is crucial because flex items default to `min-height: auto`, which can prevent overflow from working correctly.
### Complete Example
```tsx
// App layout with consistent dev/prod behavior
function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-col h-screen items-stretch">
{/* Fixed navigation */}
<nav className="flex-shrink-0 h-16 border-b">
<Navigation />
</nav>
{/* Main content area */}
<div className="flex-1 flex items-stretch min-h-0">
{/* Sidebar */}
<aside className="w-64 flex-shrink-0 border-r overflow-auto">
<SidebarContent />
</aside>
{/* Main content with internal scroll */}
<main className="flex-1 min-w-0 overflow-auto">
{children}
</main>
</div>
</div>
);
}
```
## Key Takeaways
1. **Always test production builds locally** before deployment using `pnpm build && pnpm start`
2. **Be explicit with flexbox properties** - Don't rely on browser defaults or bundler behavior
3. **Use `items-stretch` explicitly** on containers where children need to fill space
4. **Remember `min-h-0` and `min-w-0`** for scrollable flex children
5. **Separate layout responsibilities** between parent (container behavior) and child (self behavior)
6. **Document layout patterns** in your project to ensure consistency across the team
## Related Resources
- [CSS Flexbox Guide](https://css-tricks.com/snippets/css/a-guide-to-flexbox/)
- [Next.js Turbopack Documentation](https://nextjs.org/docs/architecture/turbopack)
- [Tailwind CSS Flexbox Utilities](https://tailwindcss.com/docs/flex)

View File

@@ -0,0 +1,218 @@
# WebKit Tap Highlight and Border-Radius Issues on Mobile
## Problem
Buttons and interactive elements lose their `border-radius` styling when tapped on mobile devices (iOS Safari, Chrome on iOS). The element briefly shows a rectangular highlight instead of respecting the rounded corners.
**Symptoms:**
- Button appears with sharp corners during tap/touch
- A blue or gray rectangular overlay flashes on touch
- The visual glitch only occurs on WebKit-based mobile browsers
- Desktop browsers and Android Chrome don't show the issue
## Root Cause
WebKit browsers apply a default tap highlight effect to interactive elements. This highlight:
1. **Ignores `border-radius`** - The highlight is applied as a simple rectangular overlay
2. **Uses system default color** - Typically a semi-transparent blue or gray
3. **Overrides visual styling** - The highlight appears on top of your custom styles
### Technical Details
When you tap an element on iOS Safari:
```css
/* WebKit's default behavior (pseudo-representation) */
element:active {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
/* This creates a RECTANGULAR overlay, ignoring border-radius */
}
```
The tap highlight is rendered as a separate layer that doesn't respect the element's `border-radius`, `clip-path`, or other shape-defining properties.
## Solution
### Solution 1: Disable Tap Highlight + Wrapper with Overflow Hidden
The most reliable solution combines two techniques:
```tsx
// Button component with proper mobile touch handling
function Button({ children, className, ...props }: ButtonProps) {
return (
<div className="rounded-lg overflow-hidden inline-block">
<button
className={cn("rounded-lg px-4 py-2 bg-blue-500 text-white", className)}
style={{ WebkitTapHighlightColor: "transparent" }}
{...props}
>
{children}
</button>
</div>
);
}
```
**Why this works:**
1. `WebkitTapHighlightColor: "transparent"` removes the default highlight
2. The wrapper `div` with `overflow-hidden` clips any remaining visual artifacts
3. Both elements have matching `border-radius` for consistent appearance
### Solution 2: CSS-Only Approach
If you can't modify the component structure:
```css
/* In your global CSS */
.tap-safe {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
/* Custom active state to replace the highlight */
.tap-safe:active {
opacity: 0.8;
transform: scale(0.98);
}
```
```tsx
<button className="tap-safe rounded-lg px-4 py-2 bg-blue-500">
Click me
</button>
```
### Solution 3: Tailwind CSS Utility Class
Add a reusable utility in your Tailwind config:
```javascript
// tailwind.config.js
module.exports = {
theme: {
extend: {},
},
plugins: [
function({ addUtilities }) {
addUtilities({
'.tap-highlight-none': {
'-webkit-tap-highlight-color': 'transparent',
},
});
},
],
};
```
Then use it in components:
```tsx
<button className="tap-highlight-none rounded-lg px-4 py-2">
Click me
</button>
```
### Solution 4: Wrapper Component for Consistent Behavior
Create a reusable wrapper for all interactive rounded elements:
```tsx
// components/ui/touch-safe-wrapper.tsx
interface TouchSafeWrapperProps {
children: React.ReactNode;
className?: string;
borderRadius?: string;
}
export function TouchSafeWrapper({
children,
className,
borderRadius = "rounded-lg"
}: TouchSafeWrapperProps) {
return (
<div className={cn(borderRadius, "overflow-hidden inline-flex", className)}>
{children}
</div>
);
}
// Usage
<TouchSafeWrapper>
<button
className="rounded-lg px-4 py-2 bg-blue-500"
style={{ WebkitTapHighlightColor: "transparent" }}
>
Click me
</button>
</TouchSafeWrapper>
```
### Complete Example: Card with Clickable Areas
```tsx
function ProductCard({ product }: { product: Product }) {
return (
<div className="rounded-xl border p-4">
<h3>{product.name}</h3>
<p>{product.description}</p>
{/* Action buttons with tap-safe handling */}
<div className="flex gap-2 mt-4">
<div className="rounded-lg overflow-hidden">
<button
className="rounded-lg px-4 py-2 bg-blue-500 text-white"
style={{ WebkitTapHighlightColor: "transparent" }}
onClick={() => addToCart(product)}
>
Add to Cart
</button>
</div>
<div className="rounded-lg overflow-hidden">
<button
className="rounded-lg px-4 py-2 border border-gray-300"
style={{ WebkitTapHighlightColor: "transparent" }}
onClick={() => viewDetails(product)}
>
Details
</button>
</div>
</div>
</div>
);
}
```
## Key Takeaways
1. **WebKit tap highlight ignores border-radius** - This is browser behavior, not a CSS bug
2. **Always set `WebkitTapHighlightColor: "transparent"`** on interactive elements with rounded corners
3. **Use a wrapper with `overflow-hidden`** for the most reliable visual clipping
4. **Test on actual iOS devices** - Simulators and browser dev tools may not reproduce the issue
5. **Consider adding custom active states** to replace the removed tap feedback for better UX
6. **Create reusable components** that handle mobile touch behavior consistently
## Browser Support Notes
| Browser | Needs Fix |
|---------|-----------|
| iOS Safari | Yes |
| Chrome on iOS | Yes (uses WebKit) |
| Firefox on iOS | Yes (uses WebKit) |
| Android Chrome | Usually no |
| Desktop browsers | No |
## Related Resources
- [MDN: -webkit-tap-highlight-color](https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-tap-highlight-color)
- [WebKit Bug Tracker](https://bugs.webkit.org/)
- [CSS Tricks: Handling Touch Events](https://css-tricks.com/snippets/css/remove-gray-highlight-when-tapping-links-in-mobile-safari/)

View File

@@ -0,0 +1,255 @@
# AI SDK Frontend Integration
## 1. Overview
This guide covers frontend integration with the Vercel AI SDK using `@ai-sdk/react`. Key topics include:
- Using `@ai-sdk/react` for React integration
- Streaming chat with the `useChat` hook
- Tool call handling with proper format detection
## 2. Basic Chat with useChat
The `useChat` hook provides a simple interface for chat functionality:
```typescript
"use client";
import { useChat } from "@ai-sdk/react";
export function ChatPanel() {
const { messages, input, handleInputChange, handleSubmit, status } = useChat({
api: "/api/chat",
});
return (
<div>
{messages.map((message) => (
<div key={message.id}>
<strong>{message.role}:</strong> {message.content}
</div>
))}
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="Type a message..."
disabled={status === "streaming"}
/>
<button type="submit" disabled={status === "streaming"}>
Send
</button>
</form>
</div>
);
}
```
## 3. Custom Transport with oRPC
When using oRPC instead of standard fetch:
```typescript
import { useChat } from "@ai-sdk/react";
import { eventIteratorToStream } from "@orpc/client";
import { orpcClient } from "@/lib/orpc-client";
export function ChatPanel({ sessionId }: { sessionId: string }) {
const { messages, sendMessage, status } = useChat({
id: sessionId,
transport: {
async sendMessages(options) {
return eventIteratorToStream(
await orpcClient.chat.send(
{
sessionId,
messages: options.messages,
},
{ signal: options.abortSignal }
)
);
},
reconnectToStream() {
throw new Error("Reconnect not supported");
},
},
});
// ... rest of component
}
```
## 4. Tool Calls Handling
**CRITICAL**: Tool calls have TWO different formats that must both be handled:
### Format 1: Real-time Streaming
During streaming, tool results appear as:
```typescript
{
type: "tool-createTask", // tool-{toolName}
toolCallId: "call_abc123",
state: "output-available",
input: { title: "...", priority: "high" },
output: { success: true, taskId: "task_xyz" } // Direct object
}
```
### Format 2: History Restore
When loading from history/database:
```typescript
{
type: "tool-result",
toolName: "createTask",
toolCallId: "call_abc123",
output: {
type: "json",
value: { success: true, taskId: "task_xyz" } // Nested in value
}
}
```
### Unified Handling Pattern
```typescript
import { useChat } from "@ai-sdk/react";
import { useEffect, useState, useRef } from "react";
export function AssistantPanel({ sessionId }: { sessionId: string }) {
const [createdItems, setCreatedItems] = useState<Map<string, CreatedItem>>(new Map());
const toolCallsRef = useRef<Map<string, string>>(new Map());
const { messages, status } = useChat({
id: sessionId,
transport: { /* ... */ },
// Handle real-time tool results
onData: (dataPart) => {
const payload = typeof dataPart === "object" && "json" in dataPart
? (dataPart as { json: unknown }).json
: dataPart;
if (typeof payload === "object" && payload !== null && "type" in payload) {
const { type, data } = payload as { type: string; data: any };
if (type === "tool-output-available" || type === "tool-result") {
const { toolCallId, output } = data;
const toolName = toolCallsRef.current.get(toolCallId);
if (toolName === "createTask" && output?.success) {
setCreatedItems((prev) => {
if (prev.has(toolCallId)) return prev;
return new Map(prev).set(toolCallId, {
id: output.taskId,
title: output.title,
});
});
}
}
}
},
});
// Handle history restore
useEffect(() => {
messages.forEach((message) => {
if (message.role !== "assistant") return;
const parts = (message as any).parts || [];
parts.forEach((part: any) => {
// Match both formats
const isRealTime = part.type === "tool-createTask" && part.state === "output-available";
const isRestored = part.type === "tool-result" && part.toolName === "createTask";
if ((isRealTime || isRestored) && part.output) {
const key = part.toolCallId || message.id;
// Extract output (handle nested structure)
const rawOutput = part.output;
const output = rawOutput?.type === "json" && rawOutput?.value
? rawOutput.value
: rawOutput;
if (output?.success) {
setCreatedItems((prev) => {
if (prev.has(key)) return prev;
return new Map(prev).set(key, {
id: output.taskId,
title: output.title,
});
});
}
}
});
});
}, [messages, status]);
return (
<div>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{/* Display created items */}
{Array.from(createdItems.values()).map((item) => (
<CreatedItemCard key={item.id} item={item} />
))}
</div>
);
}
```
## 5. Tool Call State Lifecycle
During streaming, tool parts go through these states:
| State | Description |
|-------|-------------|
| `input-streaming` | Tool input is being generated |
| `input-available` | Complete input ready |
| `output-available` | Tool executed, result available |
| `output-error` | Tool execution failed |
## 6. Displaying Thought Process
Show users what the AI is "thinking":
```typescript
const [thoughtSteps, setThoughtSteps] = useState<ThoughtStep[]>([]);
// In onData handler
if (type === "tool-input-start" || type === "tool-call") {
const { toolCallId, toolName, input } = data;
toolCallsRef.current.set(toolCallId, toolName);
setThoughtSteps((prev) => [
...prev,
{ id: toolCallId, toolName, status: "pending", input },
]);
}
if (type === "tool-output-available") {
setThoughtSteps((prev) =>
prev.map((step) =>
step.id === toolCallId
? { ...step, status: "done", result: output }
: step
)
);
}
```
## 7. Best Practices Summary
| Rule | Description |
|------|-------------|
| Handle both tool formats | Real-time and history restore |
| Use toolCallId as key | Correlate calls across formats |
| Use useRef for toolName mapping | Avoid React state timing issues |
| onData for real-time UI | useEffect for history restore |
| Show thought process | Better UX for tool-heavy flows |

View File

@@ -0,0 +1,464 @@
# API Integration
This document covers API integration patterns including oRPC client usage, real-time communication, and AI streaming.
## oRPC Client Usage
### Client Setup
```typescript
// lib/orpc.ts
import { createORPCClient } from '@your-app/api/client'; // Replace with your monorepo package path
export const orpcClient = createORPCClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL,
});
```
### Basic API Calls
```typescript
// Simple GET
const users = await orpcClient.users.list();
// GET with parameters
const user = await orpcClient.users.get({ id: userId });
// POST (create)
const newUser = await orpcClient.users.create({
name: 'John Doe',
email: 'john@example.com',
});
// PUT/PATCH (update)
const updatedUser = await orpcClient.users.update({
id: userId,
name: 'Jane Doe',
});
// DELETE
await orpcClient.users.delete({ id: userId });
```
### With React Query
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { orpcClient } from '@/lib/orpc';
// Query
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => orpcClient.users.list(),
});
}
// Mutation
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: orpcClient.users.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
```
## Query Patterns
### Pagination
```typescript
interface PaginationParams {
page: number;
pageSize: number;
}
export function usePaginatedOrders({ page, pageSize }: PaginationParams) {
return useQuery({
queryKey: ['orders', { page, pageSize }],
queryFn: () => orpcClient.orders.list({ page, pageSize }),
placeholderData: (prev) => prev, // Keep previous data while fetching
});
}
```
### Filtering and Sorting
```typescript
interface OrderFilters {
status?: string;
customerId?: string;
sortBy?: 'createdAt' | 'total';
sortOrder?: 'asc' | 'desc';
}
export function useFilteredOrders(filters: OrderFilters) {
return useQuery({
queryKey: ['orders', filters],
queryFn: () => orpcClient.orders.list(filters),
});
}
```
### Prefetching
```typescript
export function useOrdersWithPrefetch() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['orders', { page: 1 }],
queryFn: () => orpcClient.orders.list({ page: 1 }),
});
// Prefetch next page
useEffect(() => {
if (query.data?.hasNextPage) {
queryClient.prefetchQuery({
queryKey: ['orders', { page: 2 }],
queryFn: () => orpcClient.orders.list({ page: 2 }),
});
}
}, [query.data, queryClient]);
return query;
}
```
## Real-time Communication
### WebSocket with Ably
```typescript
// lib/ably.ts
import Ably from 'ably';
export const ablyClient = new Ably.Realtime({
authUrl: '/api/ably/auth',
});
// Hook for real-time updates
export function useRealtimeOrders() {
const queryClient = useQueryClient();
useEffect(() => {
const channel = ablyClient.channels.get('orders');
channel.subscribe('order:created', (message) => {
queryClient.invalidateQueries({ queryKey: ['orders'] });
});
channel.subscribe('order:updated', (message) => {
const order = message.data;
queryClient.setQueryData(['orders', order.id], order);
});
return () => {
channel.unsubscribe();
};
}, [queryClient]);
}
```
### WebSocket Connection Management
```typescript
export function useWebSocket(channelName: string) {
const [isConnected, setIsConnected] = useState(false);
const channelRef = useRef<Ably.RealtimeChannel | null>(null);
useEffect(() => {
const channel = ablyClient.channels.get(channelName);
channelRef.current = channel;
channel.on('attached', () => setIsConnected(true));
channel.on('detached', () => setIsConnected(false));
return () => {
channel.detach();
};
}, [channelName]);
const subscribe = useCallback(
(event: string, callback: (data: unknown) => void) => {
channelRef.current?.subscribe(event, (message) => {
callback(message.data);
});
},
[]
);
return { isConnected, subscribe };
}
```
## SSE/Streaming for AI Chat
### Basic SSE Pattern
```typescript
export function useAIChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const sendMessage = useCallback(async (content: string) => {
setIsStreaming(true);
// Add user message
setMessages((prev) => [
...prev,
{ role: 'user', content },
]);
// Create placeholder for assistant response
setMessages((prev) => [
...prev,
{ role: 'assistant', content: '' },
]);
try {
const response = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: content }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
setMessages((prev) => {
const updated = [...prev];
const lastMessage = updated[updated.length - 1];
lastMessage.content += chunk;
return updated;
});
}
} finally {
setIsStreaming(false);
}
}, []);
return { messages, sendMessage, isStreaming };
}
```
### Using Vercel AI SDK
```typescript
import { useChat } from 'ai/react';
export function useAIChatWithSDK() {
const {
messages,
input,
handleInputChange,
handleSubmit,
isLoading,
error,
} = useChat({
api: '/api/ai/chat',
onFinish: (message) => {
// Handle completed message
},
});
return {
messages,
input,
handleInputChange,
handleSubmit,
isLoading,
error,
};
}
```
## AI Tool Calls Handling
AI responses may include tool calls (function calls). The format differs between real-time streaming and history restore.
### Real-time Streaming Format
During streaming, tool calls arrive incrementally:
```typescript
interface StreamingToolCall {
type: 'tool-call';
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
}
interface StreamingToolResult {
type: 'tool-result';
toolCallId: string;
result: unknown;
}
```
### History Restore Format
When loading chat history, tool calls are embedded in messages:
```typescript
interface HistoryMessage {
role: 'assistant';
content: string;
toolInvocations?: Array<{
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
result?: unknown;
state: 'pending' | 'result' | 'error';
}>;
}
```
### Unified Handler Pattern
```typescript
interface ToolCall {
id: string;
name: string;
args: Record<string, unknown>;
result?: unknown;
state: 'pending' | 'result' | 'error';
}
function normalizeToolCall(
data: StreamingToolCall | HistoryMessage['toolInvocations'][0]
): ToolCall {
// Handle streaming format
if ('type' in data && data.type === 'tool-call') {
return {
id: data.toolCallId,
name: data.toolName,
args: data.args,
state: 'pending',
};
}
// Handle history format
return {
id: data.toolCallId,
name: data.toolName,
args: data.args,
result: data.result,
state: data.state,
};
}
// Usage in component
function ToolCallDisplay({ toolCall }: { toolCall: ToolCall }) {
switch (toolCall.name) {
case 'searchProducts':
return <ProductSearchResult args={toolCall.args} result={toolCall.result} />;
case 'createOrder':
return <OrderCreationResult args={toolCall.args} result={toolCall.result} />;
default:
return <GenericToolResult toolCall={toolCall} />;
}
}
```
### Handling Tool Call States
```typescript
export function useToolCallHandler() {
const [pendingToolCalls, setPendingToolCalls] = useState<Map<string, ToolCall>>(
new Map()
);
const handleStreamChunk = useCallback((chunk: unknown) => {
if (isToolCall(chunk)) {
setPendingToolCalls((prev) => {
const next = new Map(prev);
next.set(chunk.toolCallId, normalizeToolCall(chunk));
return next;
});
}
if (isToolResult(chunk)) {
setPendingToolCalls((prev) => {
const next = new Map(prev);
const existing = next.get(chunk.toolCallId);
if (existing) {
next.set(chunk.toolCallId, {
...existing,
result: chunk.result,
state: 'result',
});
}
return next;
});
}
}, []);
return { pendingToolCalls, handleStreamChunk };
}
```
## Error Handling
### API Error Handling
```typescript
import { isORPCError } from '@your-app/api/client'; // Replace with your monorepo package path
export function useCreateOrder() {
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: orpcClient.orders.create,
onError: (err) => {
if (isORPCError(err)) {
switch (err.code) {
case 'UNAUTHORIZED':
setError('Please sign in to continue');
break;
case 'VALIDATION_ERROR':
setError('Please check your input');
break;
default:
setError('Something went wrong');
}
} else {
setError('Network error. Please try again.');
}
},
});
return { ...mutation, error };
}
```
### Retry Logic
```typescript
export function useResilientQuery() {
return useQuery({
queryKey: ['data'],
queryFn: () => orpcClient.data.get(),
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
}
```
## Best Practices
1. **Centralize API Client**: Keep oRPC client configuration in one place
2. **Use Query Keys Consistently**: Follow a hierarchical naming convention
3. **Handle Loading States**: Always show feedback during API calls
4. **Implement Error Boundaries**: Catch and display errors gracefully
5. **Optimize Real-time**: Unsubscribe from channels when components unmount
6. **Type Everything**: Leverage TypeScript for API response types

View File

@@ -0,0 +1,748 @@
# Frontend Authentication with better-auth
This document provides guidelines for implementing client-side authentication using better-auth in a Next.js React application.
## 1. Overview
better-auth provides a comprehensive authentication solution for React applications with:
- **Session Management**: Cookie-based sessions with automatic refresh
- **Multiple Auth Methods**: Password, magic link, OAuth, and passkeys
- **Type Safety**: Full TypeScript support with inferred types
- **Plugin Architecture**: Extensible through plugins (2FA, organizations, admin, etc.)
### Key Concepts
- **Auth Client**: The main interface for all authentication operations
- **Session Context**: React context for accessing session state across components
- **Middleware**: Server-side route protection before rendering
## 2. Auth Client Setup
### Creating the Auth Client
Create a centralized auth client that can be imported throughout your application:
```typescript
// packages/auth/client.ts
import {
adminClient,
inferAdditionalFields,
magicLinkClient,
organizationClient,
passkeyClient,
twoFactorClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import type { auth } from ".";
export const authClient = createAuthClient({
plugins: [
inferAdditionalFields<typeof auth>(),
magicLinkClient(),
organizationClient(),
adminClient(),
passkeyClient(),
twoFactorClient(),
],
});
export type AuthClientErrorCodes = typeof authClient.$ERROR_CODES & {
INVALID_INVITATION: string;
};
```
### Configuration Options
The auth client supports various plugins based on your needs:
| Plugin | Purpose |
|--------|---------|
| `inferAdditionalFields` | Type inference for custom user fields |
| `magicLinkClient` | Passwordless email login |
| `organizationClient` | Multi-tenant organization support |
| `adminClient` | Admin user management |
| `passkeyClient` | WebAuthn/Passkey authentication |
| `twoFactorClient` | Two-factor authentication |
## 3. Session Hook/Context
### Session Context Definition
Define the session context type and create the context:
```typescript
// lib/session-context.ts
import type { Session } from "@your-app/auth"; // Replace with your monorepo package path
import React from "react";
export const SessionContext = React.createContext<
| {
session: Session["session"] | null;
user: Session["user"] | null;
loaded: boolean;
reloadSession: () => Promise<void>;
}
| undefined
>(undefined);
```
### Session Provider Component
Wrap your application with a SessionProvider to manage session state:
```typescript
// components/SessionProvider.tsx
"use client";
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useEffect, useState } from "react";
import { SessionContext } from "../lib/session-context";
// Query key for session caching
export const sessionQueryKey = ["user", "session"] as const;
// Custom hook for fetching session
export const useSessionQuery = () => {
return useQuery({
queryKey: sessionQueryKey,
queryFn: async () => {
const { data, error } = await authClient.getSession({
query: {
disableCookieCache: true,
},
});
if (error) {
throw new Error(error.message || "Failed to fetch session");
}
return data;
},
staleTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
retry: false,
});
};
export function SessionProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const { data: session } = useSessionQuery();
const [loaded, setLoaded] = useState(!!session);
useEffect(() => {
if (session && !loaded) {
setLoaded(true);
}
}, [session, loaded]);
return (
<SessionContext.Provider
value={{
loaded,
session: session?.session ?? null,
user: session?.user ?? null,
reloadSession: async () => {
const { data: newSession, error } = await authClient.getSession({
query: {
disableCookieCache: true,
},
});
if (error) {
throw new Error(error.message || "Failed to fetch session");
}
queryClient.setQueryData(sessionQueryKey, () => newSession);
},
}}
>
{children}
</SessionContext.Provider>
);
}
```
### useSession Hook
Create a convenient hook to access session data:
```typescript
// hooks/use-session.ts
import { useContext } from "react";
import { SessionContext } from "../lib/session-context";
export const useSession = () => {
const sessionContext = useContext(SessionContext);
if (sessionContext === undefined) {
throw new Error("useSession must be used within SessionProvider");
}
return sessionContext;
};
```
### Usage Example
```typescript
function UserGreeting() {
const { user, loaded } = useSession();
if (!loaded) {
return <div>Loading...</div>;
}
if (!user) {
return <div>Please log in</div>;
}
return <div>Welcome, {user.name}!</div>;
}
```
## 4. Protected Routes
### Middleware for Route Protection
Use Next.js middleware to protect routes at the server level:
```typescript
// middleware.ts
import { getSessionCookie } from "better-auth/cookies";
import { type NextRequest, NextResponse } from "next/server";
import { withQuery } from "ufo";
export default async function middleware(req: NextRequest) {
const { pathname, origin } = req.nextUrl;
const sessionCookie = getSessionCookie(req);
// Protect /app routes
if (pathname.startsWith("/app")) {
if (!sessionCookie) {
return NextResponse.redirect(
new URL(
withQuery("/auth/login", {
redirectTo: pathname,
}),
origin,
),
);
}
return NextResponse.next();
}
// Allow auth routes
if (pathname.startsWith("/auth")) {
return NextResponse.next();
}
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
```
### Client-Side Route Protection
For additional client-side protection, redirect authenticated users away from auth pages:
```typescript
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useSession } from "@/hooks/use-session";
export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { user, loaded } = useSession();
const redirectPath = "/app/dashboard";
useEffect(() => {
if (loaded && user) {
router.replace(redirectPath);
}
}, [user, loaded, router]);
if (!loaded) {
return <LoadingSpinner />;
}
if (user) {
return null; // Will redirect
}
return <>{children}</>;
}
```
### Loading States
Always handle loading states to prevent flash of unauthorized content:
```typescript
function ProtectedContent() {
const { user, loaded } = useSession();
// Show loading while session is being fetched
if (!loaded) {
return (
<div className="flex items-center justify-center min-h-screen">
<Spinner />
</div>
);
}
// Redirect or show unauthorized message
if (!user) {
return <Redirect to="/auth/login" />;
}
return <DashboardContent user={user} />;
}
```
## 5. Login/Logout Flows
### Email/Password Sign In
```typescript
"use client";
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
import { useRouter } from "next/navigation";
function LoginForm() {
const router = useRouter();
const onSubmit = async (values: { email: string; password: string }) => {
try {
const { data, error } = await authClient.signIn.email({
email: values.email,
password: values.password,
});
if (error) {
throw error;
}
// Handle 2FA redirect if enabled
if ((data as any).twoFactorRedirect) {
router.replace("/auth/verify");
return;
}
// Redirect to dashboard
router.replace("/app/dashboard");
} catch (e) {
// Handle error
console.error("Login failed:", e);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields */}
</form>
);
}
```
### Magic Link Sign In
```typescript
const signInWithMagicLink = async (email: string) => {
const { error } = await authClient.signIn.magicLink({
email,
callbackURL: "/app/dashboard",
});
if (error) {
throw error;
}
// Show success message - user will receive email
showNotification("Check your email for the login link");
};
```
### OAuth Sign In
```typescript
"use client";
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
function SocialSigninButton({ provider }: { provider: string }) {
const redirectPath = "/app/dashboard";
const onSignin = () => {
const callbackURL = new URL(redirectPath, window.location.origin);
authClient.signIn.social({
provider, // "google", "github", etc.
callbackURL: callbackURL.toString(),
});
};
return (
<button onClick={onSignin}>
Sign in with {provider}
</button>
);
}
```
### Passkey Sign In
```typescript
const signInWithPasskey = async () => {
try {
await authClient.signIn.passkey();
router.replace("/app/dashboard");
} catch (e) {
console.error("Passkey authentication failed:", e);
}
};
```
### Sign Out
```typescript
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
const onLogout = () => {
authClient.signOut({
fetchOptions: {
onSuccess: async () => {
// Redirect to home or login page
window.location.href = new URL("/", window.location.origin).toString();
},
},
});
};
```
## 6. User Profile
### Accessing Current User
Use the `useSession` hook to access user data:
```typescript
function UserProfile() {
const { user, loaded } = useSession();
if (!loaded || !user) {
return null;
}
const { name, email, image } = user;
return (
<div className="flex items-center gap-2">
<img src={image} alt={name} className="w-10 h-10 rounded-full" />
<div>
<p className="font-medium">{name}</p>
<p className="text-sm text-gray-500">{email}</p>
</div>
</div>
);
}
```
### Updating User Profile
```typescript
"use client";
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
import { useSession } from "@/hooks/use-session";
function ChangeNameForm() {
const { user, reloadSession } = useSession();
const onSubmit = async ({ name }: { name: string }) => {
const { error } = await authClient.updateUser({
name,
});
if (error) {
showError("Failed to update name");
return;
}
showSuccess("Name updated successfully");
// Reload session to reflect changes
await reloadSession();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
type="text"
defaultValue={user?.name ?? ""}
{...register("name")}
/>
<button type="submit">Save</button>
</form>
);
}
```
### Updating Other Profile Fields
```typescript
// Update avatar
const updateAvatar = async (imageUrl: string) => {
const { error } = await authClient.updateUser({
image: imageUrl,
});
if (!error) {
await reloadSession();
}
};
// Update language preference (if custom field)
const updateLanguage = async (language: string) => {
const { error } = await authClient.updateUser({
language,
});
if (!error) {
await reloadSession();
}
};
```
## 7. Server-Side Session Access
For server components, access the session directly:
```typescript
// lib/server.ts
import "server-only";
import { auth } from "@your-app/auth"; // Replace with your monorepo package path
import { headers } from "next/headers";
import { cache } from "react";
export const getSession = cache(async () => {
const session = await auth.api.getSession({
headers: await headers(),
query: {
disableCookieCache: true,
},
});
return session;
});
export const getActiveOrganization = cache(async (slug: string) => {
try {
const activeOrganization = await auth.api.getFullOrganization({
query: {
organizationSlug: slug,
},
headers: await headers(),
});
return activeOrganization;
} catch {
return null;
}
});
```
### Usage in Server Components
```typescript
// app/(app)/dashboard/page.tsx
import { getSession } from "@/lib/server";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await getSession();
if (!session?.user) {
redirect("/auth/login");
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
</div>
);
}
```
## 8. Best Practices
### Always Check Session Before Protected Operations
```typescript
function DeleteAccountButton() {
const { user, loaded } = useSession();
const handleDelete = async () => {
if (!loaded || !user) {
showError("Not authenticated");
return;
}
// Proceed with deletion
};
return (
<button onClick={handleDelete} disabled={!loaded || !user}>
Delete Account
</button>
);
}
```
### Handle Loading States Properly
```typescript
function AuthenticatedComponent() {
const { user, loaded } = useSession();
// Always handle loading state first
if (!loaded) {
return <Skeleton />;
}
// Then handle unauthenticated state
if (!user) {
return <LoginPrompt />;
}
// Finally render authenticated content
return <ProtectedContent user={user} />;
}
```
### Proper Redirect After Auth
```typescript
function LoginForm() {
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirectTo");
const onLoginSuccess = () => {
// Redirect to original destination or default
const destination = redirectTo ?? "/app/dashboard";
router.replace(destination);
};
}
```
### Invalidate Session Cache After Auth Changes
```typescript
import { useQueryClient } from "@tanstack/react-query";
function AuthComponent() {
const queryClient = useQueryClient();
const onAuthChange = () => {
// Invalidate session cache to trigger refetch
queryClient.invalidateQueries({
queryKey: sessionQueryKey,
});
};
}
```
### Error Handling
```typescript
const handleAuthError = (error: any) => {
// Get error code from better-auth error
const errorCode = error?.code;
// Map to user-friendly message
const errorMessages: Record<string, string> = {
INVALID_CREDENTIALS: "Invalid email or password",
USER_NOT_FOUND: "No account found with this email",
EMAIL_NOT_VERIFIED: "Please verify your email first",
TOO_MANY_REQUESTS: "Too many attempts. Please try again later",
};
const message = errorMessages[errorCode] ?? "An error occurred";
showError(message);
};
```
### Security Considerations
1. **Never store sensitive auth data in localStorage** - better-auth uses secure HTTP-only cookies
2. **Always validate sessions server-side** - Middleware protection is essential
3. **Use HTTPS in production** - Required for secure cookies
4. **Implement CSRF protection** - better-auth handles this automatically
5. **Set appropriate session expiry** - Configure in server auth options
## 9. Common Patterns
### Conditional Rendering Based on Auth
```typescript
function Navigation() {
const { user, loaded } = useSession();
return (
<nav>
<Link href="/">Home</Link>
{loaded && (
<>
{user ? (
<>
<Link href="/app/dashboard">Dashboard</Link>
<LogoutButton />
</>
) : (
<>
<Link href="/auth/login">Login</Link>
<Link href="/auth/signup">Sign Up</Link>
</>
)}
</>
)}
</nav>
);
}
```
### Auth State Persistence Across Tabs
```typescript
// Session is automatically synced via cookies
// For real-time sync, listen to storage events
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === "auth-sync") {
reloadSession();
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
```
### Automatic Session Refresh
```typescript
// Configure in useSessionQuery
export const useSessionQuery = () => {
return useQuery({
queryKey: sessionQueryKey,
queryFn: fetchSession,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchInterval: 10 * 60 * 1000, // Refetch every 10 minutes
refetchOnWindowFocus: true,
});
};
```

View File

@@ -0,0 +1,454 @@
# Component Development Guidelines
This document covers component development patterns including Server vs Client components, semantic HTML, and UI best practices.
## Server vs Client Components
### Default to Server Components
Next.js App Router defaults to Server Components. Use them for:
- Data fetching
- Accessing backend resources directly
- Keeping sensitive data on the server
- Reducing client-side JavaScript
```typescript
// app/(app)/dashboard/page.tsx (Server Component)
import { DashboardStats } from '@/modules/dashboard/components';
export default async function DashboardPage() {
// Can fetch data directly
const stats = await fetchDashboardStats();
return (
<main>
<h1>Dashboard</h1>
<DashboardStats data={stats} />
</main>
);
}
```
### When to Use Client Components
Add `'use client'` directive only when you need:
- Event handlers (onClick, onChange, etc.)
- useState, useEffect, or other React hooks
- Browser-only APIs (localStorage, window)
- Class components with lifecycle methods
```typescript
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
```
### Composition Pattern
Keep Server Components at the top, push Client Components down:
```typescript
// Server Component (page.tsx)
import { ProductList } from './ProductList';
import { FilterSidebar } from './FilterSidebar'; // Client
export default async function ProductsPage() {
const products = await fetchProducts();
return (
<div className="flex">
<FilterSidebar /> {/* Client component for interactivity */}
<ProductList products={products} /> {/* Can be server or client */}
</div>
);
}
```
### Passing Server Data to Client Components
```typescript
// Server Component
export default async function Page() {
const initialData = await fetchData();
return <InteractiveWidget initialData={initialData} />;
}
// Client Component
'use client';
export function InteractiveWidget({ initialData }: { initialData: Data }) {
const [data, setData] = useState(initialData);
// Interactive logic...
}
```
## Semantic HTML
### Use Proper Elements
```typescript
// Bad: div for everything
<div onClick={handleClick}>Click me</div>
<div>
<div>Item 1</div>
<div>Item 2</div>
</div>
// Good: semantic elements
<button onClick={handleClick}>Click me</button>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
```
### Button vs Div
Always use `<button>` for clickable actions:
```typescript
// Bad: Non-semantic, no keyboard support, no accessibility
<div
className="cursor-pointer"
onClick={handleClick}
>
Save
</div>
// Good: Semantic, keyboard accessible, proper focus
<button
type="button"
onClick={handleClick}
className="..."
>
Save
</button>
```
### Form Elements
```typescript
// Bad: Missing labels, wrong elements
<div>
<span>Email</span>
<input type="text" />
</div>
// Good: Proper form structure
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-describedby="email-error"
/>
{error && <p id="email-error" role="alert">{error}</p>}
</div>
```
### Navigation
```typescript
// Bad
<div onClick={() => router.push('/about')}>About</div>
// Good
<Link href="/about">About</Link>
// For programmatic navigation with button appearance
<Link href="/about" className="btn btn-primary">
About
</Link>
```
## Next.js Image Component
### Always Use next/image
```typescript
// Bad: Raw img tag
<img src="/hero.jpg" alt="Hero" />
// Good: Optimized Image component
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // For above-the-fold images
/>
```
### Responsive Images
```typescript
// Fill container
<div className="relative h-64 w-full">
<Image
src="/banner.jpg"
alt="Banner"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
```
### Remote Images
Configure domains in `next.config.js`:
```javascript
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
},
],
},
};
```
## Command Palette (cmdk)
### Basic Implementation
```typescript
'use client';
import { Command } from 'cmdk';
import { useState, useEffect } from 'react';
export function CommandPalette() {
const [open, setOpen] = useState(false);
// Toggle with keyboard shortcut
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, []);
return (
<Command.Dialog
open={open}
onOpenChange={setOpen}
label="Global Command Menu"
>
<Command.Input placeholder="Type a command or search..." />
<Command.List>
<Command.Empty>No results found.</Command.Empty>
<Command.Group heading="Navigation">
<Command.Item onSelect={() => router.push('/dashboard')}>
Go to Dashboard
</Command.Item>
<Command.Item onSelect={() => router.push('/settings')}>
Go to Settings
</Command.Item>
</Command.Group>
<Command.Group heading="Actions">
<Command.Item onSelect={handleNewOrder}>
Create New Order
</Command.Item>
</Command.Group>
</Command.List>
</Command.Dialog>
);
}
```
### With Search Results
```typescript
export function SearchCommandPalette() {
const [search, setSearch] = useState('');
const { data: results, isLoading } = useSearch(search);
return (
<Command.Dialog open={open} onOpenChange={setOpen}>
<Command.Input
value={search}
onValueChange={setSearch}
placeholder="Search..."
/>
<Command.List>
{isLoading && <Command.Loading>Searching...</Command.Loading>}
<Command.Empty>No results found.</Command.Empty>
{results?.map((item) => (
<Command.Item
key={item.id}
value={item.title}
onSelect={() => handleSelect(item)}
>
{item.title}
</Command.Item>
))}
</Command.List>
</Command.Dialog>
);
}
```
## Styling with Tailwind
### Component Styling Pattern
```typescript
// Use className for styling
export function Card({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'rounded-lg border bg-card p-4 shadow-sm',
className
)}
>
{children}
</div>
);
}
```
### Conditional Styles
```typescript
import { cn } from '@/lib/utils';
export function Button({
variant = 'primary',
size = 'md',
className,
...props
}: ButtonProps) {
return (
<button
className={cn(
'inline-flex items-center justify-center rounded-md font-medium',
// Variants
{
'bg-primary text-primary-foreground': variant === 'primary',
'bg-secondary text-secondary-foreground': variant === 'secondary',
'border bg-transparent': variant === 'outline',
},
// Sizes
{
'h-8 px-3 text-sm': size === 'sm',
'h-10 px-4': size === 'md',
'h-12 px-6 text-lg': size === 'lg',
},
className
)}
{...props}
/>
);
}
```
### Responsive Design
```typescript
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((item) => (
<Card key={item.id}>{item.content}</Card>
))}
</div>
```
## Accessibility
### Focus Management
```typescript
export function Modal({ open, onClose, children }: ModalProps) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open) {
closeButtonRef.current?.focus();
}
}, [open]);
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
{children}
<button ref={closeButtonRef} onClick={onClose}>
Close
</button>
</DialogContent>
</Dialog>
);
}
```
### ARIA Labels
```typescript
<button
aria-label="Close dialog"
aria-expanded={isOpen}
aria-controls="dropdown-menu"
>
<CloseIcon />
</button>
<div
id="dropdown-menu"
role="menu"
aria-hidden={!isOpen}
>
{/* Menu items */}
</div>
```
## Best Practices
1. **Server First**: Default to Server Components
2. **Semantic HTML**: Use the right element for the job
3. **Optimize Images**: Always use next/image
4. **Accessibility**: Include ARIA labels and keyboard support
5. **Type Props**: Define TypeScript interfaces for all props
6. **Composition**: Break large components into smaller pieces
## Anti-Patterns
- Using `div` for buttons and links
- Using `img` instead of `next/image`
- Adding `'use client'` at the top of every file
- Inline styles instead of Tailwind classes
- Missing accessibility attributes
- Components with too many responsibilities

View File

@@ -0,0 +1,381 @@
# CSS & Layout Best Practices
This document covers CSS patterns, layout strategies, and cross-environment compatibility considerations.
## Flexbox Patterns
### Use items-stretch on Main Flex Containers
For full-height layouts where children should fill the available space:
```typescript
// Good: items-stretch (default) allows children to fill height
<div className="flex h-screen">
<aside className="w-64 bg-gray-100">
{/* Sidebar fills full height */}
</aside>
<main className="flex-1">
{/* Main content fills full height */}
</main>
</div>
```
```typescript
// Bad: items-center prevents children from filling container height
<div className="flex h-screen items-center">
<aside className="w-64 bg-gray-100">
{/* Sidebar only as tall as its content */}
</aside>
<main className="flex-1">
{/* Main content only as tall as its content */}
</main>
</div>
```
### Nested Flex Containers
```typescript
<div className="flex h-screen flex-col">
{/* Header - fixed height */}
<header className="h-16 shrink-0 border-b">
<nav>...</nav>
</header>
{/* Main area - fills remaining space */}
<div className="flex min-h-0 flex-1">
{/* Sidebar - fixed width, full height */}
<aside className="w-64 shrink-0 overflow-y-auto border-r">
<nav>...</nav>
</aside>
{/* Content - fills remaining width */}
<main className="flex-1 overflow-y-auto">
<div className="p-6">...</div>
</main>
</div>
</div>
```
### min-h-0 for Overflow Control
When using flex containers with scrollable children:
```typescript
// Without min-h-0, content may overflow
<div className="flex h-screen flex-col">
<div className="flex-1">
{/* This might overflow if content is tall */}
</div>
</div>
// With min-h-0, overflow is properly contained
<div className="flex h-screen flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
{/* Content scrolls within container */}
</div>
</div>
```
## Parent-Child Styling Pattern
### Parent Provides External Styles
The parent component controls:
- Positioning (absolute, relative, grid placement)
- External spacing (margin, gap)
- Size constraints (width, max-width)
```typescript
// Parent component
<div className="grid grid-cols-3 gap-4">
<Card className="col-span-2" /> {/* Parent sets grid span */}
<Card />
</div>
```
### Child Provides Internal Layout
The child component controls:
- Internal padding
- Internal layout (flex, grid)
- Background, borders, shadows
- Typography
```typescript
// Child component
export function Card({ className, children }: CardProps) {
return (
<div
className={cn(
// Internal styles owned by Card
'rounded-lg border bg-white p-4 shadow-sm',
// External styles from parent
className
)}
>
{children}
</div>
);
}
```
### Complete Example
```typescript
// Page layout (parent)
export function DashboardPage() {
return (
<div className="grid gap-6 p-6 lg:grid-cols-3">
{/* Parent controls: grid position, external spacing */}
<StatsCard className="lg:col-span-2" />
<ActivityFeed className="lg:row-span-2" />
<RecentOrders />
</div>
);
}
// Card component (child)
export function StatsCard({ className }: { className?: string }) {
return (
<div
className={cn(
// Child controls: internal padding, background, border
'flex flex-col gap-4 rounded-xl bg-white p-6 shadow',
className
)}
>
{/* Internal layout */}
</div>
);
}
```
## Cross-Environment Testing
### Dev Mode (Turbopack) vs Production (Webpack)
CSS may behave differently between development and production builds:
```bash
# Test in development (Turbopack)
pnpm dev
# Test in production (Webpack)
pnpm build && pnpm start
```
### Common Differences
1. **CSS Order**: Tailwind classes may be applied in different orders
2. **Purging**: Unused classes removed in production
3. **Minification**: Class names optimized
### Testing Checklist
- [ ] Run `pnpm dev` and test all features
- [ ] Run `pnpm build && pnpm start` and test again
- [ ] Check for visual differences
- [ ] Verify responsive breakpoints work
- [ ] Test animations and transitions
## Mobile Touch Optimization
### Disable Tap Highlight
Prevent the default blue/gray highlight on mobile tap:
```typescript
// Using Tailwind
<button className="[-webkit-tap-highlight-color:transparent]">
Tap me
</button>
// Using inline styles (when needed)
<button style={{ WebkitTapHighlightColor: 'transparent' }}>
Tap me
</button>
// Global reset in CSS
@layer base {
button, a, [role="button"] {
-webkit-tap-highlight-color: transparent;
}
}
```
### Touch-Friendly Sizing
```typescript
// Minimum touch target: 44x44px
<button className="min-h-[44px] min-w-[44px] p-3">
<Icon size={20} />
</button>
// For lists
<ul className="divide-y">
{items.map((item) => (
<li key={item.id}>
<button className="w-full px-4 py-3 text-left">
{item.label}
</button>
</li>
))}
</ul>
```
### Prevent Pull-to-Refresh
When implementing custom scroll behaviors:
```typescript
<div
className="h-screen overflow-y-auto overscroll-contain"
style={{ touchAction: 'pan-y' }}
>
{/* Scrollable content */}
</div>
```
## Responsive Design Patterns
### Mobile-First Approach
```typescript
// Start with mobile styles, add breakpoints for larger screens
<div className="
p-4 // Mobile: small padding
md:p-6 // Tablet: medium padding
lg:p-8 // Desktop: large padding
">
<h1 className="
text-xl // Mobile: small heading
md:text-2xl // Tablet: medium heading
lg:text-3xl // Desktop: large heading
">
Title
</h1>
</div>
```
### Container Queries (Tailwind v4)
```typescript
// Container-based responsive styles
<div className="@container">
<div className="
flex flex-col
@md:flex-row // Row layout when container >= md
@lg:gap-6 // Larger gap when container >= lg
">
{/* Content */}
</div>
</div>
```
### Hiding/Showing Elements
```typescript
// Hide on mobile, show on desktop
<div className="hidden lg:block">
Desktop only content
</div>
// Show on mobile, hide on desktop
<div className="lg:hidden">
Mobile only content
</div>
```
## Z-Index Management
### Establish a Scale
```css
/* In your CSS or Tailwind config */
:root {
--z-dropdown: 10;
--z-sticky: 20;
--z-fixed: 30;
--z-modal-backdrop: 40;
--z-modal: 50;
--z-popover: 60;
--z-tooltip: 70;
}
```
### Tailwind Config
```javascript
// tailwind.config.js
module.exports = {
theme: {
extend: {
zIndex: {
dropdown: '10',
sticky: '20',
fixed: '30',
modalBackdrop: '40',
modal: '50',
popover: '60',
tooltip: '70',
},
},
},
};
```
### Usage
```typescript
<div className="z-modal">Modal content</div>
<div className="z-tooltip">Tooltip</div>
```
## Animation Best Practices
### Use CSS Transitions
```typescript
<button className="
transition-colors duration-200 ease-out
hover:bg-primary-dark
">
Hover me
</button>
```
### Respect Motion Preferences
```typescript
// Disable animations for users who prefer reduced motion
<div className="
transition-transform duration-300
motion-reduce:transition-none
hover:scale-105
motion-reduce:hover:scale-100
">
Animated element
</div>
```
### Hardware Acceleration
```typescript
// Use transform for smooth animations
<div className="
translate-x-0 transition-transform
group-hover:translate-x-2
">
Slides on hover
</div>
```
## Best Practices Summary
1. **items-stretch**: Default for main flex containers
2. **Parent External, Child Internal**: Clear separation of concerns
3. **Test Both Modes**: Always verify in dev AND production
4. **Touch Optimization**: Disable tap highlight, ensure touch targets
5. **Mobile First**: Build up from smallest screens
6. **Consistent Z-Index**: Use a defined scale
7. **Respect Accessibility**: Honor motion preferences

View File

@@ -0,0 +1,189 @@
# Directory Structure
This document describes the module organization and folder conventions for the frontend application.
## Overview
```
app/ # Next.js App Router
├── (marketing)/ # Public marketing pages (i18n)
│ └── [locale]/ # Locale-based routing
└── (app)/ # Protected application routes
└── app/ # Main application routes
modules/ # Feature modules
├── [feature]/ # Feature module
│ ├── components/ # UI components
│ ├── hooks/ # Custom hooks
│ ├── context/ # React Context
│ ├── lib/ # Utilities and data transforms
│ └── types/ # Frontend view model types
├── shared/ # Shared components across features
└── ui/ # UI component library
middleware.ts # Authentication & routing middleware
```
## Module Structure
### Feature Module Pattern
Each feature module should follow this structure:
```
modules/dashboard/
├── components/
│ ├── DashboardHeader.tsx
│ ├── StatsCard.tsx
│ ├── ActivityFeed.tsx
│ └── index.ts # Barrel export
├── hooks/
│ ├── useDashboardStats.ts
│ ├── useActivityFeed.ts
│ └── index.ts
├── context/
│ ├── DashboardContext.tsx
│ └── index.ts
├── lib/
│ ├── formatters.ts # Data formatting utilities
│ ├── transformers.ts # API response transformers
│ └── constants.ts # Feature-specific constants
├── types/
│ └── index.ts # View model types
└── index.ts # Public API of the module
```
### Component Organization
```
components/
├── [ComponentName].tsx # Main component file
├── [ComponentName].test.tsx # Unit tests (if applicable)
└── index.ts # Barrel export
```
### Hooks Organization
```
hooks/
├── useFeatureData.ts # Data fetching hooks
├── useFeatureActions.ts # Mutation hooks
├── useFeatureState.ts # Local state hooks
└── index.ts
```
## Shared Modules
### `modules/shared/`
Components and utilities shared across multiple features:
```
shared/
├── components/
│ ├── Layout/ # Layout components
│ ├── Navigation/ # Navigation components
│ ├── DataTable/ # Reusable data tables
│ └── Forms/ # Form components
├── hooks/
│ ├── useUser.ts # Current user hook
│ ├── useOrganization.ts # Organization context
│ └── usePermissions.ts # Permission checks
└── lib/
├── api.ts # API client configuration
└── utils.ts # Shared utilities
```
### `modules/ui/`
Low-level UI components (design system):
```
ui/
├── Button/
├── Input/
├── Select/
├── Dialog/
├── Toast/
└── ...
```
## Naming Conventions
### Files
| Type | Convention | Example |
|------|------------|---------|
| Components | PascalCase | `UserProfile.tsx` |
| Hooks | camelCase with `use` prefix | `useUserProfile.ts` |
| Context | PascalCase with `Context` suffix | `UserContext.tsx` |
| Utilities | camelCase | `formatDate.ts` |
| Constants | camelCase or SCREAMING_SNAKE_CASE | `constants.ts` |
| Types | PascalCase | `types.ts` or `UserTypes.ts` |
### Exports
Use barrel exports (`index.ts`) for clean imports:
```typescript
// modules/dashboard/components/index.ts
export { DashboardHeader } from './DashboardHeader';
export { StatsCard } from './StatsCard';
export { ActivityFeed } from './ActivityFeed';
```
```typescript
// Usage
import { DashboardHeader, StatsCard } from '@/modules/dashboard/components';
```
## Route-Module Mapping
Routes in `app/(app)/` should map to modules in `modules/`:
```
app/(app)/
├── dashboard/
│ └── page.tsx -> modules/dashboard/
├── users/
│ ├── page.tsx -> modules/users/
│ └── [id]/
│ └── page.tsx -> modules/users/ (detail view)
├── settings/
│ └── page.tsx -> modules/settings/
└── orders/
├── page.tsx -> modules/orders/
└── [id]/
└── page.tsx -> modules/orders/ (detail view)
```
## Import Path Aliases
Configure in `tsconfig.json`:
```json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@/modules/*": ["./modules/*"],
"@/components/*": ["./components/*"],
"@/lib/*": ["./lib/*"]
}
}
}
```
## Best Practices
1. **Colocation**: Keep related files close together
2. **Single Responsibility**: Each module should have one clear purpose
3. **Explicit Dependencies**: Import what you need, avoid implicit globals
4. **Barrel Exports**: Use `index.ts` for public APIs
5. **Private by Default**: Only export what needs to be shared
## Anti-Patterns to Avoid
- Deeply nested folder structures (max 3-4 levels)
- Circular dependencies between modules
- Mixing feature code with shared utilities
- Importing internal module files directly (use barrel exports)
- Creating "utils" folders that become dumping grounds

View File

@@ -0,0 +1,328 @@
# Hook Development Patterns
This document covers React hook patterns for data fetching, mutations, and state management using React Query with oRPC.
## Query Hooks
### Basic Query Pattern
```typescript
import { useQuery } from '@tanstack/react-query';
import { orpcClient } from '@/lib/orpc';
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => orpcClient.users.list(),
});
}
```
### Query with Parameters
```typescript
export function useUser(userId: string) {
return useQuery({
queryKey: ['users', userId],
queryFn: () => orpcClient.users.get({ id: userId }),
enabled: !!userId, // Only fetch when userId is available
});
}
```
### Query with Filters
```typescript
interface UseOrdersOptions {
status?: string;
page?: number;
pageSize?: number;
}
export function useOrders(options: UseOrdersOptions = {}) {
const { status, page = 1, pageSize = 20 } = options;
return useQuery({
queryKey: ['orders', { status, page, pageSize }],
queryFn: () => orpcClient.orders.list({ status, page, pageSize }),
placeholderData: (previousData) => previousData, // Keep previous data while fetching
});
}
```
## Mutation Hooks
### Basic Mutation Pattern
```typescript
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { orpcClient } from '@/lib/orpc';
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateUserInput) => orpcClient.users.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
```
### Mutation with Optimistic Updates
```typescript
type OrderListData = Awaited<ReturnType<typeof orpcClient.orders.list>>;
export function useUpdateOrderStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
orpcClient.orders.updateStatus({ id, status }),
onMutate: async ({ id, status }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['orders'] });
// Snapshot previous value
const previousOrders = queryClient.getQueryData<OrderListData>(['orders']);
// Optimistically update
queryClient.setQueryData<OrderListData>(['orders'], (old) => {
if (!old) return old;
return {
...old,
items: old.items.map((order) =>
order.id === id ? { ...order, status } : order
),
};
});
return { previousOrders };
},
onError: (_err, _variables, context) => {
// Rollback on error
if (context?.previousOrders) {
queryClient.setQueryData(['orders'], context.previousOrders);
}
},
onSettled: () => {
// Always refetch after mutation
queryClient.invalidateQueries({ queryKey: ['orders'] });
},
});
}
```
## Overriding Mutation Callbacks
When overriding mutation callbacks at the call site, you MUST add explicit generics to maintain type safety:
### Problem: Lost Type Safety
```typescript
// Hook definition
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => orpcClient.users.delete({ id }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
// Bad: Overriding without generics loses type safety
const deleteUser = useDeleteUser();
deleteUser.mutate(userId, {
onSuccess: (data) => {
// 'data' is typed as 'unknown' here!
console.log(data.id); // TypeScript error or runtime error
},
});
```
### Solution: Explicit Generics
```typescript
// Infer types for the mutation
type DeleteUserData = Awaited<ReturnType<typeof orpcClient.users.delete>>;
type DeleteUserVariables = string;
// Good: Add explicit generics when overriding callbacks
deleteUser.mutate<DeleteUserData, Error, DeleteUserVariables>(userId, {
onSuccess: (data) => {
// 'data' is properly typed
console.log(data.id); // Works correctly
},
});
```
### Alternative: Define Types in Hook
```typescript
// Export types from the hook file
export type DeleteUserMutationData = Awaited<
ReturnType<typeof orpcClient.users.delete>
>;
// Usage with exported types
deleteUser.mutate(userId, {
onSuccess: (data: DeleteUserMutationData) => {
console.log(data.id);
},
});
```
## Using orpcClient Directly in Hooks
Inside hooks, use `orpcClient` directly instead of wrapping with `useMutation`:
### DO: Direct orpcClient Usage
```typescript
export function useOrderActions() {
const queryClient = useQueryClient();
const updateOrder = useMutation({
mutationFn: (data: UpdateOrderInput) => orpcClient.orders.update(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['orders'] });
},
});
const deleteOrder = useMutation({
mutationFn: (id: string) => orpcClient.orders.delete({ id }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['orders'] });
},
});
return {
updateOrder: updateOrder.mutate,
deleteOrder: deleteOrder.mutate,
isUpdating: updateOrder.isPending,
isDeleting: deleteOrder.isPending,
};
}
```
### DON'T: Nested Hooks
```typescript
// Bad: Don't create hooks that use other mutation hooks
export function useOrderActions() {
// Don't do this - creates unnecessary abstraction
const updateMutation = useUpdateOrder();
const deleteMutation = useDeleteOrder();
return {
updateOrder: updateMutation.mutate,
deleteOrder: deleteMutation.mutate,
};
}
```
## Compound Hooks
Combine related queries and mutations into a single hook:
```typescript
export function useProduct(productId: string) {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['products', productId],
queryFn: () => orpcClient.products.get({ id: productId }),
enabled: !!productId,
});
const update = useMutation({
mutationFn: (data: UpdateProductInput) =>
orpcClient.products.update({ id: productId, ...data }),
onSuccess: (updatedProduct) => {
queryClient.setQueryData(['products', productId], updatedProduct);
},
});
const remove = useMutation({
mutationFn: () => orpcClient.products.delete({ id: productId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
return {
product: query.data,
isLoading: query.isLoading,
error: query.error,
updateProduct: update.mutate,
deleteProduct: remove.mutate,
isUpdating: update.isPending,
isDeleting: remove.isPending,
};
}
```
## Infinite Query Pattern
```typescript
export function useInfiniteOrders() {
return useInfiniteQuery({
queryKey: ['orders', 'infinite'],
queryFn: ({ pageParam = 1 }) =>
orpcClient.orders.list({ page: pageParam, pageSize: 20 }),
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.page + 1 : undefined,
initialPageParam: 1,
});
}
```
## Dependent Queries
```typescript
export function useUserOrders(userId: string) {
// First query: get user
const userQuery = useQuery({
queryKey: ['users', userId],
queryFn: () => orpcClient.users.get({ id: userId }),
enabled: !!userId,
});
// Second query: depends on user data
const ordersQuery = useQuery({
queryKey: ['orders', { userId }],
queryFn: () => orpcClient.orders.list({ userId }),
enabled: !!userQuery.data, // Only run when user is loaded
});
return {
user: userQuery.data,
orders: ordersQuery.data,
isLoading: userQuery.isLoading || ordersQuery.isLoading,
};
}
```
## Best Practices
1. **Single Responsibility**: Each hook should have one clear purpose
2. **Consistent Naming**: `useXxx` for hooks, `useXxxQuery` for queries, `useXxxMutation` for mutations
3. **Error Handling**: Always consider error states in your hooks
4. **Loading States**: Expose loading states for UI feedback
5. **Cache Keys**: Use consistent, hierarchical query keys
6. **Type Safety**: Always maintain proper TypeScript types
## Common Pitfalls
- Forgetting to invalidate related queries after mutations
- Not handling race conditions with `cancelQueries`
- Missing `enabled` flag for conditional queries
- Not providing explicit generics when overriding callbacks
- Creating too many small hooks instead of compound hooks

View File

@@ -0,0 +1,125 @@
# Next.js Frontend Development Guidelines
> Universal frontend development guidelines for Next.js full-stack applications with React + TypeScript + TailwindCSS.
## Tech Stack
- **Framework**: Next.js 15, React 19
- **Language**: TypeScript (strict mode)
- **Styling**: TailwindCSS 4, Radix UI
- **API**: oRPC (OpenAPI RPC), React Query (TanStack Query)
- **URL State**: nuqs
- **Auth**: better-auth
- **AI**: Vercel AI SDK (@ai-sdk/react)
---
## Documentation Files
| File | Description | Priority |
| ---------------------------------------------------- | ---------------------------------------------------- | ------------- |
| [components.md](./components.md) | Server/Client components, semantic HTML, next/image | **Must Read** |
| [authentication.md](./authentication.md) | better-auth client, session, protected routes | **Must Read** |
| [orpc-usage.md](./orpc-usage.md) | Type-safe API calls, React Query integration | **Must Read** |
| [hooks.md](./hooks.md) | Query and mutation hook patterns | Reference |
| [api-integration.md](./api-integration.md) | oRPC client, real-time, AI streaming | Reference |
| [state-management.md](./state-management.md) | URL state with nuqs, React Context patterns | Reference |
| [directory-structure.md](./directory-structure.md) | Project structure and module conventions | Reference |
| [type-safety.md](./type-safety.md) | TypeScript guidelines, type inference, Zod | Reference |
| [css-layout.md](./css-layout.md) | CSS patterns, flexbox, responsive, touch | Reference |
| [ai-sdk-integration.md](./ai-sdk-integration.md) | useChat hook, streaming, tool call handling | Reference |
| [quality.md](./quality.md) | Pre-commit checklist and code quality standards | Reference |
---
## Quick Navigation by Task
### Before Starting Development
| Task | Document |
| --------------------------------- | -------------------------------------------------- |
| Understand project structure | [directory-structure.md](./directory-structure.md) |
| Learn Server vs Client components | [components.md](./components.md) |
| Set up authentication | [authentication.md](./authentication.md) |
### During Development
| Task | Document |
| --------------------------- | -------------------------------------------------- |
| Make type-safe API calls | [orpc-usage.md](./orpc-usage.md) |
| Create custom hooks | [hooks.md](./hooks.md) |
| Manage application state | [state-management.md](./state-management.md) |
| Build UI components | [components.md](./components.md) |
| Ensure type safety | [type-safety.md](./type-safety.md) |
| Integrate AI features | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| Handle CSS & layout | [css-layout.md](./css-layout.md) |
### Before Committing
| Task | Document |
| ----------------------- | -------------------------------- |
| Run quality checklist | [quality.md](./quality.md) |
| Verify CSS in both envs | [css-layout.md](./css-layout.md) |
| Check type safety | [type-safety.md](./type-safety.md) |
---
## Core Rules Summary
| Rule | Reference |
| ------------------------------------------------------------ | -------------------------------------------------- |
| **Default to Server Components** | [components.md](./components.md) |
| **Use `<button>` for clickable actions, not `<div>`** | [components.md](./components.md) |
| **Always use `next/image` instead of `<img>`** | [components.md](./components.md) |
| **Import types from backend, never redefine them** | [type-safety.md](./type-safety.md) |
| **No `any` types or `@ts-expect-error` in new code** | [type-safety.md](./type-safety.md) |
| **Use oRPC client for API calls (not raw fetch)** | [orpc-usage.md](./orpc-usage.md) |
| **Use oRPC generated query keys (not manual strings)** | [orpc-usage.md](./orpc-usage.md) |
| **Store shareable state in URL with nuqs** | [state-management.md](./state-management.md) |
| **Use `items-stretch` on main flex containers** | [css-layout.md](./css-layout.md) |
| **Handle both tool call formats (streaming + history)** | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| **Always check session loading state before rendering** | [authentication.md](./authentication.md) |
---
## Architecture Overview
```
+--------------------------------------------------------------+
| Next.js Application |
| |
| app/ modules/ |
| ├── (marketing)/ ├── [feature]/ |
| │ └── [locale]/ │ ├── components/ |
| └── (app)/ │ ├── hooks/ |
| └── [routes]/ │ ├── context/ |
| │ └── lib/ |
| ├── shared/ |
| └── ui/ |
+-------------------------------+------------------------------+
|
oRPC (type-safe RPC) | React Query (cache)
|
+-------------------------------+------------------------------+
| API Layer (Server) |
| +--------------+ +----------------+ +------------------+ |
| | oRPC | | better-auth | | AI SDK | |
| | Router | | Sessions | | Streaming | |
| +--------------+ +----------------+ +------------------+ |
+--------------------------------------------------------------+
```
---
## Getting Started
1. **Read the Must-Read documents** - Components, authentication, and oRPC usage
2. **Set up your project structure** - Follow [directory-structure.md](./directory-structure.md)
3. **Configure TypeScript paths** - See [type-safety.md](./type-safety.md)
4. **Set up API client** - Use patterns from [orpc-usage.md](./orpc-usage.md)
5. **Build components** - Follow [components.md](./components.md) and [hooks.md](./hooks.md)
6. **Before committing** - Complete the [quality.md](./quality.md) checklist
---
**Language**: All documentation is written in **English**.

View File

@@ -0,0 +1,662 @@
# oRPC Frontend Usage Guidelines
This document provides comprehensive guidelines for using oRPC in frontend applications, covering client setup, React Query integration, and best practices.
## 1. Overview
oRPC (OpenAPI RPC) provides type-safe RPC-style API calls with automatic TypeScript type inference. When combined with React Query (TanStack Query), it offers a powerful solution for data fetching, caching, and state synchronization.
**Key Benefits:**
- End-to-end type safety from backend to frontend
- Automatic query key generation
- Seamless React Query integration
- Built-in error handling
## 2. Client Setup
### Basic Client Configuration
```typescript
// lib/orpc-client.ts
import { createORPCClient, onError } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { ApiRouterClient } from "@your-app/api/orpc/router"; // Replace with your monorepo package path
const link = new RPCLink({
url: "/api/rpc",
headers: async () => {
// Client-side: return empty headers (cookies handled automatically)
if (typeof window !== "undefined") {
return {};
}
// Server-side: forward request headers for SSR
const { headers } = await import("next/headers");
return Object.fromEntries(await headers());
},
interceptors: [
onError((error) => {
// Ignore abort errors (e.g., from React strict mode)
if (error instanceof Error && error.name === "AbortError") {
return;
}
console.error(error);
}),
],
});
export const orpcClient: ApiRouterClient = createORPCClient(link);
```
**Key Points:**
- The `ApiRouterClient` type ensures full type safety
- Headers handling differs between client and server environments
- Error interceptors provide centralized error logging
## 3. React Query Integration
### Creating Query Utilities
```typescript
// lib/orpc-query-utils.ts
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import { orpcClient } from "./orpc-client";
export const orpc = createTanstackQueryUtils(orpcClient);
```
The `orpc` object provides utilities for generating query options and keys that integrate seamlessly with React Query.
## 4. Query Patterns
### 4.1 Basic Query with useQuery
```typescript
import { orpc } from "@/lib/orpc-query-utils";
import { orpcClient } from "@/lib/orpc-client";
import { useQuery } from "@tanstack/react-query";
// Derive types from the client
type ItemResult = Awaited<ReturnType<(typeof orpcClient)["items"]["get"]>>;
export function useItem(itemId: string | null) {
const hasItemId = typeof itemId === "string" && itemId.trim().length > 0;
const { data, isLoading, error, refetch } = useQuery<ItemResult | undefined>({
...orpc.items.get.queryOptions({
input: { itemId: itemId ?? "" },
}),
enabled: hasItemId,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
gcTime: 10 * 60 * 1000, // Keep in garbage collection for 10 minutes
});
return {
item: data?.item ?? null,
isLoading,
error,
refetch,
};
}
```
### 4.2 Infinite Query with Cursor Pagination
```typescript
import { orpc } from "@/lib/orpc-query-utils";
import { orpcClient } from "@/lib/orpc-client";
import { useInfiniteQuery } from "@tanstack/react-query";
import type { InfiniteData } from "@tanstack/react-query";
type ListResult = Awaited<ReturnType<(typeof orpcClient)["items"]["list"]>>;
type ListCursor = { lastUpdatedAt: string; id: string } | undefined;
type ListQueryKey = ReturnType<typeof orpc.items.list.queryKey>;
interface UseItemListOptions {
categoryId: string | null;
filters?: {
isActive?: boolean;
search?: string;
};
enabled?: boolean;
}
export function useItemList(options: UseItemListOptions) {
const { categoryId, filters, enabled = true } = options;
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
error,
refetch,
} = useInfiniteQuery<
ListResult,
Error,
InfiniteData<ListResult, ListCursor>,
ListQueryKey,
ListCursor
>({
queryKey: orpc.items.list.queryKey({
input: {
categoryId: categoryId ?? "",
filters,
},
}),
queryFn: async ({ pageParam }): Promise<ListResult> => {
if (!categoryId) {
throw new Error("Category ID is required");
}
return await orpcClient.items.list({
categoryId,
limit: 20,
cursor: pageParam,
filters,
});
},
initialPageParam: undefined,
getNextPageParam: (lastPage): ListCursor =>
lastPage.nextCursor ?? undefined,
enabled: enabled && !!categoryId,
});
// Flatten all pages into a single array
const items = data?.pages.flatMap((page) => page.items) ?? [];
return {
items,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
isLoading,
error,
refetch,
};
}
```
### 4.3 Batch Queries with useQueries
```typescript
import { orpc } from "@/lib/orpc-query-utils";
import { useQueries } from "@tanstack/react-query";
import { useMemo } from "react";
interface UseBatchItemsOptions {
itemIds: string[];
enabled?: boolean;
staleTime?: number;
}
export function useBatchItems(options: UseBatchItemsOptions) {
const { itemIds, enabled = true, staleTime = 5 * 60 * 1000 } = options;
const queries = useQueries({
queries: itemIds.map((itemId) => ({
...orpc.items.get.queryOptions({
input: { itemId },
}),
enabled: enabled && !!itemId,
staleTime,
})),
});
// Build a map for easy lookup
const itemsMap = useMemo(() => {
const map = new Map();
queries.forEach((query, index) => {
const itemId = itemIds[index];
if (itemId && query.data) {
map.set(itemId, {
data: query.data,
isLoading: query.isLoading,
error: query.error,
});
}
});
return map;
}, [queries, itemIds]);
return {
itemsMap,
isLoading: queries.some((q) => q.isLoading),
isAllLoaded: queries.every((q) => q.isSuccess || q.isError),
};
}
```
### 4.4 Query Key Management
oRPC provides automatic query key generation:
```typescript
// Get query key with input parameters
const queryKey = orpc.items.list.queryKey({
input: { categoryId: "123", filters: { isActive: true } },
});
// Get base key (without input) for broader invalidation
const baseKey = orpc.items.list.key();
// Usage in cache invalidation
queryClient.invalidateQueries({
queryKey: orpc.items.list.key(), // Invalidates all items.list queries
});
queryClient.invalidateQueries({
queryKey: orpc.items.list.queryKey({
input: { categoryId: "123" },
}), // Invalidates specific query
});
```
## 5. Mutation Patterns
### 5.1 Basic Mutation
```typescript
import { orpc } from "@/lib/orpc-query-utils";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
interface UseConnectServiceOptions {
onSuccess?: (data: ConnectOutput) => void;
onError?: (error: Error) => void;
}
export function useConnectService(options: UseConnectServiceOptions = {}) {
const queryClient = useQueryClient();
return useMutation<ConnectOutput, Error, ConnectInput>({
...orpc.services.connect.mutationOptions(),
onSuccess: (data) => {
// Invalidate related queries
queryClient.invalidateQueries({
queryKey: orpc.services.default.key(),
});
options.onSuccess?.(data);
},
onError: (error) => {
toast.error("Connection failed", {
description: error.message,
});
options.onError?.(error);
},
});
}
```
### 5.2 Mutation with Optimistic Updates
```typescript
import { orpc } from "@/lib/orpc-query-utils";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
export function useUpdateItem() {
const queryClient = useQueryClient();
return useMutation<
{ success: boolean },
Error,
{ itemId: string; isActive: boolean },
{ previousQueries: [readonly unknown[], unknown][] }
>({
...orpc.items.update.mutationOptions(),
onMutate: async ({ itemId, isActive }) => {
// Cancel outgoing refetches to avoid overwriting optimistic update
await queryClient.cancelQueries({
queryKey: orpc.items.list.key(),
});
// Snapshot current data for rollback
const previousQueries = queryClient.getQueriesData({
queryKey: orpc.items.list.key(),
});
// Optimistically update the cache
queryClient.setQueriesData(
{ queryKey: orpc.items.list.key() },
(old: unknown) => {
const data = old as {
pages?: Array<{
items: Array<{ id: string; isActive: boolean }>;
}>;
};
if (!data?.pages) return old;
return {
...data,
pages: data.pages.map((page) => ({
...page,
items: page.items.map((item) =>
item.id === itemId ? { ...item, isActive } : item
),
})),
};
}
);
return { previousQueries };
},
onError: (_error, _variables, context) => {
// Rollback on error
if (context?.previousQueries) {
for (const [queryKey, data] of context.previousQueries) {
queryClient.setQueryData(queryKey, data);
}
}
toast.error("Failed to update item");
},
onSuccess: () => {
// Optionally invalidate related queries
queryClient.invalidateQueries({
queryKey: orpc.items.counts.key(),
});
},
});
}
```
### 5.3 Optimistic Delete (Remove from List)
```typescript
export function useDeleteItem() {
const queryClient = useQueryClient();
return useMutation<
{ success: boolean },
Error,
{ itemId: string },
{ previousQueries: [readonly unknown[], unknown][] }
>({
...orpc.items.delete.mutationOptions(),
onMutate: async ({ itemId }) => {
await queryClient.cancelQueries({
queryKey: orpc.items.list.key(),
});
const previousQueries = queryClient.getQueriesData({
queryKey: orpc.items.list.key(),
});
// Optimistically remove from all lists
queryClient.setQueriesData(
{ queryKey: orpc.items.list.key() },
(old: unknown) => {
const data = old as {
pages?: Array<{
items: Array<{ id: string }>;
nextCursor: unknown;
hasMore: boolean;
}>;
};
if (!data?.pages) return old;
return {
...data,
pages: data.pages.map((page) => ({
...page,
items: page.items.filter((item) => item.id !== itemId),
})),
};
}
);
return { previousQueries };
},
onError: (_error, _variables, context) => {
if (context?.previousQueries) {
for (const [queryKey, data] of context.previousQueries) {
queryClient.setQueryData(queryKey, data);
}
}
toast.error("Failed to delete item");
},
onSuccess: () => {
toast.success("Item deleted");
queryClient.invalidateQueries({
queryKey: orpc.items.counts.key(),
});
},
});
}
```
## 6. Direct Client Calls
### 6.1 When to Use Direct Client vs useMutation
**Use `useMutation` when:**
- You need loading/error states in UI
- You want automatic retry behavior
- You need optimistic updates
- You want built-in cache invalidation hooks
**Use direct `orpcClient` calls when:**
- Inside `mutationFn` for custom logic (see 6.2)
- In event handlers where you need sequential operations
- When you need to transform input before calling API
- In server components or API routes
### 6.2 Custom Mutation Function
When you need to add custom logic, transform inputs, or handle complex scenarios:
```typescript
import { orpcClient } from "@/lib/orpc-client";
import { orpc } from "@/lib/orpc-query-utils";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function useCreateItem(options = {}) {
const { user } = useSession();
const queryClient = useQueryClient();
return useMutation<
CreateItemOutput,
Error,
Omit<CreateItemInput, "userId"> // userId will be added automatically
>({
mutationKey: orpc.items.create.mutationKey(),
mutationFn: async (input) => {
// Add authentication
if (!user?.id) {
throw new Error("User not authenticated");
}
// Transform input before calling API
const fullInput: CreateItemInput = {
...input,
userId: user.id,
};
// Direct client call with transformed input
return orpcClient.items.create(fullInput);
},
onSuccess: (data) => {
if (data.success) {
toast.success("Item created successfully");
queryClient.invalidateQueries({
queryKey: orpc.items.list.key(),
});
options.onSuccess?.(data);
} else {
// Handle API-level errors
const errorMessage = data.error || "Failed to create item";
toast.error("Failed to create item", {
description: errorMessage,
});
options.onError?.(new Error(errorMessage));
}
},
onError: (error) => {
toast.error("Failed to create item", {
description: error.message,
});
options.onError?.(error);
},
});
}
```
### 6.3 Type Inference
Derive types directly from the client for maximum type safety:
```typescript
import type { orpcClient } from "@/lib/orpc-client";
// Infer return type from client method
type ItemResult = Awaited<ReturnType<(typeof orpcClient)["items"]["get"]>>;
// Infer input type from client method
type CreateItemInput = Parameters<(typeof orpcClient)["items"]["create"]>[0];
```
## 7. Best Practices
### 7.1 Query Key Consistency
Always use oRPC's generated query keys for consistency:
```typescript
// GOOD: Use generated query keys
queryClient.invalidateQueries({
queryKey: orpc.items.list.key(),
});
// GOOD: Use specific query key with input
queryClient.invalidateQueries({
queryKey: orpc.items.list.queryKey({ input: { categoryId: "123" } }),
});
// BAD: Manually constructed keys
queryClient.invalidateQueries({
queryKey: ["items", "list"], // Don't do this
});
```
### 7.2 Error Handling
Implement consistent error handling with toast notifications:
```typescript
import { toast } from "sonner";
// In mutation hooks
onError: (error) => {
toast.error("Operation failed", {
description: error.message,
});
},
// Handle API-level errors in onSuccess
onSuccess: (data) => {
if (!data.success) {
toast.error("Operation failed", {
description: data.error || "Unknown error",
});
return;
}
// Handle success...
},
```
### 7.3 Loading States
Use appropriate loading state properties:
```typescript
const { isLoading, isFetching, isPending } = useQuery(...);
const { isPending, isSuccess, isError } = useMutation(...);
const { isFetchingNextPage, hasNextPage } = useInfiniteQuery(...);
// In components
{isLoading && <Skeleton />}
{isPending && <Button disabled>Saving...</Button>}
{isFetchingNextPage && <LoadingSpinner />}
```
### 7.4 Cache Configuration
Set appropriate cache times based on data characteristics:
```typescript
// Frequently changing data
staleTime: 30 * 1000, // 30 seconds
gcTime: 60 * 1000, // 1 minute
// Moderately stable data
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
// Stable/static data
staleTime: 30 * 60 * 1000, // 30 minutes
gcTime: 60 * 60 * 1000, // 1 hour
```
### 7.5 Input Validation in Hooks
Always validate inputs before making API calls:
```typescript
export function useItem(itemId: string | null) {
const hasItemId = typeof itemId === "string" && itemId.trim().length > 0;
useEffect(() => {
if (!hasItemId) {
console.warn("[useItem] Invalid itemId provided. Request skipped.");
}
}, [hasItemId]);
return useQuery({
...orpc.items.get.queryOptions({
input: { itemId: itemId ?? "" },
}),
enabled: hasItemId, // Prevent invalid requests
});
}
```
### 7.6 Partial Success Handling
Handle batch operations that may partially succeed:
```typescript
onSuccess: (result) => {
if (result.failed === 0) {
toast.success(`${result.processed} items updated`);
} else if (result.processed > 0) {
toast.warning(
`${result.processed} of ${result.total} items updated, ${result.failed} failed`
);
// Refresh to get correct state for failed items
queryClient.invalidateQueries({
queryKey: orpc.items.list.key(),
});
} else {
toast.error("Failed to update items");
}
},
```
## 8. Common Patterns Summary
| Pattern | Hook | Use Case |
|---------|------|----------|
| Single item fetch | `useQuery` | Detail pages, single record |
| List with pagination | `useInfiniteQuery` | Lists, feeds, search results |
| Multiple items | `useQueries` | Batch preloading, related items |
| Create/Update/Delete | `useMutation` | Form submissions, actions |
| Optimistic updates | `useMutation` + `onMutate` | Real-time UI updates |
| Custom mutation logic | `useMutation` + `mutationFn` | Auth injection, input transformation |
## 9. Migration Notes
When migrating from other data fetching approaches:
1. Replace manual fetch calls with `orpcClient` methods
2. Replace manual query keys with `orpc.xxx.queryKey()`
3. Use `orpc.xxx.queryOptions()` and `mutationOptions()` for React Query integration
4. Leverage TypeScript inference from the client types

View File

@@ -0,0 +1,137 @@
# Pre-commit Checklist
Complete this checklist before committing frontend code changes.
## Type Safety
- [ ] No `@ts-expect-error` or `@ts-ignore` comments added
- [ ] No `any` types in new code
- [ ] API response types are inferred or imported from backend (not redefined)
- [ ] Cache updates in React Query are properly typed
- [ ] When overriding mutation callbacks, explicit generics are provided
## Component Development
- [ ] Server Components used by default; `'use client'` only when necessary
- [ ] Semantic HTML elements used (button, not div for clicks)
- [ ] `next/image` used instead of `<img>` tags
- [ ] Proper ARIA labels and accessibility attributes added
- [ ] Props have TypeScript interfaces defined
## API Integration
- [ ] API calls use oRPC client (not raw fetch for internal APIs)
- [ ] React Query hooks follow established patterns
- [ ] Loading and error states handled
- [ ] Optimistic updates include rollback logic
- [ ] Real-time subscriptions cleaned up on unmount
## State Management
- [ ] Shareable state stored in URL with nuqs
- [ ] Context used sparingly (not for server data)
- [ ] URL and Context synchronized where necessary
- [ ] No duplicate state across different systems
## CSS & Layout
- [ ] `items-stretch` used on main flex containers (not `items-center`)
- [ ] Parent provides external styles; child provides internal layout
- [ ] Mobile touch: `WebkitTapHighlightColor: "transparent"` applied
- [ ] Touch targets are minimum 44x44px
- [ ] Responsive breakpoints tested
## Cross-Environment Testing
- [ ] Tested in development mode (`pnpm dev`)
- [ ] Tested in production mode (`pnpm build && pnpm start`)
- [ ] No visual differences between dev and prod
- [ ] Animations respect `prefers-reduced-motion`
## Code Quality
- [ ] No console.log statements left in code
- [ ] Unused imports removed
- [ ] Components follow single responsibility principle
- [ ] File and function names follow conventions
- [ ] Barrel exports updated if new files added
## Documentation
- [ ] Complex logic has inline comments
- [ ] New hooks have JSDoc comments
- [ ] API changes reflected in backend documentation
---
## Quick Commands
```bash
# Type check
pnpm type-check
# Lint
pnpm lint
# Format
pnpm format
# Build (catches production-only issues)
pnpm build
# Run all checks
pnpm lint && pnpm type-check && pnpm build
```
## Common Issues to Watch
### Type Safety
```typescript
// Bad
queryClient.setQueryData(['users'], (old: any) => ...)
// Good
queryClient.setQueryData<UserListData>(['users'], (old) => ...)
```
### Components
```typescript
// Bad
<div onClick={handleClick}>Click me</div>
// Good
<button onClick={handleClick}>Click me</button>
```
### Images
```typescript
// Bad
<img src="/hero.jpg" alt="Hero" />
// Good
import Image from 'next/image';
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} />
```
### Layout
```typescript
// Bad - children won't fill height
<div className="flex h-screen items-center">
// Good - children fill available height
<div className="flex h-screen">
```
### Mobile Touch
```typescript
// Bad - shows tap highlight on mobile
<button onClick={handleClick}>Tap</button>
// Good - no tap highlight
<button
onClick={handleClick}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
Tap
</button>
```

View File

@@ -0,0 +1,372 @@
# State Management
This document covers state management patterns including URL state with nuqs, React Context guidelines, and synchronization strategies.
## State Categories
| Category | Tool | When to Use |
|----------|------|-------------|
| Server State | React Query | API data, cached responses |
| URL State | nuqs | Filters, pagination, selected items |
| Local UI State | useState | Transient UI (modals, dropdowns) |
| Shared UI State | Context | Cross-component UI state |
## URL State with nuqs
### Why URL State?
- Shareable: Users can share links with specific state
- Bookmarkable: Browser history navigation works
- SEO-friendly: Search engines can index different states
- Persistent: Survives page refreshes
### Basic Usage
```typescript
import { useQueryState } from 'nuqs';
export function useOrderFilters() {
const [status, setStatus] = useQueryState('status');
const [page, setPage] = useQueryState('page', {
parse: (value) => parseInt(value, 10) || 1,
serialize: String,
});
return { status, setStatus, page, setPage };
}
```
### With Default Values
```typescript
import { useQueryState, parseAsInteger, parseAsString } from 'nuqs';
export function useProductFilters() {
const [category, setCategory] = useQueryState('category', {
defaultValue: 'all',
parse: parseAsString,
});
const [page, setPage] = useQueryState('page', {
defaultValue: 1,
parse: parseAsInteger,
});
const [sortBy, setSortBy] = useQueryState('sort', {
defaultValue: 'newest',
});
return {
category,
setCategory,
page,
setPage,
sortBy,
setSortBy,
};
}
```
### Complex Filter Objects
```typescript
import { useQueryStates, parseAsString, parseAsInteger } from 'nuqs';
const filterParsers = {
search: parseAsString.withDefault(''),
category: parseAsString.withDefault('all'),
minPrice: parseAsInteger,
maxPrice: parseAsInteger,
page: parseAsInteger.withDefault(1),
};
export function useAdvancedFilters() {
const [filters, setFilters] = useQueryStates(filterParsers);
const updateFilter = <K extends keyof typeof filters>(
key: K,
value: (typeof filters)[K]
) => {
setFilters({ [key]: value, page: 1 }); // Reset page on filter change
};
const resetFilters = () => {
setFilters({
search: '',
category: 'all',
minPrice: null,
maxPrice: null,
page: 1,
});
};
return { filters, updateFilter, resetFilters };
}
```
### Shallow Routing
Prevent full page reloads when updating URL state:
```typescript
const [tab, setTab] = useQueryState('tab', {
shallow: true, // Default is true in nuqs
history: 'push', // or 'replace'
});
```
## React Context Guidelines
### When to Use Context
- Theme/appearance settings
- User preferences
- Feature flags
- Cross-cutting concerns (toast notifications, modals)
### When NOT to Use Context
- Server data (use React Query instead)
- Form state (use form libraries)
- Single-component state (use useState)
- State that should be in URL
### Context Pattern
```typescript
// context/DashboardContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface DashboardState {
sidebarCollapsed: boolean;
activeWidget: string | null;
}
interface DashboardContextValue extends DashboardState {
toggleSidebar: () => void;
setActiveWidget: (widget: string | null) => void;
}
const DashboardContext = createContext<DashboardContextValue | null>(null);
export function DashboardProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<DashboardState>({
sidebarCollapsed: false,
activeWidget: null,
});
const toggleSidebar = () => {
setState((prev) => ({
...prev,
sidebarCollapsed: !prev.sidebarCollapsed,
}));
};
const setActiveWidget = (widget: string | null) => {
setState((prev) => ({ ...prev, activeWidget: widget }));
};
return (
<DashboardContext.Provider
value={{ ...state, toggleSidebar, setActiveWidget }}
>
{children}
</DashboardContext.Provider>
);
}
export function useDashboard() {
const context = useContext(DashboardContext);
if (!context) {
throw new Error('useDashboard must be used within DashboardProvider');
}
return context;
}
```
### Split Context for Performance
Separate frequently-changing values to prevent unnecessary re-renders:
```typescript
// Separate contexts for state and actions
const DashboardStateContext = createContext<DashboardState | null>(null);
const DashboardActionsContext = createContext<DashboardActions | null>(null);
export function DashboardProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<DashboardState>(initialState);
// Memoize actions to prevent re-renders
const actions = useMemo(
() => ({
toggleSidebar: () =>
setState((prev) => ({
...prev,
sidebarCollapsed: !prev.sidebarCollapsed,
})),
setActiveWidget: (widget: string | null) =>
setState((prev) => ({ ...prev, activeWidget: widget })),
}),
[]
);
return (
<DashboardStateContext.Provider value={state}>
<DashboardActionsContext.Provider value={actions}>
{children}
</DashboardActionsContext.Provider>
</DashboardStateContext.Provider>
);
}
// Separate hooks for state and actions
export function useDashboardState() {
const context = useContext(DashboardStateContext);
if (!context) throw new Error('Missing DashboardProvider');
return context;
}
export function useDashboardActions() {
const context = useContext(DashboardActionsContext);
if (!context) throw new Error('Missing DashboardProvider');
return context;
}
```
## Context and URL Synchronization
When state needs to be both in context (for easy access) and URL (for shareability):
### Pattern: URL as Source of Truth
```typescript
import { useQueryState } from 'nuqs';
import { createContext, useContext, ReactNode } from 'react';
interface FilterContextValue {
selectedId: string | null;
setSelectedId: (id: string | null) => void;
view: 'grid' | 'list';
setView: (view: 'grid' | 'list') => void;
}
const FilterContext = createContext<FilterContextValue | null>(null);
export function FilterProvider({ children }: { children: ReactNode }) {
// URL state as the single source of truth
const [selectedId, setSelectedId] = useQueryState('selected');
const [view, setView] = useQueryState('view', {
defaultValue: 'grid' as const,
parse: (v) => (v === 'list' ? 'list' : 'grid'),
});
return (
<FilterContext.Provider
value={{
selectedId,
setSelectedId,
view,
setView,
}}
>
{children}
</FilterContext.Provider>
);
}
export function useFilters() {
const context = useContext(FilterContext);
if (!context) throw new Error('Missing FilterProvider');
return context;
}
```
### Pattern: Sync Context to URL
When context state needs to be reflected in URL for specific scenarios:
```typescript
export function useSyncToUrl() {
const { selectedId } = useItemSelection(); // From context
const [, setUrlSelectedId] = useQueryState('selected');
// Sync context changes to URL
useEffect(() => {
setUrlSelectedId(selectedId);
}, [selectedId, setUrlSelectedId]);
}
```
### Pattern: Initialize Context from URL
```typescript
export function SelectionProvider({ children }: { children: ReactNode }) {
// Read initial value from URL
const [urlSelectedId] = useQueryState('selected');
const [selectedId, setSelectedId] = useState<string | null>(
urlSelectedId // Initialize from URL
);
// Keep context in sync with URL changes
useEffect(() => {
setSelectedId(urlSelectedId);
}, [urlSelectedId]);
return (
<SelectionContext.Provider value={{ selectedId, setSelectedId }}>
{children}
</SelectionContext.Provider>
);
}
```
## State Debugging
### React Query DevTools
```typescript
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<>
<AppContent />
<ReactQueryDevtools initialIsOpen={false} />
</>
);
}
```
### Context Debug Component
```typescript
function DebugContext() {
const state = useDashboardState();
if (process.env.NODE_ENV !== 'development') return null;
return (
<pre className="fixed bottom-4 right-4 p-2 bg-black/80 text-white text-xs">
{JSON.stringify(state, null, 2)}
</pre>
);
}
```
## Best Practices
1. **URL First**: Default to URL state for shareable data
2. **Minimal Context**: Keep context small and focused
3. **Separate Concerns**: Don't mix server state with UI state
4. **Type Everything**: Use TypeScript for all state types
5. **Default Values**: Always provide sensible defaults
6. **Single Source**: Avoid duplicating state across systems
## Anti-Patterns
- Storing server data in context (use React Query)
- Using context for form state (use form libraries)
- Deep nesting of providers
- Not memoizing context actions
- Duplicating URL state in useState

View File

@@ -0,0 +1,278 @@
# Type Safety Guidelines
This document covers TypeScript best practices for maintaining type safety across the frontend application.
## Core Principles
1. **Import types from backend, never redefine them**
2. **Use type inference wherever possible**
3. **Avoid type assertions and escape hatches**
4. **Leverage oRPC for end-to-end type safety**
## Importing Backend Types
### DO: Import from API Package
```typescript
// Good: Import types from the API package
import type { User, Order, Product } from '@your-app/api/modules/users/types'; // Replace with your monorepo package path
import type { OrderStatus } from '@your-app/api/modules/orders/types'; // Replace with your monorepo package path
```
### DON'T: Redefine Backend Types
```typescript
// Bad: Redefining types that exist in backend
interface User {
id: string;
name: string;
email: string;
}
// Bad: Creating parallel type definitions
type OrderStatus = 'pending' | 'processing' | 'completed';
```
## Type Inference from API Client
### Using `Awaited<ReturnType>` Pattern
Infer types directly from API client calls to ensure frontend types stay in sync with backend:
```typescript
import { orpcClient } from '@/lib/orpc';
// Infer the response type from the API client
type UsersResponse = Awaited<ReturnType<typeof orpcClient.users.list>>;
// Infer a single item type from array response
type User = UsersResponse['items'][number];
// Infer input types
type CreateUserInput = Parameters<typeof orpcClient.users.create>[0];
```
### Type Inference in Hooks
```typescript
import { useQuery } from '@tanstack/react-query';
import { orpcClient } from '@/lib/orpc';
// The return type is automatically inferred
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => orpcClient.users.list(),
});
}
// For complex transformations, use explicit inference
type UserListData = Awaited<ReturnType<typeof orpcClient.users.list>>;
export function useFormattedUsers() {
return useQuery({
queryKey: ['users', 'formatted'],
queryFn: async () => {
const data = await orpcClient.users.list();
return transformUsers(data);
},
});
}
```
## Forbidden Patterns
### NO @ts-expect-error for Custom Fields
Never use type suppression to access fields that don't exist in the type:
```typescript
// Bad: Suppressing type errors
// @ts-expect-error - customField exists at runtime
const value = user.customField;
// Bad: Using any to bypass type checking
const value = (user as any).customField;
```
**Solution**: If a field exists at runtime but not in types, update the backend type definition.
### NO `any` Type in Cache Updates
React Query cache updates must maintain type safety:
```typescript
// Bad: Using any in cache updates
queryClient.setQueryData(['users'], (old: any) => {
return old.map((user: any) => /* ... */);
});
// Good: Properly typed cache updates
queryClient.setQueryData<UserListData>(['users'], (old) => {
if (!old) return old;
return {
...old,
items: old.items.map((user) => /* ... */),
};
});
```
### NO Type Assertions Without Validation
```typescript
// Bad: Blind type assertion
const user = data as User;
// Good: Runtime validation with Zod
import { userSchema } from '@your-app/api/modules/users/types'; // Replace with your monorepo package path
const user = userSchema.parse(data);
// Good: Type guard
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'email' in data
);
}
```
## View Model Types
When the frontend needs additional computed properties, create view models that extend backend types:
```typescript
// types/index.ts
import type { Order } from '@your-app/api/modules/orders/types'; // Replace with your monorepo package path
// Extend backend type with frontend-specific computed properties
export interface OrderViewModel extends Order {
formattedTotal: string;
statusLabel: string;
isEditable: boolean;
}
// Transform function
export function toOrderViewModel(order: Order): OrderViewModel {
return {
...order,
formattedTotal: formatCurrency(order.total),
statusLabel: getStatusLabel(order.status),
isEditable: order.status === 'draft',
};
}
```
## Generic Type Patterns
### API Response Wrapper
```typescript
// Generic paginated response type
type PaginatedResponse<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
// Usage with inference
type UserListResponse = PaginatedResponse<User>;
```
### Hook Return Types
```typescript
// Explicit return type for complex hooks
interface UseOrderActionsReturn {
updateOrder: (id: string, data: UpdateOrderInput) => Promise<void>;
deleteOrder: (id: string) => Promise<void>;
isUpdating: boolean;
isDeleting: boolean;
}
export function useOrderActions(): UseOrderActionsReturn {
// Implementation
}
```
## Working with External Data
### API Responses
```typescript
// Always validate external data
import { z } from 'zod';
const externalDataSchema = z.object({
id: z.string(),
value: z.number(),
});
async function fetchExternalData() {
const response = await fetch('/api/external');
const data = await response.json();
return externalDataSchema.parse(data);
}
```
### Local Storage
```typescript
// Type-safe local storage wrapper
function getStoredValue<T>(key: string, schema: z.ZodType<T>): T | null {
const stored = localStorage.getItem(key);
if (!stored) return null;
try {
return schema.parse(JSON.parse(stored));
} catch {
return null;
}
}
```
## TypeScript Configuration
Ensure strict mode is enabled in `tsconfig.json`:
```json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true
}
}
```
## Common Type Utilities
```typescript
// Extract array element type
type ArrayElement<T> = T extends (infer E)[] ? E : never;
// Make specific properties optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Make specific properties required
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
// Non-nullable
type NonNullableFields<T> = {
[K in keyof T]: NonNullable<T[K]>;
};
```
## Checklist
Before committing, verify:
- [ ] No `@ts-expect-error` or `@ts-ignore` comments added
- [ ] No `any` types in new code
- [ ] All API response types are inferred or imported from backend
- [ ] Cache updates are properly typed
- [ ] External data is validated with Zod schemas

View File

@@ -0,0 +1,417 @@
# Cross-Layer Thinking Guide
> **Purpose**: Pre-implementation checklist for features that span multiple layers.
>
> **Core Principle**: 30 minutes of thinking saves 3 hours of debugging.
---
## When to Use This Guide
Use this guide when your feature:
- Touches 3+ layers (Server Component, Client Component, oRPC, Database)
- Involves data transformation between layers
- Has real-time or event-driven components
- Receives data from external sources (APIs, webhooks, file uploads)
---
## Pre-Implementation Checklist
Before writing code, answer these questions:
### 1. Layer Identification
**Which layers does this feature touch?**
- [ ] Server Components (RSC - data fetching, static rendering)
- [ ] Client Components (interactivity, browser APIs, React hooks)
- [ ] API Routes / oRPC Procedures (validation, business logic)
- [ ] Middleware (auth checks, redirects, header manipulation)
- [ ] Database (Drizzle ORM queries, migrations)
- [ ] Server Actions (form handling, progressive enhancement)
- [ ] External Services (third-party APIs, webhooks)
### 2. Data Flow Direction
**How does data flow?**
```
Read Flow: DB -> Drizzle -> oRPC Handler -> API Response -> React Query -> Component -> UI
Write Flow: UI -> Form/Action -> oRPC Mutation -> Handler -> Drizzle -> DB
SSR Flow: DB -> Drizzle -> oRPC Handler -> Server Component -> HTML -> Client Hydration
```
- [ ] Read-only (data flows from DB to UI)
- [ ] Write-only (data flows from UI to DB)
- [ ] Bidirectional (both directions)
- [ ] Server-rendered (data fetched in Server Components)
- [ ] Client-fetched (data fetched via React Query in Client Components)
### 3. Data Format at Each Layer
**What format is the data at each boundary?**
| Layer | Format | Example |
| ---------------- | ----------------------- | ----------------------------------------------- |
| Database | SQL types | `TEXT`, `INTEGER`, `TIMESTAMP`, `JSONB` |
| Drizzle ORM | TypeScript types | `string`, `number`, `Date`, `Record<>` |
| oRPC Handler | Zod-validated objects | `{ id: string, createdAt: Date }` |
| oRPC Response | Serialized JSON | `{ id: "abc", createdAt: "2024-01-01T..." }` |
| React Query | Cached response | Same as oRPC response (deserialized) |
| Server Component | Props (must serialize) | No functions, no Date objects, no class instances |
| Client Component | React state | Component props, hook return values |
| UI | Rendered output | HTML, Tailwind-styled elements |
### 3.1 Serialization Boundary (CRITICAL!)
**Design Principle**: Data crossing the Server/Client Component boundary must be serializable.
| Serializable (OK) | NOT Serializable (WILL BREAK) |
| ----------------------- | --------------------------------- |
| `string`, `number` | `Date` objects |
| `boolean`, `null` | `Map`, `Set` |
| Plain objects, arrays | Functions, class instances |
| `undefined` (as absent) | `BigInt`, `Symbol` |
**Common serialization trap**:
```typescript
// BAD - Date objects don't serialize across RSC boundary
async function ItemPage() {
const item = await orpcClient.items.get({ itemId: "123" });
// item.createdAt might be a Date object from Drizzle
return <ClientItem item={item} />; // Date becomes string or breaks!
}
// GOOD - Convert to serializable format before passing to Client Component
async function ItemPage() {
const item = await orpcClient.items.get({ itemId: "123" });
return <ClientItem item={{
...item,
createdAt: item.createdAt.toISOString(), // Explicit string conversion
}} />;
}
```
### 4. Data Transformation Points
**Where does format change? Who is responsible?**
| From | To | Transformer | Location |
| ----------------- | ------------------- | ------------------- | -------------------------- |
| DB timestamp | JS Date | Drizzle ORM | Automatic |
| JS Date | ISO string | oRPC serialization | API response |
| ISO string | Display string | React component | UI layer |
| User input | Validated data | Zod schema | oRPC input validation |
| JSONB column | TypeScript object | Drizzle + cast | Query layer |
### 5. Boundary Questions (Critical!)
For each layer boundary, ask:
**RSC / Client Component Boundary:**
- What data is the Server Component passing as props?
- Is all of it serializable? (no functions, no Date objects, no Maps)
- Could this data be fetched directly in the Client Component via React Query instead?
- Does the Client Component need to refetch or mutate this data?
**Client Component / oRPC Boundary:**
- What format does the oRPC response return?
- How does React Query cache and deserialize it?
- What happens if the response format changes?
- Are query keys consistent for cache invalidation?
**oRPC Handler / Database Boundary:**
- Are timestamps handled consistently? (ISO strings vs Date objects)
- Are IDs strings or numbers?
- What about null vs undefined?
- Does Drizzle transform types automatically?
- Are JSONB columns properly cast?
**Middleware / Route Boundary:**
- Is auth checked in middleware, oRPC procedure, or both?
- What happens if middleware redirects but the API call continues?
- Are headers properly forwarded in SSR context?
### 6. Authentication Context
**Where is auth available?**
| Layer | Auth Method | Notes |
| ---------------- | -------------------------------------- | --------------------------------------- |
| Middleware | `getSession()` from headers/cookies | Runs before route handler |
| Server Component | `getSession()` or `auth()` helper | Can redirect on the server |
| Client Component | `useSession()` hook | May need loading state |
| oRPC Procedure | `protectedProcedure` middleware | Throws UNAUTHORIZED if no session |
| API Route | `getSession()` from request headers | Manual check needed |
**Common auth pitfall**:
```typescript
// BAD - Auth checked in middleware but not in oRPC procedure
// If someone calls the API directly, auth is bypassed!
export const middleware = NextResponse.next(); // auth check here
export const getSecret = publicProcedure.handler(...); // no auth check!
// GOOD - Auth in oRPC procedure (always enforced)
export const getSecret = protectedProcedure.handler(...);
```
### 7. Edge Cases
- [ ] What if the data is empty/null?
- [ ] What if the database query fails?
- [ ] What if the oRPC call times out?
- [ ] What if a referenced entity doesn't exist?
- [ ] What if the user navigates away mid-mutation?
- [ ] What if React Query returns stale data?
- [ ] What if the user's session expires mid-operation?
- [ ] What if the same mutation fires twice (double-click)?
---
## Common Patterns
### Pattern A: Server Component Data Fetch
**Layers**: Server Component -> oRPC Client -> Handler -> Database
**Data Flow**:
```
1. Server Component: Calls oRPC client directly (server-side)
2. oRPC Handler: Validates auth, queries database
3. Drizzle: Returns typed results
4. Server Component: Renders HTML with data
5. Client: Receives pre-rendered HTML
```
**Common Issues**:
- **Serialization**: Server Components can render Date objects directly, but cannot pass them as props to Client Components
- **No cache**: Server-side oRPC calls bypass React Query cache; consider prefetching
- **Waterfall**: Sequential server-side calls create request waterfalls; use `Promise.all` for parallel fetching
### Pattern B: Client Component with React Query
**Layers**: Client Component -> React Query -> oRPC Client -> Handler -> Database
**Data Flow**:
```
1. Client Component: Mounts, triggers useQuery
2. React Query: Checks cache, calls oRPC client if stale
3. oRPC Client: Sends HTTP request to API route
4. oRPC Handler: Validates input/auth, queries DB
5. Response: JSON back through React Query
6. Client Component: Re-renders with data
```
**Common Issues**:
- **Loading states**: Must handle `isLoading`, `isError`, `isPending` properly
- **Stale data**: Configure `staleTime` and `gcTime` appropriately
- **Cache invalidation**: Use `orpc.xxx.key()` for consistent invalidation after mutations
- **Enabled flag**: Disable queries when required parameters are missing
### Pattern C: Mutation with Optimistic Update
**Layers**: Client Component -> useMutation -> oRPC Client -> Handler -> Database
**Data Flow**:
```
1. User: Triggers action (click, form submit)
2. onMutate: Optimistically update React Query cache
3. oRPC Client: Sends mutation request
4. Handler: Validates, writes to DB
5. onSuccess: Invalidate related queries
6. onError: Rollback optimistic update from snapshot
```
**Common Issues**:
- **Rollback complexity**: Must snapshot all affected queries before optimistic update
- **Type safety**: Cache manipulation needs explicit type annotations
- **Race conditions**: Cancel outgoing refetches before optimistic update (`cancelQueries`)
- **Partial failures**: Batch operations may partially succeed
### Pattern D: Server Action (Form Handling)
**Layers**: Form -> Server Action -> oRPC Client / DB -> Revalidate
**Data Flow**:
```
1. User: Submits form
2. Server Action: Receives FormData, validates
3. Action: Calls oRPC client or DB directly
4. Action: Calls revalidatePath/revalidateTag
5. Page: Re-renders with updated data
```
**Common Issues**:
- **Progressive enhancement**: Forms work without JS when using Server Actions
- **Validation**: Validate on both client (UX) and server (security)
- **Redirect vs revalidate**: Choose the right post-action behavior
- **Error handling**: Server Action errors need proper error boundaries
### Pattern E: Middleware + API Route Auth
**Layers**: Request -> Middleware -> API Route / oRPC -> Handler
**Data Flow**:
```
1. Request: Arrives at Next.js server
2. Middleware: Checks auth, may redirect to login
3. API Route: Handles oRPC request
4. oRPC Middleware: Validates session (protectedProcedure)
5. Handler: Executes business logic
```
**Common Issues**:
- **Double auth check**: Middleware protects pages, oRPC protects API; both are needed
- **Header forwarding**: SSR requests must forward cookies/headers to oRPC client
- **Middleware scope**: Don't run auth middleware on public assets or API routes that handle their own auth
---
## Lessons from Common Bugs
| Bug | Root Cause | Prevention |
| ------------------------------- | --------------------------------------------------------- | --------------------------------------------------- |
| `Date` props break hydration | Date objects passed from Server to Client Component | Convert to ISO string before passing as props |
| Stale data after mutation | Forgot to invalidate React Query cache | Always invalidate with `orpc.xxx.key()` in onSuccess |
| Auth bypass on API | Auth only in middleware, not in oRPC procedure | Always use `protectedProcedure` for protected data |
| `BigInt` serialization error | Database returns BigInt, JSON.stringify fails | Cast to number or string before response |
| Query fires with null ID | `enabled` flag not set on conditional queries | Always guard with `enabled: !!requiredParam` |
| Cache key mismatch | Manual query key doesn't match oRPC generated key | Always use `orpc.xxx.key()` or `orpc.xxx.queryKey()` |
| N+1 queries in handler | Fetching related data in a loop | Use `inArray()` for batch queries |
| Hydration mismatch | Server and client render different output (e.g., locale) | Ensure consistent data between server and client |
| Headers not forwarded in SSR | oRPC client doesn't forward cookies in server context | Configure client to forward headers in SSR mode |
---
## Checklist Template
Copy this for your feature:
```markdown
## Feature: [Name]
### Layers Involved
- [ ] Server Component
- [ ] Client Component
- [ ] oRPC Procedure
- [ ] Middleware
- [ ] Database
- [ ] Server Action
- [ ] External Service
### Data Flow
[Describe the flow]
### Format at Each Layer
| Layer | Format |
| ----- | ------ |
| ... | ... |
### Transformation Points
| From | To | Who |
| ---- | --- | --- |
| ... | ... | ... |
### Auth Strategy
- Middleware: [yes/no, what it checks]
- oRPC: [publicProcedure/protectedProcedure/adminProcedure]
### Edge Cases Considered
- [ ] Empty/null data
- [ ] Invalid format / serialization
- [ ] Operation failure / timeout
- [ ] User cancellation / navigation
- [ ] Session expiry mid-operation
- [ ] Double submission
```
---
## Cross-Layer Review Mindset
### The Comparison Trap
**Wrong thinking**: "This line wasn't changed, so it must be correct."
```
Comparison thinking (surface level):
Before: new Date() -> After: new Date() -> "No change, must be fine"
Global thinking (design level):
Design intent: ISO strings across RSC boundary -> Current: Date object -> "This is a bug"
```
**Key insight**: Review validates "system state is correct", not just "change is correct".
### Data Outlet Checklist
Every review must cover ALL data outlets:
```
Data Outlets:
|-- oRPC Response (handler -> client)
|-- Server Component Props (RSC -> Client Component)
|-- React Query Cache (shared across components)
|-- URL State (nuqs, searchParams)
|-- Server Action Return (action -> form)
|-- Any external interface
```
Ask: **"Is the format correct at EACH outlet?"**
### Review Three Questions
Before finishing any cross-layer review:
1. **Outlet Question**: Have I checked ALL data outlets, not just the "core" one?
2. **Design Question**: Does existing code match design principles? (Not "is the change correct?")
3. **Checklist Question**: Could my checklist itself be wrong?
### Validation vs Verification
| Approach | Focus | Risk |
| --------------- | ---------------------------- | ------------------------------------ |
| **Incremental** | "Is this change correct?" | Misses pre-existing bugs |
| **Global** | "Is the system correct now?" | More thorough, catches legacy issues |
Always prefer global verification for cross-layer features.
---
## When Things Go Wrong
If you encounter a cross-layer bug:
1. **Identify the boundary** - Where exactly does it fail?
2. **Log at boundaries** - Add logging before and after each transformation
3. **Check assumptions** - What format did you expect vs what you got?
4. **Test in isolation** - Can you reproduce with a simple test case?
5. **Document the fix** - Add to "Lessons from Common Bugs" table
---
**Language**: All documentation should be written in **English**.

View File

@@ -0,0 +1,122 @@
# Thinking Guides for Next.js Full-Stack Projects
> **Purpose**: Systematic thinking guides to catch issues before they become bugs.
>
> **Core Philosophy**: 30 minutes of thinking saves 3 hours of debugging.
---
## Why Thinking Guides?
**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill:
- Didn't think about what happens at layer boundaries -> cross-layer bugs
- Didn't think about code patterns repeating -> duplicated code everywhere
- Didn't think about edge cases -> runtime errors
- Didn't think about future maintainers -> unreadable code
These guides help you **ask the right questions before coding**.
---
## Available Thinking Guides
| Guide | Purpose | When to Use |
| ----------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ |
| [Cross-Layer Thinking](./cross-layer-thinking-guide.md) | Think through data flow across layers | Before implementing features that span 3+ layers |
| [Pre-Implementation Checklist](./pre-implementation-checklist.md) | Verify readiness before coding | Before starting any feature implementation |
---
## Quick Reference: When to Use Which Guide
### Cross-Layer Issues
Use [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) when:
- [ ] Feature touches 3+ layers (Server Component, Client Component, oRPC, Database)
- [ ] Data format changes between layers
- [ ] Multiple consumers need the same data
- [ ] You're not sure where to put some logic
- [ ] Integrates with external services or third-party APIs
### Before Writing Code
Use [Pre-Implementation Checklist](./pre-implementation-checklist.md) when:
- [ ] About to add a constant or config value
- [ ] About to implement new logic
- [ ] About to define a type or Zod schema
- [ ] About to create a component or hook
- [ ] About to add an oRPC procedure
- [ ] Feels like you've seen similar code before
---
## The Pre-Modification Rule (CRITICAL)
> **Before changing ANY value, ALWAYS search first!**
```bash
# Search for the value you're about to change
rg "value_to_change" --type ts
# Check how many files define this value
rg "CONFIG_NAME" --type ts -c
```
This single habit prevents most "forgot to update X" bugs.
---
## Next.js-Specific Layers
In Next.js full-stack projects with oRPC and Drizzle, these are the typical layers:
```
Server Components (RSC - data fetching, static rendering)
|
v
Client Components ('use client' - interactivity, React Query)
|
v
API Routes / oRPC Router (type-safe RPC, middleware, validation)
|
v
Service / Business Logic (shared utilities, domain rules)
|
v
Database Layer (Drizzle ORM, PostgreSQL, migrations)
```
Each boundary is a potential source of bugs due to:
- **Serialization** - Only serializable data crosses the RSC/Client boundary (no functions, no Date objects, no Maps)
- **Type mismatches** - Zod schemas on oRPC may not match what the frontend expects
- **Auth context** - Session availability differs between Server Components, API routes, and middleware
- **Rendering mode** - Server Components vs Client Components have different capabilities and constraints
- **Async timing** - React Query caching, stale data, and race conditions
---
## Core Principles
1. **Search Before Write** - Always search for existing patterns before creating new ones
2. **Think Before Code** - 5 minutes of checklist saves 50 minutes of debugging
3. **Document Assumptions** - Make implicit assumptions explicit
4. **Verify All Layers** - Changes often need updates in multiple places
5. **Learn From Bugs** - Add lessons to these guides after fixing non-trivial bugs
---
## Contributing
Found a new "didn't think of that" moment? Add it:
1. If it's a **general thinking pattern** -> Add to existing guide or create new one
2. If it caused a bug -> Add to "Lessons Learned" section in the relevant guide
3. If it's **project-specific** -> Create a separate project-specific guide
---
**Language**: All documentation should be written in **English**.

View File

@@ -0,0 +1,289 @@
# Pre-Implementation Checklist
> **Purpose**: Ask the right questions **before** writing code to avoid common architectural mistakes.
---
## Why This Checklist?
Most code quality issues aren't caught during implementation--they're **designed in** from the start:
| Problem | Root Cause | Cost |
| -------------------------------------- | --------------------------------------------- | -------------------------- |
| Constants duplicated across 5 files | Didn't ask "will this be used elsewhere?" | Refactoring + testing |
| Same logic repeated in multiple hooks | Didn't ask "does this pattern exist?" | Creating abstraction later |
| Cross-layer type mismatches | Didn't ask "who else consumes this?" | Debugging + fixing |
| Zod schema redefined in frontend | Didn't ask "is this type already exported?" | Inconsistent validation |
| oRPC procedure duplicates existing one | Didn't ask "does a similar endpoint exist?" | API surface bloat |
**This checklist catches these issues before they become code.**
---
## The Checklist
### 1. Constants & Configuration
Before adding any constant or config value:
- [ ] **Cross-package usage?** Will this value be used in both frontend app and API package?
- If yes -> Put in a shared package (e.g., `@your-app/utils` or `@your-app/config`)
- Example: `MAX_UPLOAD_SIZE` used by both file upload UI and oRPC validation
- [ ] **Multiple consumers?** Will this value be used in 2+ files within the same package?
- If yes -> Put in a shared constants file for that package
- Example: Don't define `DEBOUNCE_MS = 300` in each hook file
- [ ] **Magic number?** Is this a hardcoded value that could change?
- If yes -> Extract to named constant with comment explaining why
- Example: `PAGINATION_LIMIT: 50 // oRPC default page size`
- [ ] **Environment-dependent?** Does this differ between dev/staging/production?
- If yes -> Use environment variables with proper validation
- Example: API URLs, feature flags, third-party API keys
### 2. Logic & Patterns
Before implementing any logic:
- [ ] **Pattern exists?** Search for similar patterns in the codebase first
```bash
# Example: Before implementing debounced search
rg "debounce" src/ packages/
rg "useDebounce" src/ packages/
```
- [ ] **Will repeat?** Will this exact logic be needed in 2+ places?
- If yes -> Create a shared hook/utility **first**, then use it
- Example: `useDebounce` instead of repeating debounce logic in 5 hooks
- [ ] **React Query pattern?** Is there an existing query/mutation hook for this data?
- Search before creating: `rg "orpc.items" src/`
- Check if you can extend an existing hook rather than creating a new one
- [ ] **Server or client?** Does this logic need interactivity?
- If no -> Keep it in a Server Component (default)
- If yes -> Extract only the interactive part into a Client Component
### 3. Types & Schemas
Before defining types:
- [ ] **Zod schema exists?** Is there already a Zod schema for this data shape?
- Check the API module's `types.ts`: `rg "Schema = z.object" packages/api/`
- Derive TypeScript types from Zod schemas with `z.infer<typeof schema>`
- Never manually define a TypeScript interface that duplicates a Zod schema
- [ ] **Existing type?** Does a similar type already exist?
- Search before creating: `rg "interface.*YourTypeName\|type.*YourTypeName" src/ packages/`
- [ ] **Cross-layer type?** Is this type used across the oRPC boundary?
- If yes -> Define the Zod schema in the API module's `types.ts`, export the inferred type
- Frontend should import types from the API package, not redefine them
- [ ] **Derived from client?** Can you infer the type from the oRPC client?
```typescript
// Prefer this over manually defining types
type ItemResult = Awaited<ReturnType<(typeof orpcClient)["items"]["get"]>>;
```
### 4. UI Components
Before creating UI components:
- [ ] **Server or Client Component?** Does this component need:
- Event handlers (onClick, onChange)? -> `'use client'`
- React hooks (useState, useEffect)? -> `'use client'`
- Browser APIs (window, document)? -> `'use client'`
- None of the above? -> Keep as Server Component (default)
- [ ] **Similar component exists?** Search before creating
- `rg "function.*YourComponent\|export.*YourComponent" src/`
- [ ] **Visual-logic consistency?** If there's already a visual distinction (icon, color, label) for a concept, does your logic match?
- [ ] **State lifecycle?** Will this component unmount during normal user flow?
- If yes -> Consider where state should persist (URL params with nuqs, parent, context)
### 5. API Routes & oRPC Procedures
Before writing an API route or oRPC procedure:
- [ ] **Existing procedure?** Does a similar oRPC procedure already exist?
- Check the module's `router.ts`: `rg "Router = {" packages/api/`
- Can you extend an existing procedure rather than creating a new one?
- [ ] **Correct HTTP method?**
- GET for read operations (queries)
- POST for create operations (mutations)
- PUT/PATCH for update operations
- DELETE for removal operations
- [ ] **Authentication level?** Which base procedure to use?
- Public data -> `publicProcedure`
- User-specific data -> `protectedProcedure`
- Admin operations -> `adminProcedure`
- [ ] **Input/output schemas defined?** Both should be Zod schemas in `types.ts`
### 6. Dependencies
Before adding a dependency:
- [ ] **Already installed?** Check `package.json` across all packages
```bash
rg "\"dependency-name\"" package.json packages/*/package.json
```
- [ ] **Built-in alternative?** Can you use a native API or existing utility instead?
- Example: `structuredClone()` instead of `lodash.cloneDeep`
- [ ] **Bundle impact?** Will this significantly increase the client bundle?
- If yes -> Consider dynamic import or server-only usage
---
## Quick Decision Tree
```
Adding a value/constant?
|-- Used in both app AND api package? -> shared package (@your-app/utils)
|-- Used in 2+ files within same package? -> shared constants file
+-- Single file only? -> Local constant is fine
Adding logic/behavior?
|-- Similar pattern exists? -> Extend or reuse existing
|-- Will be used in 2+ places? -> Create shared hook/utility first
+-- Single use only? -> Implement directly (but document pattern)
Adding a type?
|-- Zod schema exists? -> Use z.infer<typeof schema>
|-- Crosses oRPC boundary? -> Define in API types.ts, import elsewhere
|-- Can derive from client? -> Use Awaited<ReturnType<...>>
+-- Local only? -> Define locally
Adding a component?
|-- Needs interactivity? -> 'use client'
|-- Pure display? -> Server Component (default)
+-- Mix of both? -> Split into Server wrapper + Client interactive part
```
---
## What to Verify Across Layers
When implementing a feature that spans Server Component -> API -> Database, verify:
| Layer | Check |
| ---------------- | ------------------------------------------------------------------ |
| Server Component | Data fetched correctly? Props serializable? No client-only APIs? |
| Client Component | Loading/error states handled? React Query cache invalidated? |
| oRPC Procedure | Input validated? Auth checked? Output schema matches? |
| Database Query | No N+1 queries? Proper indexes? Transactions where needed? |
| Zod Schemas | Input and output schemas consistent? Date/null handling correct? |
---
## Anti-Patterns to Avoid
### Redefining Backend Types
```typescript
// DON'T: Manually define types that mirror Zod schemas
interface Item {
id: string;
name: string;
createdAt: Date;
}
// DO: Import or infer from the source of truth
import type { Item } from "@your-app/api/modules/items/types";
// or
type Item = Awaited<ReturnType<(typeof orpcClient)["items"]["get"]>>["item"];
```
### Manual Query Keys
```typescript
// DON'T: Manually construct query keys
queryClient.invalidateQueries({ queryKey: ["items", "list"] });
// DO: Use oRPC generated keys
queryClient.invalidateQueries({ queryKey: orpc.items.list.key() });
```
### Unnecessary Client Components
```typescript
// DON'T: Mark everything as 'use client'
'use client';
export function ItemCard({ item }) {
return <div>{item.name}</div>; // No interactivity needed!
}
// DO: Keep as Server Component when possible
export function ItemCard({ item }) {
return <div>{item.name}</div>;
}
```
### Fetch in Client Components When Server Would Work
```typescript
// DON'T: Fetch in Client Component when data could come from Server Component
'use client';
export function ItemList() {
const { data } = useQuery(orpc.items.list.queryOptions({ input: {} }));
return <ul>{data?.items.map(...)}</ul>;
}
// DO: Fetch in Server Component, pass as props (when no interactivity needed)
export async function ItemList() {
const data = await orpcClient.items.list({});
return <ul>{data.items.map(...)}</ul>;
}
```
---
## When to Use This Checklist
| Trigger | Action |
| ------------------------------------------ | ------------------------- |
| About to add a constant | Run through Section 1 |
| About to implement logic | Run through Section 2 |
| About to define a type or schema | Run through Section 3 |
| About to create a component | Run through Section 4 |
| About to add an oRPC procedure | Run through Section 5 |
| About to add a dependency | Run through Section 6 |
| Feels like you've seen similar code before | **STOP** and search first |
---
## Relationship to Other Guides
| Guide | Focus | Timing |
| ------------------------------------------------------------- | ------------------------- | --------------------------- |
| **Pre-Implementation Checklist** (this) | Questions before coding | Before writing code |
| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Data flow across layers | Complex feature planning |
**Ideal workflow:**
1. Read this checklist before coding
2. Use Cross-Layer guide for features spanning multiple layers
---
## Lessons Learned
| Date | Issue | Lesson |
| ---- | ---------------------------------------------- | --------------------------------------------------------------------- |
| - | Zod schema redefined in frontend and backend | Always derive frontend types from the API package's Zod schemas |
| - | `useQuery` used where Server Component sufficed | Ask "does this need interactivity?" before reaching for React Query |
| - | Manual query keys diverged from oRPC keys | Always use `orpc.xxx.key()` or `orpc.xxx.queryKey()` for cache ops |
| - | Type defined in both app and api package | Cross-boundary types must be defined once in the API module |
---
**Core Principle**: 5 minutes of checklist thinking saves 50 minutes of refactoring.

View File

@@ -0,0 +1,315 @@
# Code Quality Guidelines
> Mandatory code quality rules for all Next.js full-stack applications.
---
## No Non-Null Assertions
**NEVER** use non-null assertions (`!`). They bypass TypeScript's null checking and lead to runtime errors.
```typescript
// FORBIDDEN
const name = user!.name;
const value = data!.items![0]!;
// REQUIRED - Use explicit checks
const user = getUser();
if (!user) {
throw new Error('User not found');
}
const name = user.name;
// REQUIRED - Use optional chaining with fallback
const value = data?.items?.[0] ?? defaultValue;
// REQUIRED - Use local variable after null check
const project = getProject(id);
if (!project) {
return { success: false, reason: 'Project not found' };
}
const projectName = project.name;
```
---
## No `any` Type
```typescript
// BAD
function process(data: any) { ... }
// GOOD - Use proper types
function process(data: ProcessInput) { ... }
// GOOD - Use unknown for truly unknown data
function parseJSON(input: string): unknown {
return JSON.parse(input);
}
// BAD - any in cache updates
queryClient.setQueryData(['users'], (old: any) => ...);
// GOOD - Properly typed cache updates
queryClient.setQueryData<UserListData>(['users'], (old) => {
if (!old) return old;
return { ...old, items: old.items.filter((u) => u.id !== deletedId) };
});
```
---
## No `@ts-expect-error` / `@ts-ignore`
```typescript
// FORBIDDEN
// @ts-expect-error - field exists at runtime
const value = user.customField;
// @ts-ignore
doSomething(invalidArg);
// REQUIRED - Fix the type issue at the source
// If a field exists at runtime but not in types, update the type definition.
doSomething(validArg);
```
---
## No `console.log`
Use structured logging instead of `console.log`. This applies to both frontend and backend code.
```typescript
// BAD
console.log('User created:', userId);
console.log('Error:', error);
// GOOD - Backend: use structured logger
logger.info('user_created', { userId });
logger.error('operation_failed', { error, operationId });
// GOOD - Frontend: remove debug logs before commit
// Use browser dev tools for debugging, not console.log
```
**Exception**: `console.warn` and `console.error` are acceptable in frontend code for development-time warnings that will not appear in production.
---
## Import Ordering
Organize imports in this order, separated by blank lines:
```typescript
// 1. Node built-ins
import path from 'node:path';
// 2. External packages
import { z } from 'zod';
import { useQuery } from '@tanstack/react-query';
// 3. Internal workspace packages
import type { User } from '@your-app/api/modules/users/types';
// 4. Local imports (relative paths)
import { formatDate } from './utils';
import type { Props } from './types';
```
Always use `import type` for type-only imports:
```typescript
// GOOD
import type { User, Project } from './types';
import { createUser } from './procedures';
// BAD - Mixed imports without type annotation
import { User, createUser } from './types';
```
---
## Naming Conventions
### Files and Directories
| Type | Convention | Example |
| --------------- | --------------------------- | --------------------------- |
| React Component | PascalCase | `UserProfile.tsx` |
| Hook | camelCase with `use` prefix | `useProject.ts` |
| Utility | kebab-case | `date-utils.ts` |
| Type file | kebab-case or `types.ts` | `types.ts`, `user-types.ts` |
| Test file | Same name + `.test` | `date-utils.test.ts` |
| Directory | kebab-case | `user-profile/` |
### Variables and Functions
| Type | Convention | Example |
| -------------- | ------------------------------------------- | ---------------------------------- |
| Variable | camelCase | `userName`, `isActive` |
| Constant | SCREAMING_SNAKE_CASE | `MAX_RETRY_COUNT` |
| Function | camelCase | `getUserById` |
| Class | PascalCase | `UserService` |
| Type/Interface | PascalCase | `UserInput`, `ProjectOutput` |
| Enum | PascalCase (type), SCREAMING_SNAKE (values) | `enum Status { ACTIVE, INACTIVE }` |
### Boolean Variables
Use `is`, `has`, `should`, `can` prefixes:
```typescript
// GOOD
const isLoading = true;
const hasPermission = user.role === 'admin';
const shouldRefresh = Date.now() > expiresAt;
const canEdit = isOwner || hasPermission;
// BAD
const loading = true;
const permission = user.role === 'admin';
```
---
## Error Handling
### Never Swallow Errors
```typescript
// BAD - Silent failure
try {
await dangerousOperation();
} catch (e) {
// nothing
}
// GOOD - Log and handle
try {
await dangerousOperation();
} catch (error) {
logger.error('operation_failed', { error });
throw new AppError('Operation failed', 'OPERATION_FAILED');
}
```
### Consistent Error Response Format
All API responses must use the standard `success` + `reason` format:
```typescript
// Success
return {
success: true,
reason: 'Operation completed successfully',
data: result,
};
// Error
return {
success: false,
reason: 'Insufficient permissions to perform this action',
};
```
---
## Dead Code Elimination
- Remove unused imports (Biome enforces this automatically)
- Remove commented-out code blocks
- Remove unused variables, functions, and types
- Remove unreachable code after `return`, `throw`, `break`, `continue`
```typescript
// BAD - Dead code
function processOrder(order: Order) {
// const oldLogic = order.items.map(...);
const result = newLogic(order);
return result;
cleanup(); // unreachable
}
// GOOD - Clean
function processOrder(order: Order) {
return newLogic(order);
}
```
---
## Lint and Type Check Before Commit
```bash
# MUST pass before every commit
pnpm lint
pnpm type-check
# Production build check (catches additional issues)
pnpm build
# Or combined
pnpm lint && pnpm type-check && pnpm build
```
---
## Testing Guidelines
### Test File Location
```
src/
__tests__/ # Integration tests
api.test.ts
app/
feature/
page.tsx
page.test.tsx # Co-located test (when appropriate)
```
### Test Structure (AAA Pattern)
```typescript
describe('OrderService', () => {
describe('createOrder', () => {
it('should create an order with valid input', async () => {
// Arrange
const input = { items: [{ productId: '1', quantity: 2 }] };
// Act
const result = await createOrder(input);
// Assert
expect(result.success).toBe(true);
expect(result.order.items).toHaveLength(1);
});
it('should reject empty order', async () => {
// Arrange
const input = { items: [] };
// Act
const result = await createOrder(input);
// Assert
expect(result.success).toBe(false);
});
});
});
```
---
## Summary
| Rule | Reason |
| ------------------------------ | ------------------- |
| No `!` assertions | Runtime errors |
| No `any` type | Type safety |
| No `@ts-expect-error` | Masks real issues |
| No `console.log` | Use structured logs |
| Lint + typecheck before commit | Consistent code |
| Structured errors | Consistent handling |
| Never swallow errors | Debuggability |
| Remove dead code | Maintainability |

View File

@@ -0,0 +1,173 @@
# Dependencies & Versions
> Adjust versions to your project. These represent a known-working combination as of the time of writing. Pin or widen ranges to match your stability requirements.
---
## Runtime Environment
| Dependency | Version | Description |
|------------|---------|-------------|
| Node.js | >=20 | JavaScript runtime |
| pnpm | ^10.x | Package manager |
---
## Core Framework
| Package | Version | Description |
|---------|---------|-------------|
| next | ^15.x | React framework for production |
| react | ^19.x | UI library |
| react-dom | ^19.x | React DOM renderer |
| typescript | ^5.x | TypeScript language |
---
## Backend
### API Layer
| Package | Version | Description |
|---------|---------|-------------|
| hono | ^4.x | Lightweight web framework |
| @orpc/server | ^1.x | oRPC server implementation |
| @orpc/client | ^1.x | oRPC client |
| @orpc/zod | ^1.x | oRPC Zod integration |
| @orpc/openapi | ^1.x | OpenAPI schema generation |
| zod | ^4.x | Schema validation |
### Database
| Package | Version | Description |
|---------|---------|-------------|
| drizzle-orm | ^0.44.x | TypeScript ORM |
| drizzle-kit | ^0.31.x | Drizzle CLI tools |
| drizzle-zod | ^0.8.x | Drizzle + Zod integration |
| pg | ^8.x | PostgreSQL client |
### Authentication
| Package | Version | Description |
|---------|---------|-------------|
| better-auth | ^1.x | Authentication library |
### Caching & Queue
| Package | Version | Description |
|---------|---------|-------------|
| @upstash/redis | ^1.x | Redis client (Upstash) |
| @upstash/qstash | ^2.x | Message queue |
---
## AI Integration
| Package | Version | Description |
|---------|---------|-------------|
| ai | ^5.x | Vercel AI SDK core |
| @ai-sdk/react | ^2.x | AI SDK React hooks |
| @ai-sdk/openai | ^2.x | OpenAI provider |
| @ai-sdk/anthropic | ^2.x | Anthropic provider |
---
## Frontend
### UI Components
| Package | Version | Description |
|---------|---------|-------------|
| @radix-ui/* | latest | Headless UI primitives |
| lucide-react | ^0.x | Icon library |
| cmdk | ^1.x | Command palette |
| sonner | ^2.x | Toast notifications |
### Styling
| Package | Version | Description |
|---------|---------|-------------|
| tailwindcss | ^4.x | Utility-first CSS (v4 config format) |
| @tailwindcss/postcss | ^4.x | PostCSS plugin |
| tailwind-merge | ^3.x | Tailwind class merging |
| class-variance-authority | ^0.7.x | Variant management |
| clsx | ^2.x | Class name utility |
### State Management
| Package | Version | Description |
|---------|---------|-------------|
| @tanstack/react-query | ^5.x | Data fetching & caching |
| @orpc/tanstack-query | ^1.x | oRPC + React Query bridge |
| nuqs | ^2.x | URL state management |
| react-hook-form | ^7.x | Form state management |
| @hookform/resolvers | ^5.x | Form validation resolvers |
### Internationalization
| Package | Version | Description |
|---------|---------|-------------|
| next-intl | ^4.x | Next.js i18n |
### Utilities
| Package | Version | Description |
|---------|---------|-------------|
| date-fns | ^4.x | Date utilities |
| es-toolkit | ^1.x | Utility functions |
| nanoid | ^5.x | ID generation |
| p-limit | ^7.x | Concurrency control |
---
## Monitoring & Logging
| Package | Version | Description |
|---------|---------|-------------|
| @sentry/nextjs | ^10.x | Error tracking |
---
## Development Tools
### Build & Bundling
| Package | Version | Description |
|---------|---------|-------------|
| turbo | ^2.x | Monorepo build system |
| tsx | ^4.x | TypeScript executor |
### Code Quality
| Package | Version | Description |
|---------|---------|-------------|
| @biomejs/biome | ^2.x | Linter & formatter |
| husky | ^9.x | Git hooks |
### Testing
| Package | Version | Description |
|---------|---------|-------------|
| @playwright/test | ^1.x | E2E testing |
---
## Important Notes
1. **React 19**: Major version with breaking changes from React 18
2. **Next.js 15**: App Router is the primary routing pattern
3. **TailwindCSS 4**: Uses the new v4 configuration format (not `tailwind.config.js`)
4. **Zod 4**: Latest version with improved TypeScript support
5. **Monorepo**: Use `@your-app/*` for internal workspace package references
---
## Updating Dependencies
When updating dependencies:
1. Check compatibility with React 19 and Next.js 15
2. Update pnpm overrides if changing React or Drizzle versions
3. Run `pnpm install` from the root directory
4. Run `pnpm type-check` to verify TypeScript compatibility
5. Run `pnpm build` to ensure production build works

View File

@@ -0,0 +1,66 @@
# Shared Development Guidelines
> These guidelines apply to all Next.js full-stack applications using this architecture.
---
## Documentation Files
| File | Description | When to Read |
| -------------------------------------- | ------------------------------------ | ----------------------- |
| [code-quality.md](./code-quality.md) | Code quality mandatory rules | Always |
| [typescript.md](./typescript.md) | TypeScript best practices | Type-related decisions |
| [dependencies.md](./dependencies.md) | Dependency versions and constraints | Adding/updating deps |
---
## Quick Navigation
| Task | File |
| --------------------------- | -------------------------------------- |
| Code quality rules | [code-quality.md](./code-quality.md) |
| Type annotations | [typescript.md](./typescript.md) |
| Dependency management | [dependencies.md](./dependencies.md) |
---
## Core Rules (MANDATORY)
| Rule | File |
| ----------------------------------------- | -------------------------------------- |
| No non-null assertions (`!`) | [code-quality.md](./code-quality.md) |
| No `any` type | [code-quality.md](./code-quality.md) |
| No `@ts-expect-error` / `@ts-ignore` | [code-quality.md](./code-quality.md) |
| No `console.log` (use structured logging) | [code-quality.md](./code-quality.md) |
| Zod-first type definitions | [typescript.md](./typescript.md) |
| Import types from backend, never redefine | [typescript.md](./typescript.md) |
| Standard response format (`success` + `reason`) | [typescript.md](./typescript.md) |
---
## Before Every Commit
- [ ] `pnpm lint` - 0 errors
- [ ] `pnpm type-check` - 0 errors
- [ ] `pnpm build` - production build succeeds
- [ ] No `any` types in new code
- [ ] No non-null assertions (`!`)
- [ ] No `@ts-expect-error` or `@ts-ignore` comments
- [ ] No `console.log` statements (use `logger`)
- [ ] Tests pass (if applicable)
---
## Code Review Checklist
- [ ] Types are explicit, not `any`
- [ ] API inputs/outputs have Zod schemas
- [ ] Error handling returns structured responses
- [ ] No duplicate type definitions (import from source of truth)
- [ ] Naming follows conventions (files: kebab-case, components: PascalCase)
- [ ] Unused imports and dead code removed
- [ ] No swallowed errors (silent `catch` blocks)
---
**Language**: All documentation must be written in **English**.

View File

@@ -0,0 +1,441 @@
# TypeScript Best Practices
> TypeScript guidelines for Next.js full-stack applications.
---
## Zod-First Type Definitions
Define Zod schemas first, then infer TypeScript types from them. Never define types manually when a Zod schema exists.
```typescript
import { z } from 'zod';
// 1. Define the schema (single source of truth)
export const createUserInputSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'member', 'viewer']),
});
export const createUserOutputSchema = z.object({
success: z.boolean(),
reason: z.string(),
user: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}).optional(),
});
// 2. Derive types from schemas
export type CreateUserInput = z.infer<typeof createUserInputSchema>;
export type CreateUserOutput = z.infer<typeof createUserOutputSchema>;
// BAD - Manual type that duplicates schema
interface CreateUserInput {
name: string;
email: string;
role: 'admin' | 'member' | 'viewer';
}
```
### Reusable Base Schemas
```typescript
const paginationSchema = z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
});
const timestampSchema = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
// Compose into larger schemas
export const listOrdersInputSchema = paginationSchema.extend({
status: orderStatusZodSchema.optional(),
customerId: z.string().optional(),
});
```
---
## Type Inference from API
Import types from the backend or infer them from the API client. Never redefine backend types on the frontend.
### Import from Backend Package
```typescript
// GOOD - Import from the API package
import type { User, Order } from '@your-app/api/modules/users/types';
import type { OrderStatus } from '@your-app/api/modules/orders/types';
// BAD - Redefining types that exist in backend
interface User {
id: string;
name: string;
email: string;
}
```
### Infer from API Client
```typescript
import { orpcClient } from '@/lib/orpc';
// Infer the response type from the API client
type UsersResponse = Awaited<ReturnType<typeof orpcClient.users.list>>;
// Infer a single item type from array response
type User = UsersResponse['items'][number];
// Infer input types
type CreateUserInput = Parameters<typeof orpcClient.users.create>[0];
```
### Type Inference in Hooks
```typescript
// The return type is automatically inferred from oRPC
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => orpcClient.users.list(),
});
}
// For complex transformations, use explicit inference
type UserListData = Awaited<ReturnType<typeof orpcClient.users.list>>;
export function useFormattedUsers() {
return useQuery({
queryKey: ['users', 'formatted'],
queryFn: async () => {
const data = await orpcClient.users.list();
return transformUsers(data);
},
});
}
```
---
## Discriminated Unions
Use discriminated unions for types that can be one of several shapes. Use strict equality (`=== true`) for narrowing.
### TypeScript Discriminated Union
```typescript
type Result<T> =
| { success: true; data: T }
| { success: false; error: string };
const result: Result<User> = doSomething();
// CORRECT: Use === true for narrowing
if (result.success === true) {
console.log(result.data); // TypeScript knows data exists
} else {
console.log(result.error); // TypeScript knows error exists
}
```
### Zod Discriminated Union
```typescript
export const notificationSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('email'),
recipient: z.string().email(),
subject: z.string(),
}),
z.object({
type: z.literal('sms'),
phoneNumber: z.string(),
message: z.string(),
}),
z.object({
type: z.literal('push'),
deviceToken: z.string(),
title: z.string(),
body: z.string(),
}),
]);
type Notification = z.infer<typeof notificationSchema>;
```
---
## Generic Patterns
### Generic Result Type
```typescript
type Result<T> =
| { success: true; data: T }
| { success: false; error: string };
function createResult<T>(data: T): Result<T> {
return { success: true, data };
}
```
### Generic Paginated Response
```typescript
type PaginatedResponse<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
type UserListResponse = PaginatedResponse<User>;
```
### Generic with Constraints
```typescript
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
```
### Common Utility Types
```typescript
// Extract array element type
type ArrayElement<T> = T extends (infer E)[] ? E : never;
// Make specific properties optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Make specific properties required
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
```
---
## Standard Response Format
All API responses must include `success` and `reason` fields.
```typescript
// Output schema pattern
export const operationResultSchema = z.object({
success: z.boolean(),
reason: z.string(),
data: z.unknown().optional(),
});
// Success response
return {
success: true,
reason: 'User created successfully',
user: { id, name, email },
};
// Error response
return {
success: false,
reason: 'Email address is already in use',
};
```
### Batch Operation Response
```typescript
export const batchOperationResultSchema = z.object({
success: z.boolean(),
total: z.number(),
processed: z.number(),
failed: z.number(),
errors: z.array(z.object({
itemId: z.string(),
error: z.string(),
})).optional(),
});
```
---
## Forbidden Patterns
### No `any`
```typescript
// BAD
function process(data: any) { ... }
// GOOD
function process(data: unknown) { ... }
function process(data: ProcessInput) { ... }
```
### No Non-null Assertion
```typescript
// BAD
const name = user!.name;
const first = items[0]!;
// GOOD
if (user) {
const name = user.name;
}
const first = items[0];
if (!first) {
return { success: false, reason: 'No items found' };
}
```
### No `@ts-expect-error` / `@ts-ignore`
```typescript
// BAD
// @ts-expect-error - customField exists at runtime
const value = user.customField;
// GOOD - Update the type definition instead
interface User {
customField: string;
// ...
}
```
### No Type Assertions Without Validation
```typescript
// BAD - Blind assertion
const user = data as User;
// GOOD - Runtime validation with Zod
const user = userSchema.parse(data);
// GOOD - Type guard
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'email' in data
);
}
```
---
## Type Imports
Always use `import type` for type-only imports:
```typescript
// GOOD
import type { User, Project } from './types';
import { createUser } from './procedures';
// Also acceptable
import { type User, createUser } from './types';
// BAD
import { User, createUser } from './types';
```
---
## Explicit Return Types for Exports
Always annotate explicit return types on exported functions:
```typescript
// BAD - Implicit return type
export function getUser(id: string) {
return db.query.users.findFirst({ where: eq(users.id, id) });
}
// GOOD - Explicit return type
export function getUser(id: string): Promise<User | undefined> {
return db.query.users.findFirst({ where: eq(users.id, id) });
}
```
---
## TypeScript Configuration
Ensure strict mode is enabled:
```json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true
}
}
```
---
## Drizzle Type Inference
```typescript
// Infer types from Drizzle tables
type User = typeof userTable.$inferSelect;
type NewUser = typeof userTable.$inferInsert;
// Combine with Zod via drizzle-zod
import { createSelectSchema, createInsertSchema } from 'drizzle-zod';
const userSelectSchema = createSelectSchema(userTable);
const userInsertSchema = createInsertSchema(userTable);
```
---
## View Model Types
When the frontend needs computed properties, extend backend types rather than redefining them:
```typescript
import type { Order } from '@your-app/api/modules/orders/types';
export interface OrderViewModel extends Order {
formattedTotal: string;
statusLabel: string;
isEditable: boolean;
}
export function toOrderViewModel(order: Order): OrderViewModel {
return {
...order,
formattedTotal: formatCurrency(order.total),
statusLabel: getStatusLabel(order.status),
isEditable: order.status === 'draft',
};
}
```
---
## Summary
| Practice | Reason |
| --------------------------- | ----------------------------- |
| Zod-first types | Single source of truth |
| Import, don't redefine | No type drift |
| `=== true` for unions | Proper narrowing |
| Generics for reuse | DRY, type-safe |
| `success` + `reason` format | Consistent API responses |
| No `any` | Type safety |
| No `!` assertions | Runtime safety |
| No `@ts-expect-error` | Masks real issues |
| `import type` | Clear separation, tree-shake |
| Explicit return types | Documentation, catch errors |