docs: add Trellis planning and project specs
This commit is contained in:
255
.trellis/spec/frontend/ai-sdk-integration.md
Normal file
255
.trellis/spec/frontend/ai-sdk-integration.md
Normal 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 |
|
||||
464
.trellis/spec/frontend/api-integration.md
Normal file
464
.trellis/spec/frontend/api-integration.md
Normal 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
|
||||
748
.trellis/spec/frontend/authentication.md
Normal file
748
.trellis/spec/frontend/authentication.md
Normal 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,
|
||||
});
|
||||
};
|
||||
```
|
||||
454
.trellis/spec/frontend/components.md
Normal file
454
.trellis/spec/frontend/components.md
Normal 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
|
||||
381
.trellis/spec/frontend/css-layout.md
Normal file
381
.trellis/spec/frontend/css-layout.md
Normal 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
|
||||
189
.trellis/spec/frontend/directory-structure.md
Normal file
189
.trellis/spec/frontend/directory-structure.md
Normal 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
|
||||
328
.trellis/spec/frontend/hooks.md
Normal file
328
.trellis/spec/frontend/hooks.md
Normal 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
|
||||
125
.trellis/spec/frontend/index.md
Normal file
125
.trellis/spec/frontend/index.md
Normal 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**.
|
||||
662
.trellis/spec/frontend/orpc-usage.md
Normal file
662
.trellis/spec/frontend/orpc-usage.md
Normal 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
|
||||
137
.trellis/spec/frontend/quality.md
Normal file
137
.trellis/spec/frontend/quality.md
Normal 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>
|
||||
```
|
||||
372
.trellis/spec/frontend/state-management.md
Normal file
372
.trellis/spec/frontend/state-management.md
Normal 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
|
||||
278
.trellis/spec/frontend/type-safety.md
Normal file
278
.trellis/spec/frontend/type-safety.md
Normal 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
|
||||
Reference in New Issue
Block a user