Frontend Development Guidelines
January 10, 2026 · View on GitHub
⚛️ React + TypeScript Frontend Development Standards
This document details the development standards and best practices for the FreeTodo project frontend (Next.js + React + TypeScript).
Tech Stack
- Framework: Next.js 16 + React 19 (App Router)
- Language: Node.js 22.x + TypeScript 5.x
- Styling: Tailwind CSS 4 + shadcn/ui
- State Management: Zustand + React Hooks
- Data Fetching: TanStack Query (React Query) v5
- API Generation: Orval (auto-generate from OpenAPI)
- Data Validation: Zod (runtime type validation)
- Theming: next-themes (light/dark mode toggle)
- Animation/Interaction: framer-motion, @dnd-kit
- Markdown: react-markdown + remark-gfm
- Icons: lucide-react
- Internationalization: next-intl
- Package Manager: pnpm 10.x
- Code Quality: Biome (lint/format/check)
📋 Table of Contents
- Code Style
- Project Structure
- Naming Conventions
- TypeScript Standards
- React Component Standards
- State Management
- API Calls
- Internationalization
- Styling
- Performance
- Testing
- Accessibility
- Security
🎨 Code Style
Biome Configuration
The project uses Biome as the linter, formatter, and type checker.
# Check code
pnpm lint
# Auto-fix issues
pnpm lint --fix
# Format code
pnpm format
# Type check
pnpm typecheck
# Build test
pnpm build
Basic Rules
Indentation and Formatting
// ✅ Correct: Use 2 spaces
function MyComponent() {
const [count, setCount] = useState(0);
if (count > 0) {
return <div>Count: {count}</div>;
}
return null;
}
// ❌ Wrong: Use 4 spaces or tabs
function MyComponent() {
const [count, setCount] = useState(0);
return <div>Count: {count}</div>;
}
Quotes and Semicolons
// ✅ Correct: Use double quotes, no semicolons
const message = "Hello, World!"
const name = "Alice"
// ❌ Wrong: Use single quotes and semicolons
const message = 'Hello, World!';
Imports
// ✅ Correct: Import order and grouping
// 1. React and Next.js core
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import Image from "next/image"
// 2. Third-party libraries
import axios from "axios"
import clsx from "clsx"
// 3. Internal components
import { Button } from "@/components/common/Button"
import { Card } from "@/components/common/Card"
// 4. Utils and types
import { api } from "@/lib/api"
import type { Task } from "@/lib/types"
// 5. Styles
import styles from "./page.module.css"
// ❌ Wrong: Mixed order
import { Button } from "@/components/common/Button"
import { useState } from "react"
import axios from "axios"
🏗️ Project Structure
free-todo-frontend/
├── app/ # Next.js App Router
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Home page
│ └── apps/ # Feature pages
│ ├── todo-list/ # Todo list
│ ├── todo-detail/ # Todo detail
│ └── [feature]/ # Other features
├── components/ # React components
│ ├── common/ # Common components
│ ├── layout/ # Layout components
│ └── [feature]/ # Feature components
├── lib/ # Utilities
│ ├── api.ts # API client (streaming APIs)
│ ├── generated/ # Orval-generated API code
│ │ ├── [module]/ # Split by feature modules
│ │ ├── fetcher.ts # Custom Fetcher
│ │ └── schemas/ # Zod schemas
│ ├── query/ # TanStack Query hooks wrapper
│ │ └── keys.ts # Query Keys management
│ ├── types/ # Unified type definitions (camelCase)
│ ├── store/ # Zustand state management
│ ├── hooks/ # Custom Hooks
│ └── utils.ts # Utility functions
├── messages/ # Internationalization files
│ ├── zh.json # Chinese translations
│ └── en.json # English translations
└── public/ # Static assets
📝 Naming Conventions
File Naming
# ✅ Correct: Components use PascalCase
Button.tsx
TaskCard.tsx
UserProfile.tsx
# ✅ Correct: Non-components use camelCase
api.ts
utils.ts
use-tasks.ts
# ❌ Wrong: Inconsistent naming
button.tsx
task_card.tsx
Component Naming
// ✅ Correct: PascalCase
export function TaskCard() {}
export function UserProfile() {}
export default function HomePage() {}
// ❌ Wrong: camelCase
export function taskCard() {}
Variables and Functions
// ✅ Correct: camelCase
const userName = "Alice"
const taskCount = 10
function getUserProfile() {}
function calculateTotal() {}
// ❌ Wrong: PascalCase or snake_case
const UserName = "Alice"
const task_count = 10
Constants
// ✅ Correct: UPPER_SNAKE_CASE
const MAX_RETRY_COUNT = 3
const API_BASE_URL = "https://api.example.com"
const DEFAULT_PAGE_SIZE = 10
// ❌ Wrong: camelCase
const maxRetryCount = 3
Hooks
// ✅ Correct: Start with "use"
function useTasks() {}
function useUser() {}
function useDebounce() {}
// ❌ Wrong: No "use" prefix
function getTasks() {}
Event Handlers
// ✅ Correct: Use "handle" prefix
function handleClick() {}
function handleSubmit() {}
function handleChange(e: ChangeEvent<HTMLInputElement>) {}
// ✅ Correct: Callback props use "on" prefix
<Button onClick={handleClick} />
<Input onChange={handleChange} />
🔤 TypeScript Standards
Enable Strict Mode
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
Type Definitions
// ✅ Correct: Define clear types
interface Task {
id: number
title: string
description: string | null
status: "pending" | "in_progress" | "completed"
priority: number
createdAt: string
updatedAt: string
}
type TaskStatus = "pending" | "in_progress" | "completed"
// ❌ Wrong: Use any
interface Task {
id: number
title: string
data: any // Avoid any
}
Component Props
// ✅ Correct: Define Props interface
interface TaskCardProps {
task: Task
onEdit?: (task: Task) => void
onDelete?: (taskId: number) => void
className?: string
}
export function TaskCard({
task,
onEdit,
onDelete,
className
}: TaskCardProps) {
// Component implementation
}
// ✅ Correct: Use generics
interface ListProps<T> {
items: T[]
renderItem: (item: T) => React.ReactNode
keyExtractor: (item: T) => string | number
}
export function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<div>
{items.map(item => (
<div key={keyExtractor(item)}>
{renderItem(item)}
</div>
))}
</div>
)
}
⚛️ React Component Standards
Function Components
// ✅ Correct: Use function components
interface UserProfileProps {
user: User
onUpdate: (user: User) => void
}
export function UserProfile({ user, onUpdate }: UserProfileProps) {
const [isEditing, setIsEditing] = useState(false)
return (
<div>
<h2>{user.name}</h2>
{/* Component content */}
</div>
)
}
// ❌ Wrong: Use class components (unless necessary)
class UserProfile extends React.Component<UserProfileProps> {
render() {
return <div>{this.props.user.name}</div>
}
}
Custom Hooks
// ✅ Correct: Create custom hooks
function useTasks() {
const [tasks, setTasks] = useState<Task[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchTasks()
}, [])
const fetchTasks = async () => {
setLoading(true)
setError(null)
try {
const response = await api.get<Task[]>("/api/tasks")
setTasks(response.data)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch tasks")
} finally {
setLoading(false)
}
}
return { tasks, loading, error, fetchTasks }
}
// Use custom hook
function TasksPage() {
const { tasks, loading, error } = useTasks()
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error}</div>
return <TaskList tasks={tasks} />
}
🎯 State Management
Local State (useState)
// ✅ Correct: Use functional updates
function Counter() {
const [count, setCount] = useState(0)
const increment = () => setCount(prev => prev + 1)
const decrement = () => setCount(prev => prev - 1)
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
)
}
Global State (Zustand)
// lib/store/taskStore.ts
import { create } from "zustand"
interface TaskState {
tasks: Task[]
loading: boolean
error: string | null
fetchTasks: () => Promise<void>
createTask: (task: TaskCreate) => Promise<void>
}
export const useTaskStore = create<TaskState>((set) => ({
tasks: [],
loading: false,
error: null,
fetchTasks: async () => {
set({ loading: true, error: null })
try {
const response = await api.get<Task[]>("/api/tasks")
set({ tasks: response.data, loading: false })
} catch (error) {
set({ error: "Failed to fetch tasks", loading: false })
}
},
createTask: async (taskData: TaskCreate) => {
try {
const response = await api.post<Task>("/api/tasks", taskData)
set(state => ({ tasks: [...state.tasks, response.data] }))
} catch (error) {
set({ error: "Failed to create task" })
throw error
}
}
}))
🌐 API Calls
The project uses Orval + TanStack Query + Zod for type-safe API calls and data validation.
Orval Code Generation
- Config file:
orval.config.ts - Generate command:
pnpm orval(requires backend service running) - Generated content: TypeScript types, Zod schemas, React Query hooks
- Output directory:
lib/generated/(split by API tags, e.g.,todos/,chat/)
Main configuration:
input.target: Backend OpenAPI schema URL (http://localhost:8001/openapi.json)output.client: Use react-query to generate hooksoutput.mode: tags-split by feature modulesoverride.mutator: Use custom fetcher (lib/generated/fetcher.ts)override.zod.strict: Enable strict runtime validation
Using Orval-Generated API Hooks
// 1. Use generated hooks directly
import { useGetTodos, useCreateTodo } from "@/lib/generated/todos"
function TodoList() {
const { data: todos, isLoading } = useGetTodos()
const createTodo = useCreateTodo()
// Use generated hooks
}
// 2. Wrap hooks in lib/query/ to add business logic
// lib/query/todos.ts
import { useGetTodos as useGetTodosBase } from "@/lib/generated/todos"
import { queryKeys } from "./keys"
export function useTodos() {
return useGetTodosBase({
query: {
queryKey: queryKeys.todos.list(),
staleTime: 30000, // 30 seconds cache
},
})
}
TanStack Query Usage Guidelines
- Query Keys: Manage in
lib/query/keys.tswith hierarchical structure (e.g.,todos.list(),todos.detail(id)) - Optimistic Updates: Update cache in
onMutate, rollback inonError, refetch inonSettled - Debounced Updates: Use 500ms debounce for frequently changing fields (e.g., description, notes)
- Cache Strategy: Set reasonable
staleTime(e.g., 30 seconds) to avoid excessive requests
// lib/query/keys.ts
export const queryKeys = {
todos: {
all: () => ["todos"] as const,
lists: () => [...queryKeys.todos.all(), "list"] as const,
list: (filters?: string) => [...queryKeys.todos.lists(), { filters }] as const,
details: () => [...queryKeys.todos.all(), "detail"] as const,
detail: (id: number) => [...queryKeys.todos.details(), id] as const,
},
}
Zod Data Validation
- Generated schemas: Located in
lib/generated/schemas/, auto-generated by Orval - Runtime validation: Automatically validate API response format in fetcher
- Form validation: Use with React Hook Form's
zodResolver
Custom Fetcher
Located in lib/generated/fetcher.ts, responsible for:
- Environment adaptation (client/server URL)
- Automatic naming style conversion:
- Request: camelCase → snake_case (frontend style → backend style)
- Response: snake_case → camelCase (backend style → frontend style)
- Time string normalization (handle missing timezone suffix)
- Unified error handling
- Zod schema runtime validation
Streaming API Handling
Orval doesn't support Server-Sent Events, implement manually in lib/api.ts:
// lib/api.ts
export async function sendChatMessageStream(
message: string,
onChunk: (chunk: string) => void
) {
const response = await fetch(`${API_BASE_URL}/api/chat/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
})
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (!reader) return
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
onChunk(chunk)
}
}
Type Safety Best Practices
- Prefer camelCase types from
lib/types/index.ts(fetcher automatically converts) - IDs use
numbertype uniformly (consistent with backend database) - Orval-generated types only for API layer, business layer uses unified type definitions
Development Workflow
- Backend API changes: Run
pnpm orvalto regenerate code, checkgit diff lib/generated/ - New API: Backend updates OpenAPI → Generate code → Wrap in
lib/query/→ Use in components - Debugging: Add logs in fetcher to view requests/responses and validation errors
🌍 Internationalization
The project uses next-intl for internationalization, managed through Zustand store (no URL routing mode).
- Translation files:
messages/zh.jsonanden.json - Request config:
i18n/request.ts - Language management:
lib/store/locale.ts(syncs to cookie on change) - Access method:
useTranslations(namespace)imported fromnext-intl
Using Internationalization
// ✅ Correct: Use translation hook
import { useTranslations } from "next-intl"
function TaskList() {
const t = useTranslations("page.todo")
return (
<div>
<h1>{t("title")}</h1>
<p>{t("description", { count: tasks.length })}</p>
</div>
)
}
// ❌ Wrong: Hard-coded text
function TaskList() {
const locale = useLocale()
return <h1>{locale === "zh" ? "任务列表" : "Task List"}</h1>
}
Adding/Modifying Translations
- Add translation keys synchronously in
messages/zh.jsonanden.json - Use nested structure to organize translations, e.g.,
page.settings.title - Support ICU MessageFormat interpolation syntax, e.g.,
{count}and plural forms
🎨 Styling
Tailwind CSS 4
The project uses Tailwind CSS 4 and shadcn/ui component library.
// ✅ Correct: Use Tailwind utility classes with clsx/tailwind-merge
import { cn } from "@/lib/utils" // tailwind-merge wrapper
function Button({ children, variant = "primary", className }: ButtonProps) {
return (
<button
className={cn(
"px-4 py-2 rounded-lg font-medium transition-colors",
variant === "primary" && "bg-blue-500 hover:bg-blue-600 text-white",
variant === "secondary" && "bg-gray-200 hover:bg-gray-300 text-gray-800",
className
)}
>
{children}
</button>
)
}
Dark Mode
Use next-themes to manage theme, use dark: prefix in components:
function Card({ children }: CardProps) {
return (
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
{children}
</div>
)
}
shadcn/ui Components
Use shadcn/ui provided components, add via npx shadcn@latest add [component]:
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
function MyComponent() {
return (
<Card>
<Button variant="default">Click</Button>
</Card>
)
}
⚡ Performance
React.memo
// ✅ Correct: Use React.memo
export const TaskCard = React.memo(function TaskCard({ task }: TaskCardProps) {
return (
<div>
<h3>{task.title}</h3>
<p>{task.description}</p>
</div>
)
})
useCallback and useMemo
// ✅ Correct: Use useCallback
function TaskList({ tasks }: TaskListProps) {
const handleTaskClick = useCallback((taskId: number) => {
console.log("Task clicked:", taskId)
}, [])
return (
<div>
{tasks.map(task => (
<TaskCard key={task.id} task={task} onClick={handleTaskClick} />
))}
</div>
)
}
// ✅ Correct: Use useMemo
function TaskStats({ tasks }: TaskStatsProps) {
const stats = useMemo(() => ({
total: tasks.length,
completed: tasks.filter(t => t.status === "completed").length,
pending: tasks.filter(t => t.status === "pending").length
}), [tasks])
return (
<div>
<p>Total: {stats.total}</p>
<p>Completed: {stats.completed}</p>
<p>Pending: {stats.pending}</p>
</div>
)
}
🧪 Testing
// TaskCard.test.tsx
import { render, screen, fireEvent } from "@testing-library/react"
import { TaskCard } from "./TaskCard"
describe("TaskCard", () => {
const mockTask: Task = {
id: 1,
title: "Test Task",
description: "Test Description",
status: "pending",
priority: 1,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
}
it("renders task title", () => {
render(<TaskCard task={mockTask} />)
expect(screen.getByText("Test Task")).toBeInTheDocument()
})
it("calls onEdit when edit button is clicked", () => {
const handleEdit = jest.fn()
render(<TaskCard task={mockTask} onEdit={handleEdit} />)
fireEvent.click(screen.getByRole("button", { name: /edit/i }))
expect(handleEdit).toHaveBeenCalledWith(mockTask)
})
})
♿ Accessibility
Semantic HTML
// ✅ Correct: Use semantic tags
function TaskList({ tasks }: TaskListProps) {
return (
<section>
<h2>Tasks</h2>
<ul>
{tasks.map(task => (
<li key={task.id}>
<article>
<h3>{task.title}</h3>
<p>{task.description}</p>
</article>
</li>
))}
</ul>
</section>
)
}
// ❌ Wrong: Overuse divs
function TaskList({ tasks }: TaskListProps) {
return (
<div>
<div>Tasks</div>
<div>
{tasks.map(task => (
<div key={task.id}>
<div>{task.title}</div>
</div>
))}
</div>
</div>
)
}
ARIA Attributes
// ✅ Correct: Use ARIA attributes
function Button({ loading, children }: ButtonProps) {
return (
<button
aria-busy={loading}
aria-label={loading ? "Loading..." : undefined}
disabled={loading}
>
{children}
</button>
)
}
🔒 Security
XSS Protection
// ✅ Correct: React auto-escapes
function TaskDescription({ description }: { description: string }) {
return <p>{description}</p>
}
// ⚠️ Caution: Use dangerouslySetInnerHTML carefully
import DOMPurify from "dompurify"
function TaskDescription({ html }: { html: string }) {
const sanitized = DOMPurify.sanitize(html)
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />
}
Environment Variables
// ✅ Correct: Use environment variables
const API_URL = process.env.NEXT_PUBLIC_API_URL
// NEXT_PUBLIC_ prefix exposes to client
// Without prefix, only available on server
✅ Code Review Checklist
Before submitting code, ensure:
- Code passes Biome (
pnpm lint) - Code is formatted (
pnpm format) - Code builds successfully (
pnpm build) - All components have TypeScript types
- Props interfaces are complete
- Follow naming conventions
- No
anytypes (unless necessary) - Large components are split
- Proper React Hooks usage
- Key props added to lists
- Semantic HTML used
- Accessibility considered
- API calls use Orval-generated hooks (no manual implementation)
- Translation text uses
useTranslations, no hard-coded text - TanStack Query Query Keys managed in
lib/query/keys.ts - Streaming APIs use manual implementation in
lib/api.ts - Code has appropriate comments
- Documentation updated
Happy Coding! ⚛️