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

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