Next.js 16 App Router Patterns
Patterns for Next.js 16 with App Router, TypeScript, and Tailwind 4.1.
Import Conventions
- •Same folder: Use relative
./imports (e.g.,import { Button } from './Button') - •Parent/other folders: Use
@/web/alias (e.g.,import { useQuizStore } from '@/stores/quizStore') - •Shared libs: Use package imports (e.g.,
import type { HairType } from '@hair-product-scanner/shared') - •NEVER use
../- parent imports must use@/web/alias
TypeScript Guidelines
Type Usage
- •Use TypeScript for all code; prefer
typeoverinterface - •Use explicit return types for all functions
- •Avoid
anyat all costs - •Use absolute imports with
@/...paths
typescript
// Good
type UserProps = {
id: string;
name: string;
email?: string;
};
// Avoid
interface UserProps {
id: string;
}
Code Style
- •Functional and declarative programming patterns
- •Avoid classes, use functions
- •Descriptive variable names with auxiliary verbs (
isLoading,hasError) - •Use
functionkeyword for pure functions - •Use declarative JSX
File Extensions and Naming
Extension Rules
Use .tsx ONLY for files containing JSX. Use .ts for everything else.
Use .ts for:
- •
index.ts- Barrel exports - •API services (
*.ts) - •Schemas (
*.schema.ts) - •Types (
types/*.ts) - •Hooks without JSX
- •Utilities
Use .tsx for:
- •React components
- •Pages and layouts (
app/**/*.tsx) - •Tests (
*.spec.tsx) - •Hooks that return JSX (rare)
File Naming by Location
code
apps/web/
├── app/ # kebab-case for routes
│ ├── (routes)/
│ │ └── quiz/
│ │ ├── page.tsx
│ │ └── layout.tsx
│ └── _dev/ # Dev preview routes
│ └── preview/
│ └── page.tsx
├── components/ # PascalCase for components
│ └── quiz/
│ ├── QuizCard.tsx
│ ├── QuizProgress.tsx
│ └── index.ts
├── hooks/ # camelCase starting with 'use'
│ └── useQuiz.ts
├── lib/ # camelCase for utilities
│ └── apiClient.ts
└── types/ # camelCase for type files
└── quiz.ts
Index Files
- •Use
index.tsfor barrel exports only - •Do not write code in index files
- •Favor named exports
typescript
// components/quiz/index.ts
export { QuizCard } from './QuizCard';
export { QuizProgress } from './QuizProgress';
export type { QuizCardProps } from './QuizCard';
React Component Patterns
Smart vs Presentational Components
Presentational Components: Pure UI, receive props, no hooks, no side effects. Smart Components: Handle logic, hooks, state, pass props to presentational.
tsx
// Presentational - no hooks, pure rendering
type QuizCardDisplayProps = {
question: string;
options: string[];
selectedOption: string | null;
onSelect: (option: string) => void;
isSubmitting: boolean;
};
function QuizCardDisplay({ question, options, selectedOption, onSelect, isSubmitting }: QuizCardDisplayProps) {
return (
<div className="bg-white rounded-lg p-6 shadow-sm">
<h2 className="text-xl font-semibold mb-4">{question}</h2>
<div className="flex flex-col gap-2">
{options.map((option) => (
<button key={option} onClick={() => onSelect(option)} disabled={isSubmitting} className={cn('p-4 rounded-lg text-left transition-colors', selectedOption === option ? 'bg-primary-500 text-white' : 'bg-gray-100 hover:bg-gray-200')}>
{option}
</button>
))}
</div>
</div>
);
}
// Smart - handles state and logic
export function QuizCard() {
const { currentQuestion, submitAnswer, isSubmitting } = useQuiz();
const [selected, setSelected] = useState<string | null>(null);
const handleSelect = (option: string) => {
setSelected(option);
};
const handleSubmit = () => {
if (selected) {
submitAnswer(selected);
setSelected(null);
}
};
return <QuizCardDisplay question={currentQuestion.text} options={currentQuestion.options} selectedOption={selected} onSelect={handleSelect} isSubmitting={isSubmitting} />;
}
Component Size Limit
Keep components under 100 lines. Break larger components into smaller pieces.
tsx
// Instead of one large component
function QuizScreen() {
/* 200 lines - BAD */
}
// Break into focused components
function QuizScreen() {
return (
<div className="min-h-screen bg-gray-50">
<QuizHeader />
<QuizProgress />
<QuizCard />
<QuizNavigation />
</div>
);
}
App Router Structure
Server vs Client Components
Server Components are default. Add 'use client' only when needed:
- •Using hooks (useState, useEffect, etc.)
- •Event handlers (onClick, onChange, etc.)
- •Browser APIs
tsx
// Server Component (default) - no directive needed
export default function QuizPage() {
return (
<main>
<QuizHeader />
<QuizContent /> {/* This can be client */}
</main>
);
}
// Client Component - needs directive
('use client');
import { useState } from 'react';
export function QuizContent() {
const [step, setStep] = useState(0);
return <div onClick={() => setStep((s) => s + 1)}>...</div>;
}
Route Organization
code
app/ ├── layout.tsx # Root layout (providers, metadata) ├── page.tsx # Home page ├── (routes)/ # Route groups │ ├── quiz/ │ │ ├── page.tsx # /quiz │ │ ├── layout.tsx # Quiz-specific layout │ │ └── [questionId]/ │ │ └── page.tsx # /quiz/:questionId │ └── scan/ │ └── page.tsx # /scan ├── _dev/ # Dev preview routes │ └── preview/ │ └── page.tsx └── api/ # API routes (if needed)
Form Patterns with React Hook Form + Zod
Schema Definition
typescript
// schemas/quiz.schema.ts
import { z } from 'zod';
export const quizAnswerSchema = z.object({
questionId: z.string().min(1),
selectedOption: z.string().min(1, 'Please select an option'),
});
export type QuizAnswerFormData = z.infer<typeof quizAnswerSchema>;
Form Component
tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { quizAnswerSchema, type QuizAnswerFormData } from '@/schemas/quiz.schema';
export function QuizForm({ questionId }: { questionId: string }) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<QuizAnswerFormData>({
resolver: zodResolver(quizAnswerSchema),
defaultValues: {
questionId,
selectedOption: '',
},
});
const onSubmit = async (data: QuizAnswerFormData) => {
// Handle submission
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<input type="hidden" {...register('questionId')} />
<div className="flex flex-col gap-2">
{options.map((option) => (
<label key={option} className="flex items-center gap-2">
<input type="radio" value={option} {...register('selectedOption')} className="w-4 h-4" />
{option}
</label>
))}
</div>
{errors.selectedOption && <p className="text-red-500 text-sm">{errors.selectedOption.message}</p>}
<button type="submit" disabled={isSubmitting} className="bg-primary-500 text-white px-4 py-2 rounded-lg disabled:opacity-50">
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
Tailwind 4.1 Styling
Class Organization
tsx
// Organize classes logically
<div
className={cn(
// Layout
'flex flex-col gap-4',
// Sizing
'w-full max-w-md',
// Spacing
'p-6 m-4',
// Visual
'bg-white rounded-lg shadow-sm',
// States
'hover:shadow-md transition-shadow'
)}
>
Responsive Design (Mobile-First)
tsx
<div
className={cn(
// Mobile (default)
'flex flex-col gap-2 p-4',
// Tablet
'sm:flex-row sm:gap-4 sm:p-6',
// Desktop
'lg:gap-8 lg:p-8'
)}
>
Conditional Classes
Use clsx or cn utility for conditional classes:
typescript
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
tsx
<button
className={cn(
'px-4 py-2 rounded-lg transition-colors',
isSelected
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
isDisabled && 'opacity-50 cursor-not-allowed'
)}
>
No Inline Styles
tsx
// Good
<div className="flex flex-col gap-4 p-4 bg-white rounded-lg">
// Bad - inline styles
<div style={{ display: 'flex', padding: '16px' }}>
State Management
State Management Rules
DO: Use Zustand for all client-side state DON'T: Create React Context providers for state management
Library providers are acceptable (they don't manage app state):
- •
QueryClientProvider(TanStack Query) - •
ThemeProvider(next-themes) - •Toast/notification providers
tsx
// BAD - Context Provider for state
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null); // State in provider = BAD
return <AuthContext.Provider value={{ user, setUser }}>{children}</AuthContext.Provider>;
}
// GOOD - Zustand Store (with persist for localStorage)
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
}),
{ name: 'auth-storage' }
)
);
Local UI State
tsx
// Allowed - local UI state only
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
Server State (TanStack Query)
typescript
// hooks/useQuiz.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function useQuiz(questionId: string) {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['quiz', questionId],
queryFn: () => quizApi.getQuestion(questionId),
});
const submitMutation = useMutation({
mutationFn: (answer: string) => quizApi.submitAnswer(questionId, answer),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['quiz'] });
},
});
return {
question: query.data,
isLoading: query.isLoading,
error: query.error,
submitAnswer: submitMutation.mutate,
isSubmitting: submitMutation.isPending,
};
}
Client State (Zustand)
typescript
// stores/quizStore.ts
import { create } from 'zustand';
type QuizStore = {
currentStep: number;
answers: Record<string, string>;
setCurrentStep: (step: number) => void;
setAnswer: (questionId: string, answer: string) => void;
reset: () => void;
};
export const useQuizStore = create<QuizStore>((set) => ({
currentStep: 0,
answers: {},
setCurrentStep: (step) => set({ currentStep: step }),
setAnswer: (questionId, answer) =>
set((state) => ({
answers: { ...state.answers, [questionId]: answer },
})),
reset: () => set({ currentStep: 0, answers: {} }),
}));
Best Practices
Component Organization
- •Type definitions at top
- •Component implementation
- •Helper functions
- •Exports at bottom
Error Boundaries
Wrap sections that might fail:
tsx
// app/error.tsx
'use client';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-xl font-semibold mb-4">Something went wrong</h2>
<button onClick={reset} className="bg-primary-500 text-white px-4 py-2 rounded-lg">
Try again
</button>
</div>
);
}
Loading States
tsx
// app/quiz/loading.tsx
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500" />
</div>
);
}