
Frontend Builder
- 86 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with frontend development tasks during AI-assisted development.
About
frontend-builder is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted coding.
- frontend-builder
- Frontend Development
- AI-coding skill
Frontend Builder by the numbers
- 86 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,094 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill frontend-builderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 86 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with frontend development tasks during AI-assisted development.
Files
Frontend Builder
Overview
Builds maintainable, performant React and Next.js frontends using a server-first architecture. Covers component design, state management, data fetching, forms, styling, and performance optimization. Not for backend API design, database schema, or deployment infrastructure.
Quick Reference
| Pattern | Approach | Key Points |
|---|---|---|
| Framework | Next.js App Router (default), React + Vite (SPAs) | Server-first rendering, file-based routing |
| Components | Page, Feature, UI, Layout types | Single responsibility, typed props, composition |
| Server vs Client | Server Components default, 'use client' at leaf nodes | Push interactivity to edges of component tree |
| State (local) | useState, props, lift to parent | Keep state close to where it is consumed |
| State (global) | Context API (theme, auth), Zustand (complex) | Avoid Context for frequently changing values |
| Data fetching | Server Components (server), TanStack Query (client) | Server Actions for mutations, revalidatePath for cache |
| Forms | React Hook Form + Zod, or Server Actions + useActionState | Schema validation, optimistic updates with useOptimistic |
| Styling | Tailwind CSS v4 + shadcn/ui | CSS-first config with @theme, OKLCH colors |
| Performance | Suspense streaming, code splitting, memoization | React.lazy(), next/dynamic, selective memo() |
| Error handling | Error boundaries, error.tsx per route | Wrap feature sections, not individual components |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Adding 'use client' to every component | Default to Server Components; add 'use client' only for interactivity |
| Giant multi-responsibility component | Break into focused sub-components with single purposes |
| Placing all state at the top of the component tree | Keep state as close to where it is consumed as possible |
Using useEffect to compute derived data | Use useMemo for derived values; reserve useEffect for side effects |
| Missing error boundaries around feature sections | Wrap feature areas with error boundaries to prevent full-page crashes |
| Creating API routes for simple mutations | Use Server Actions with 'use server' for form submissions and mutations |
| Passing non-serializable props to Client Components | Props crossing server/client boundary must be serializable (no functions, classes) |
Using tailwind.config.js with Tailwind v4 | Use CSS-first configuration with @theme directive in CSS file |
| Fetching data in Client Components when Server Components suffice | Fetch in Server Components by default; use TanStack Query only when client-side caching is needed |
Delegation
When building frontends, delegate to specialized skills:
react-patterns-- React hooks, rendering patterns, and performance optimizationnextjs-- Next.js routing, middleware, and deployment configurationtanstack-query-- Client-side data fetching, caching, and mutationstanstack-form-- Complex form handling and field-level validationtailwind-- Tailwind CSS utility patterns and responsive designdesign-system-- Token hierarchy and component architectureperformance-optimizer-- Profiling, bundle analysis, and Core Web Vitals
References
- Component Architecture -- Component types, folder structure, TypeScript patterns, and composition
- Server Components -- Server/client boundary, Server Actions, Suspense streaming, and data flow
- State Management -- useState, Context API, Zustand, and URL state patterns
- Data Fetching -- TanStack Query, Server Components data, and cache revalidation
- Forms and Validation -- React Hook Form, Zod schemas, Server Actions, and useActionState
- Styling -- Tailwind CSS v4, shadcn/ui, CSS-first config, and responsive patterns
- Performance and Errors -- Memoization, code splitting, Suspense streaming, and error boundaries
Component Architecture
Component Types
Page Components
Route entry points that compose feature and layout components. In Next.js App Router, these are Server Components by default.
// app/users/page.tsx
import { UserList } from '@/components/features/user-list';
export default async function UsersPage() {
const users = await fetchUsers();
return (
<main className="container mx-auto py-8">
<h1 className="text-2xl font-bold mb-6">Users</h1>
<UserList users={users} />
</main>
);
}Feature Components
Contain business logic and data handling. Compose UI components together for a specific use case.
// components/features/user-list.tsx
'use client';
import { useState } from 'react';
import { type User } from '@/lib/types';
import { UserCard } from '@/components/ui/user-card';
import { Input } from '@/components/ui/input';
interface UserListProps {
users: User[];
}
export function UserList({ users }: UserListProps) {
const [search, setSearch] = useState('');
const filtered = users.filter((user) =>
user.name.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="space-y-4">
<Input
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filtered.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
</div>
);
}UI Components
Reusable, stateless presentation components with no business logic. These map to shadcn/ui primitives or custom design system atoms.
// components/ui/user-card.tsx
import { type User } from '@/lib/types';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
interface UserCardProps {
user: User;
}
export function UserCard({ user }: UserCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center gap-3">
<Avatar>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>{user.name[0]}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{user.name}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
</CardHeader>
</Card>
);
}Layout Components
Structural components for page chrome: headers, sidebars, footers.
// components/layouts/sidebar-layout.tsx
interface SidebarLayoutProps {
sidebar: React.ReactNode;
children: React.ReactNode;
}
export function SidebarLayout({ sidebar, children }: SidebarLayoutProps) {
return (
<div className="flex min-h-screen">
<aside className="w-64 border-r bg-muted/50">{sidebar}</aside>
<main className="flex-1 p-6">{children}</main>
</div>
);
}TypeScript Prop Patterns
Typed Props with Defaults
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'destructive';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
children: React.ReactNode;
onClick?: () => void;
}
export function Button({
variant = 'primary',
size = 'md',
disabled = false,
children,
onClick,
}: ButtonProps) {
return (
<button disabled={disabled} onClick={onClick}>
{children}
</button>
);
}Extending HTML Elements
import { type ComponentPropsWithoutRef } from 'react';
interface InputProps extends ComponentPropsWithoutRef<'input'> {
label: string;
error?: string;
}
export function Input({ label, error, className, ...props }: InputProps) {
return (
<div>
<label className="text-sm font-medium">{label}</label>
<input className={cn('border rounded px-3 py-2', className)} {...props} />
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</div>
);
}Discriminated Union Props
type NotificationProps =
| { variant: 'success'; message: string }
| { variant: 'error'; message: string; retry: () => void }
| { variant: 'loading' };
export function Notification(props: NotificationProps) {
switch (props.variant) {
case 'success':
return <div className="text-green-600">{props.message}</div>;
case 'error':
return (
<div className="text-red-600">
{props.message}
<button onClick={props.retry}>Retry</button>
</div>
);
case 'loading':
return <div className="animate-pulse">Loading...</div>;
}
}Folder Structure (Next.js App Router)
app/
├── (auth)/ # Route group (no URL segment)
│ ├── login/page.tsx
│ └── signup/page.tsx
├── (dashboard)/
│ ├── layout.tsx # Shared dashboard layout
│ ├── page.tsx
│ └── settings/page.tsx
├── api/ # Route Handlers
│ └── users/route.ts
├── error.tsx # Root error boundary
├── loading.tsx # Root loading UI
├── layout.tsx # Root layout
└── page.tsx # Home page
components/
├── ui/ # shadcn/ui primitives
│ ├── button.tsx
│ ├── input.tsx
│ └── dialog.tsx
├── features/ # Business logic components
│ ├── user-list.tsx
│ └── user-profile.tsx
└── layouts/ # Page structure
├── header.tsx
└── sidebar.tsx
lib/
├── utils.ts # cn() and shared utilities
├── api.ts # API client configuration
└── schemas.ts # Shared Zod schemas
hooks/
├── use-debounce.ts
└── use-media-query.ts
stores/
└── user-store.ts # Zustand storesComposition Patterns
Compound Components
function Tabs({ children }: { children: React.ReactNode }) {
const [activeTab, setActiveTab] = useState(0);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div>{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }: { children: React.ReactNode }) {
return (
<div role="tablist" className="flex border-b">
{children}
</div>
);
}
function TabPanel({
index,
children,
}: {
index: number;
children: React.ReactNode;
}) {
const { activeTab } = useTabsContext();
return activeTab === index ? <div role="tabpanel">{children}</div> : null;
}
Tabs.List = TabList;
Tabs.Panel = TabPanel;Render Props (Headless Components)
interface DataListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
emptyState?: React.ReactNode;
}
export function DataList<T>({
items,
renderItem,
emptyState,
}: DataListProps<T>) {
if (items.length === 0) {
return <>{emptyState ?? <p>No items found.</p>}</>;
}
return <div className="space-y-2">{items.map(renderItem)}</div>;
}Data Fetching
Decision Tree
| Scenario | Approach |
|---|---|
| Static or semi-static data | Server Component with fetch |
| User-specific server data | Server Component (reads cookies/headers) |
| Interactive list with filters | Server Component initial + TanStack Query client-side |
| Real-time or frequently polled data | TanStack Query with refetchInterval |
| Mutations (create, update, delete) | Server Actions or TanStack Query useMutation |
| Infinite scroll / pagination | TanStack Query useInfiniteQuery |
Server Component Data Fetching
Server Components can fetch data directly using async/await. No client-side JavaScript is shipped for the data-fetching logic.
// app/products/page.tsx
import { Suspense } from 'react';
export default function ProductsPage() {
return (
<div>
<h1>Products</h1>
<Suspense fallback={<ProductsSkeleton />}>
<ProductList />
</Suspense>
</div>
);
}
async function ProductList() {
const products = await db.select().from(productsTable);
return (
<div className="grid gap-4 md:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}Parallel Data Fetching
// app/dashboard/page.tsx
export default async function DashboardPage() {
const [stats, recentOrders, topProducts] = await Promise.all([
fetchStats(),
fetchRecentOrders(),
fetchTopProducts(),
]);
return (
<div>
<StatsGrid stats={stats} />
<RecentOrders orders={recentOrders} />
<TopProducts products={topProducts} />
</div>
);
}TanStack Query (Client-Side)
Use TanStack Query when you need client-side caching, background refetching, optimistic updates, or interactive data management.
Provider Setup
// providers/query-provider.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}Queries
'use client';
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
const {
data: user,
isLoading,
error,
} = useQuery({
queryKey: ['users', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
staleTime: 5 * 60 * 1000,
});
if (isLoading) return <Skeleton className="h-32" />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}Dependent Queries
function UserPosts({ userId }: { userId: string }) {
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
});
const { data: posts } = useQuery({
queryKey: ['posts', { authorId: user?.id }],
queryFn: () => fetchPostsByAuthor(user!.id),
enabled: !!user,
});
return posts ? <PostList posts={posts} /> : <Skeleton />;
}Mutations with Invalidation
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreatePostForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: { title: string; body: string }) =>
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(data),
}).then((r) => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
title: formData.get('title') as string,
body: formData.get('body') as string,
});
}}
>
<input name="title" required />
<textarea name="body" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</button>
{mutation.isError ? (
<p className="text-destructive">{mutation.error.message}</p>
) : null}
</form>
);
}Optimistic Updates
const mutation = useMutation({
mutationFn: toggleLike,
onMutate: async (postId: string) => {
await queryClient.cancelQueries({ queryKey: ['posts', postId] });
const previous = queryClient.getQueryData<Post>(['posts', postId]);
queryClient.setQueryData<Post>(['posts', postId], (old) =>
old
? {
...old,
liked: !old.liked,
likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
}
: old,
);
return { previous };
},
onError: (_err, postId, context) => {
queryClient.setQueryData(['posts', postId], context?.previous);
},
onSettled: (_data, _err, postId) => {
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
},
});Cache Revalidation (Server Actions)
After mutations via Server Actions, revalidate the Next.js cache to reflect changes.
// app/posts/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
await db.insert(posts).values({
title: formData.get('title') as string,
body: formData.get('body') as string,
});
revalidatePath('/posts');
}
export async function updatePost(id: string, formData: FormData) {
await db
.update(posts)
.set({ title: formData.get('title') as string })
.where(eq(posts.id, id));
revalidateTag(`post-${id}`);
revalidatePath('/posts');
}Tag-Based Revalidation
// app/posts/[id]/page.tsx
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await fetch(`${API_URL}/posts/${params.id}`, {
next: { tags: [`post-${params.id}`] },
}).then((r) => r.json());
return <PostContent post={post} />;
}API Client Pattern
Centralize API calls with a typed client to avoid scattered fetch calls.
// lib/api.ts
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '/api';
async function apiClient<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
});
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
}
return res.json();
}
export const api = {
users: {
list: () => apiClient<User[]>('/users'),
get: (id: string) => apiClient<User>(`/users/${id}`),
create: (data: CreateUser) =>
apiClient<User>('/users', { method: 'POST', body: JSON.stringify(data) }),
},
posts: {
list: () => apiClient<Post[]>('/posts'),
get: (id: string) => apiClient<Post>(`/posts/${id}`),
},
};Forms and Validation
Choosing an Approach
| Pattern | When to Use |
|---|---|
Server Action + useActionState | Simple forms with server-side validation |
| React Hook Form + Zod | Complex client-side forms with instant feedback |
| Server Action + React Hook Form | Client validation first, then server mutation |
React Hook Form with Zod
Standard approach for complex forms with immediate client-side validation.
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const createUserSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
role: z.enum(['admin', 'editor', 'viewer']),
bio: z.string().max(500, 'Bio must be under 500 characters').optional(),
});
type CreateUserForm = z.infer<typeof createUserSchema>;
export function CreateUserForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<CreateUserForm>({
resolver: zodResolver(createUserSchema),
defaultValues: {
role: 'viewer',
},
});
const onSubmit = async (data: CreateUserForm) => {
const res = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data),
});
if (res.ok) reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="text-sm font-medium">Name</label>
<input
{...register('name')}
className="border rounded px-3 py-2 w-full"
/>
{errors.name ? (
<p className="text-sm text-destructive">{errors.name.message}</p>
) : null}
</div>
<div>
<label className="text-sm font-medium">Email</label>
<input
{...register('email')}
type="email"
className="border rounded px-3 py-2 w-full"
/>
{errors.email ? (
<p className="text-sm text-destructive">{errors.email.message}</p>
) : null}
</div>
<div>
<label className="text-sm font-medium">Role</label>
<select
{...register('role')}
className="border rounded px-3 py-2 w-full"
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="text-sm font-medium">Bio</label>
<textarea
{...register('bio')}
className="border rounded px-3 py-2 w-full"
/>
{errors.bio ? (
<p className="text-sm text-destructive">{errors.bio.message}</p>
) : null}
</div>
<button
type="submit"
disabled={isSubmitting}
className="bg-primary text-primary-foreground px-4 py-2 rounded"
>
{isSubmitting ? 'Creating...' : 'Create User'}
</button>
</form>
);
}Server Actions with useActionState
For forms that submit directly to the server with built-in pending state management.
Define the Action
// app/contacts/actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const contactSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
export type ContactState = {
errors?: Record<string, string[]>;
message?: string;
} | null;
export async function submitContact(
_prevState: ContactState,
formData: FormData,
): Promise<ContactState> {
const result = contactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
await db.insert(contacts).values(result.data);
revalidatePath('/contacts');
return { message: 'Message sent successfully!' };
}Use in a Client Component
// components/features/contact-form.tsx
'use client';
import { useActionState } from 'react';
import { submitContact } from '@/app/contacts/actions';
export function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, null);
return (
<form action={formAction} className="space-y-4">
<div>
<label className="text-sm font-medium">Name</label>
<input
name="name"
required
className="border rounded px-3 py-2 w-full"
/>
{state?.errors?.name ? (
<p className="text-sm text-destructive">{state.errors.name[0]}</p>
) : null}
</div>
<div>
<label className="text-sm font-medium">Email</label>
<input
name="email"
type="email"
required
className="border rounded px-3 py-2 w-full"
/>
{state?.errors?.email ? (
<p className="text-sm text-destructive">{state.errors.email[0]}</p>
) : null}
</div>
<div>
<label className="text-sm font-medium">Message</label>
<textarea
name="message"
required
className="border rounded px-3 py-2 w-full"
/>
{state?.errors?.message ? (
<p className="text-sm text-destructive">{state.errors.message[0]}</p>
) : null}
</div>
<button
type="submit"
disabled={isPending}
className="bg-primary text-primary-foreground px-4 py-2 rounded"
>
{isPending ? 'Sending...' : 'Send Message'}
</button>
{state?.message ? (
<p className="text-sm text-green-600">{state.message}</p>
) : null}
</form>
);
}Optimistic Form Updates
Use useOptimistic to show immediate feedback while the server processes the mutation.
'use client';
import { useOptimistic } from 'react';
import { type Todo } from '@/lib/types';
import { toggleTodo } from '@/app/todos/actions';
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, setOptimisticTodo] = useOptimistic(
todos,
(state, updatedId: string) =>
state.map((todo) =>
todo.id === updatedId ? { ...todo, completed: !todo.completed } : todo,
),
);
return (
<ul className="space-y-2">
{optimisticTodos.map((todo) => (
<li key={todo.id} className="flex items-center gap-2">
<form
action={async () => {
setOptimisticTodo(todo.id);
await toggleTodo(todo.id);
}}
>
<button type="submit">{todo.completed ? '[x]' : '[ ]'}</button>
</form>
<span className={todo.completed ? 'line-through opacity-50' : ''}>
{todo.title}
</span>
</li>
))}
</ul>
);
}Shared Zod Schemas
Define schemas once and reuse for both client validation and Server Actions.
// lib/schemas.ts
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export const signupSchema = loginSchema
.extend({
name: z.string().min(2, 'Name required'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
export type LoginInput = z.infer<typeof loginSchema>;
export type SignupInput = z.infer<typeof signupSchema>;Performance and Errors
When to Memoize
Memoization adds complexity. Only use it when you have measured a performance problem or when a component is demonstrably expensive to re-render.
| Tool | Purpose | Use When |
|---|---|---|
useMemo | Cache computed values | Expensive calculations (sorting, filtering large lists) |
useCallback | Stable function reference | Passing callbacks to memoized children |
memo() | Skip re-renders if props unchanged | Component renders frequently with same props |
useMemo for Expensive Computations
function DataTable({ data, sortField }: { data: Row[]; sortField: string }) {
const sortedData = useMemo(
() => [...data].sort((a, b) => a[sortField].localeCompare(b[sortField])),
[data, sortField],
);
return (
<table>
<tbody>
{sortedData.map((row) => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.email}</td>
</tr>
))}
</tbody>
</table>
);
}memo for Expensive Children
const ExpensiveChart = memo(function ExpensiveChart({
data,
onSelect,
}: {
data: ChartData[];
onSelect: (point: ChartData) => void;
}) {
return <canvas>{/* expensive rendering logic */}</canvas>;
});
function Dashboard({ data }: { data: ChartData[] }) {
const handleSelect = useCallback((point: ChartData) => {
console.log('Selected:', point);
}, []);
return <ExpensiveChart data={data} onSelect={handleSelect} />;
}Code Splitting
React.lazy (Vite/React)
import { lazy, Suspense } from 'react';
const HeavyEditor = lazy(() => import('./heavy-editor'));
function EditorPage() {
return (
<Suspense fallback={<Skeleton className="h-96" />}>
<HeavyEditor />
</Suspense>
);
}next/dynamic (Next.js)
import dynamic from 'next/dynamic';
const RichTextEditor = dynamic(() => import('@/components/rich-text-editor'), {
loading: () => <Skeleton className="h-64" />,
ssr: false,
});
export default function EditPage() {
return <RichTextEditor />;
}Route-Level Splitting
Next.js App Router automatically code-splits at the page level. Each page.tsx gets its own bundle.
Next.js Optimization
Image Optimization
import Image from 'next/image';
export function ProductImage({ product }: { product: Product }) {
return (
<Image
src={product.imageUrl}
alt={product.name}
width={600}
height={400}
priority={false}
placeholder="blur"
blurDataURL={product.blurHash}
className="rounded-lg object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
);
}Use priority only for above-the-fold images (hero images, LCP elements). Provide sizes to serve appropriately sized images at each breakpoint.
Font Optimization
// app/layout.tsx
import { Inter, JetBrains_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
variable: '--font-sans',
});
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}Suspense Streaming
Wrap independent data sections in <Suspense> boundaries. Each section streams to the client as its data resolves, avoiding waterfall loading.
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div className="grid gap-6 md:grid-cols-2">
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<ListSkeleton />}>
<TopCustomers />
</Suspense>
</div>
);
}
async function StatsPanel() {
const stats = await fetchStats();
return (
<div className="grid grid-cols-2 gap-4">
{stats.map((stat) => (
<div key={stat.label} className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">{stat.label}</p>
<p className="text-2xl font-bold">{stat.value}</p>
</div>
))}
</div>
);
}Loading UI Convention
Next.js App Router supports loading.tsx files for automatic Suspense wrapping at the route level.
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-48" />
<Skeleton className="h-48" />
</div>
</div>
);
}Error Boundaries
Custom Error Boundary Component
'use client';
import { Component, type ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<h3 className="font-medium text-destructive">
Something went wrong
</h3>
<p className="text-sm text-muted-foreground">
{this.state.error?.message}
</p>
</div>
)
);
}
return this.props.children;
}
}Next.js Route Error Handling
// app/dashboard/error.tsx
'use client';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-16">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-muted-foreground">{error.message}</p>
<button
onClick={reset}
className="rounded-md bg-primary px-4 py-2 text-primary-foreground"
>
Try again
</button>
</div>
);
}Not Found Handling
// app/users/[id]/not-found.tsx
export default function UserNotFound() {
return (
<div className="flex flex-col items-center justify-center py-16">
<h2 className="text-xl font-semibold">User not found</h2>
<p className="text-muted-foreground">
The user you are looking for does not exist.
</p>
</div>
);
}// app/users/[id]/page.tsx
import { notFound } from 'next/navigation';
export default async function UserPage({ params }: { params: { id: string } }) {
const user = await fetchUser(params.id);
if (!user) notFound();
return <UserProfile user={user} />;
}Strategic Error Boundary Placement
Wrap feature sections independently so a failure in one section does not crash the entire page.
export default function DashboardPage() {
return (
<div className="space-y-6">
<ErrorBoundary fallback={<p>Failed to load stats</p>}>
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<p>Failed to load chart</p>}>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</ErrorBoundary>
</div>
);
}Server Components
Server-First Mental Model
Default to Server Components for everything. Add 'use client' only at the leaves of the component tree where interactivity is needed. The root of the application is a Server Component that contains "islands" of Client Components.
// app/dashboard/page.tsx -- Server Component (default)
import { Suspense } from 'react';
import { DashboardStats } from '@/components/features/dashboard-stats';
import { RecentActivity } from '@/components/features/recent-activity';
import { InteractiveChart } from '@/components/features/interactive-chart';
export default async function DashboardPage() {
const stats = await fetchDashboardStats();
return (
<div className="space-y-6">
<DashboardStats stats={stats} />
<Suspense fallback={<ChartSkeleton />}>
<InteractiveChart />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}Server vs Client Components
| Aspect | Server Component | Client Component |
|---|---|---|
| Directive | None (default) | 'use client' at top of file |
| Runs on | Server only | Server (SSR) + Client (hydration) |
| Can use hooks | No | Yes |
| Can use browser APIs | No | Yes |
| Can access DB/filesystem | Yes | No |
| Ships JS to client | No | Yes |
| Can render Server Components | Yes | Only as children prop |
The Client Boundary
When you add 'use client' to a file, all components imported into that file become part of the client bundle. Place the directive as deep in the tree as possible.
// components/features/search-bar.tsx
'use client';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
export function SearchBar({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
return (
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch(query);
}}
/>
);
}Passing Server Components as Children
Client Components cannot import Server Components directly, but they can receive them as children or other React node props.
// components/layouts/interactive-panel.tsx
'use client';
import { useState } from 'react';
interface InteractivePanelProps {
children: React.ReactNode;
sidebar: React.ReactNode;
}
export function InteractivePanel({ children, sidebar }: InteractivePanelProps) {
const [isOpen, setIsOpen] = useState(true);
return (
<div className="flex">
{isOpen ? <aside className="w-64">{sidebar}</aside> : null}
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
<main>{children}</main>
</div>
);
}// app/page.tsx -- Server Component composing client + server
import { InteractivePanel } from '@/components/layouts/interactive-panel';
import { ServerSidebar } from '@/components/features/server-sidebar';
export default async function Page() {
const data = await fetchData();
return (
<InteractivePanel sidebar={<ServerSidebar />}>
<div>{data.content}</div>
</InteractivePanel>
);
}Preventing Code Leakage
Use server-only and client-only packages to enforce boundaries at build time.
// lib/db.ts
import 'server-only';
export async function getUsers() {
return db.select().from(users);
}// lib/analytics.ts
import 'client-only';
export function trackEvent(name: string) {
window.gtag('event', name);
}Server Actions
Server Actions allow Client Components to call server-side functions directly. They replace API routes for most mutation patterns.
Inline Server Action
// app/users/page.tsx
export default function UsersPage() {
async function createUser(formData: FormData) {
'use server';
const name = formData.get('name') as string;
await db.insert(users).values({ name });
revalidatePath('/users');
}
return (
<form action={createUser}>
<input name="name" required />
<button type="submit">Create User</button>
</form>
);
}Separate Actions File
// app/users/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
export async function createUser(formData: FormData) {
const result = createUserSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
});
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
await db.insert(users).values(result.data);
revalidatePath('/users');
redirect('/users');
}Using Server Actions with useActionState
// components/features/create-user-form.tsx
'use client';
import { useActionState } from 'react';
import { createUser } from '@/app/users/actions';
export function CreateUserForm() {
const [state, formAction, isPending] = useActionState(createUser, null);
return (
<form action={formAction}>
<input name="name" required />
<input name="email" type="email" required />
{state?.error ? (
<p className="text-sm text-destructive">
{JSON.stringify(state.error)}
</p>
) : null}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create User'}
</button>
</form>
);
}Suspense Streaming
Wrap independent data-fetching sections in <Suspense> to stream UI progressively. Each boundary resolves independently.
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-6">
<Suspense fallback={<Skeleton className="h-48" />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<Skeleton className="h-48" />}>
<UserGrowth />
</Suspense>
<Suspense fallback={<Skeleton className="h-64 col-span-2" />}>
<RecentOrders />
</Suspense>
</div>
);
}
async function RevenueChart() {
const data = await fetchRevenue();
return <Chart data={data} />;
}
async function UserGrowth() {
const data = await fetchUserGrowth();
return <Chart data={data} />;
}Optimistic Updates with Server Actions
'use client';
import { useOptimistic } from 'react';
import { type Message } from '@/lib/types';
import { sendMessage } from '@/app/chat/actions';
export function MessageList({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(state, newMessage: string) => [
...state,
{ id: 'temp', text: newMessage, sending: true },
],
);
async function handleSend(formData: FormData) {
const text = formData.get('text') as string;
addOptimistic(text);
await sendMessage(formData);
}
return (
<div>
{optimisticMessages.map((msg) => (
<div key={msg.id} className={msg.sending ? 'opacity-50' : ''}>
{msg.text}
</div>
))}
<form action={handleSend}>
<input name="text" required />
<button type="submit">Send</button>
</form>
</div>
);
}State Management
Decision Tree
| Scope | Solution | When to Use |
|---|---|---|
| Single component | useState | Toggle, input value, local UI state |
| Parent + children | Props drilling | 1-2 levels deep |
| Siblings | Lift state to common parent | Shared state between adjacent components |
| App-wide (infrequent updates) | Context API | Theme, auth, locale |
| Complex client state | Zustand | Shopping cart, multi-step wizard, filters |
| Shareable/bookmarkable | URL search params | Pagination, filters, tabs, sort order |
| Server state | TanStack Query | API data with caching and revalidation |
Local State (useState)
function TogglePanel() {
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<'info' | 'settings'>('info');
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? 'Collapse' : 'Expand'}
</button>
{isOpen ? (
<div>
<div className="flex gap-2">
<button onClick={() => setActiveTab('info')}>Info</button>
<button onClick={() => setActiveTab('settings')}>Settings</button>
</div>
{activeTab === 'info' ? <InfoPanel /> : <SettingsPanel />}
</div>
) : null}
</div>
);
}Derived State (No Extra useState)
function UserList({ users }: { users: User[] }) {
const [search, setSearch] = useState('');
const filteredUsers = useMemo(
() =>
users.filter((u) => u.name.toLowerCase().includes(search.toLowerCase())),
[users, search],
);
const userCount = filteredUsers.length;
return (
<div>
<Input value={search} onChange={(e) => setSearch(e.target.value)} />
<p>{userCount} users found</p>
{filteredUsers.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}Context API
Best for infrequently changing values (theme, auth, locale). Avoid for state that updates frequently since all consumers re-render on every change.
// providers/theme-provider.tsx
'use client';
import { createContext, useContext, useState, type ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const toggleTheme = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
}Context with Reducer (Complex Updates)
'use client';
import { createContext, useContext, useReducer, type ReactNode } from 'react';
interface AuthState {
user: User | null;
isLoading: boolean;
}
type AuthAction =
| { type: 'LOGIN_START' }
| { type: 'LOGIN_SUCCESS'; user: User }
| { type: 'LOGOUT' };
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'LOGIN_START':
return { ...state, isLoading: true };
case 'LOGIN_SUCCESS':
return { user: action.user, isLoading: false };
case 'LOGOUT':
return { user: null, isLoading: false };
}
}
const AuthContext = createContext<
| {
state: AuthState;
dispatch: React.Dispatch<AuthAction>;
}
| undefined
>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(authReducer, {
user: null,
isLoading: false,
});
return (
<AuthContext.Provider value={{ state, dispatch }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
}Zustand (Complex Client State)
Zustand provides a lightweight store that works outside React's component tree. Use selectors to prevent unnecessary re-renders.
// stores/cart-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
total: () => number;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i,
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
})),
clearCart: () => set({ items: [] }),
total: () =>
get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}),
{ name: 'cart-storage' },
),
);Using Selectors
function CartCount() {
const itemCount = useCartStore((state) => state.items.length);
return <span className="badge">{itemCount}</span>;
}
function CartTotal() {
const total = useCartStore((state) => state.total());
return <span>${total.toFixed(2)}</span>;
}URL State
Use URL search params for state that should be shareable, bookmarkable, or survive page refreshes. The nuqs library provides type-safe URL state management for Next.js.
'use client';
import { useQueryState, parseAsInteger, parseAsStringEnum } from 'nuqs';
const sortOptions = ['name', 'date', 'price'] as const;
export function ProductFilters() {
const [search, setSearch] = useQueryState('q', { defaultValue: '' });
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [sort, setSort] = useQueryState(
'sort',
parseAsStringEnum(sortOptions).withDefault('name'),
);
return (
<div className="flex gap-4">
<Input value={search} onChange={(e) => setSearch(e.target.value)} />
<Select value={sort} onValueChange={setSort}>
{sortOptions.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</Select>
<Pagination page={page} onPageChange={setPage} />
</div>
);
}Styling
Tailwind CSS v4 Configuration
Tailwind v4 uses CSS-first configuration. Define design tokens with the @theme directive directly in CSS instead of tailwind.config.js.
/* app/globals.css */
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0.064 285.89);
--primary-foreground: oklch(0.985 0 0);
--muted: oklch(0.965 0 0);
--muted-foreground: oklch(0.556 0 0);
--destructive: oklch(0.577 0.245 27.33);
--border: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.5rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0.064 285.89);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--destructive: oklch(0.704 0.191 22.22);
--border: oklch(1 0 0 / 10%);
--ring: oklch(0.556 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
}The cn Utility
Merge Tailwind classes conditionally using clsx + tailwind-merge.
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}shadcn/ui Component Patterns
shadcn/ui components are copied into your project. Customize them directly.
Variant-Based Components with cva
// components/ui/button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-white hover:bg-destructive/90',
outline: 'border border-border bg-background hover:bg-muted',
ghost: 'hover:bg-muted',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
interface ButtonProps
extends
React.ComponentPropsWithoutRef<'button'>,
VariantProps<typeof buttonVariants> {}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}Composing shadcn Components
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
export function ConfirmDialog({
title,
description,
onConfirm,
children,
}: {
title: string;
description: string;
onConfirm: () => void;
children: React.ReactNode;
}) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline">Cancel</Button>
<Button variant="destructive" onClick={onConfirm}>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}Responsive Design
Use Tailwind's mobile-first breakpoints. Design for mobile, then layer on larger screen styles.
export function ProductGrid({ products }: { products: Product[] }) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}Responsive Typography and Spacing
export function HeroSection() {
return (
<section className="px-4 py-12 sm:px-6 sm:py-16 lg:px-8 lg:py-24">
<h1 className="text-3xl font-bold sm:text-4xl lg:text-6xl">
Build faster
</h1>
<p className="mt-4 text-lg text-muted-foreground sm:text-xl">
Ship production frontends with confidence.
</p>
</section>
);
}Dark Mode
Toggle dark mode by adding/removing the dark class on the root element. Pair with a theme provider.
// components/theme-toggle.tsx
'use client';
import { useTheme } from '@/providers/theme-provider';
import { Button } from '@/components/ui/button';
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<Button variant="ghost" size="icon" onClick={toggleTheme}>
{theme === 'light' ? <MoonIcon /> : <SunIcon />}
</Button>
);
}CSS Modules (Alternative)
For projects not using Tailwind or with existing CSS Module conventions.
/* components/card.module.css */
.card {
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
padding: 1.5rem;
}
.card:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 8%);
}
.title {
font-size: 1.125rem;
font-weight: 600;
}// components/card.tsx
import styles from './card.module.css';
export function Card({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className={styles.card}>
<h3 className={styles.title}>{title}</h3>
{children}
</div>
);
}