Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Next.js 16+ uses App Router with Server Components by default. Client Components are only used when interactivity is needed (hooks, event handlers, browser APIs).
.claude/skills/aiskillstore-frontend-component/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 43% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 248% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 187% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 235% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 139% | 0% |
Purpose: Guidance for creating Next.js components following server/client patterns and existing component structures.
Next.js 16+ uses App Router with Server Components by default. Client Components are only used when interactivity is needed (hooks, event handlers, browser APIs).
When to Use:
Pattern:
typescript// No "use client" directive import { Metadata } from "next"; export const metadata: Metadata = { title: "Page Title", }; export default function PageComponent() { return <div>Static content</div>; }
Example: frontend/app/layout.tsx, frontend/app/page.tsx
When to Use:
Pattern:
typescript"use client"; // MUST be first line import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; interface ComponentProps { prop1: string; prop2?: number; } export default function ComponentName({ prop1, prop2 }: ComponentProps) { const router = useRouter(); const [state, setState] = useState(""); useEffect(() => { // Side effects }, []); return <div>{/* Component JSX */}</div>; }
Example: frontend/components/ProtectedRoute.tsx, frontend/app/signup/page.tsx
typescript"use client"; // Only if client component /** * Component Name * * Brief description of what this component does */ import { useState, useEffect } from "react"; import { ComponentType } from "@/types"; import { cn } from "@/lib/utils"; interface ComponentProps { prop1: string; prop2?: number; className?: string; } export default function ComponentName({ prop1, prop2, className }: ComponentProps) { // State const [state, setState] = useState(""); // Effects useEffect(() => { // Side effects }, []); // Handlers const handleClick = () => { // Handler logic }; // Render return ( <div className={cn("base-classes", className)}> {/* Component content */} </div> ); }
From: frontend/components/ProtectedRoute.tsx
typescript"use client"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { isAuthenticated } from "@/lib/auth"; import LoadingSpinner from "./LoadingSpinner"; interface ProtectedRouteProps { children: React.ReactNode; } export default function ProtectedRoute({ children }: ProtectedRouteProps) { const router = useRouter(); const [isAuthorized, setIsAuthorized] = useState(false); const [isChecking, setIsChecking] = useState(true); useEffect(() => { async function checkAuth() { try { const authenticated = await isAuthenticated(); if (!authenticated) { const currentPath = window.location.pathname; if (currentPath !== "/signin") { sessionStorage.setItem("redirectAfterLogin", currentPath); } router.push("/signin"); } else { setIsAuthorized(true); } } catch (error) { console.error("Auth check failed:", error); router.push("/signin"); } finally { setIsChecking(false); } } checkAuth(); }, [router]); if (isChecking) { return ( <div className="min-h-screen flex items-center justify-center"> <LoadingSpinner size="large" /> </div> ); } if (!isAuthorized) { return null; } return <>{children}</>; }
Pattern:
/signin if not authenticatedFrom: frontend/components/LoadingSpinner.tsx
typescriptinterface LoadingSpinnerProps { size?: "small" | "medium" | "large"; color?: string; label?: string; } export default function LoadingSpinner({ size = "medium", color = "blue", label = "Loading...", }: LoadingSpinnerProps) { const sizeClasses = { small: "w-4 h-4 border-2", medium: "w-8 h-8 border-3", large: "w-12 h-12 border-4", }; const colorClasses = { blue: "border-blue-600 border-t-transparent", gray: "border-gray-600 border-t-transparent", white: "border-white border-t-transparent", }; const spinnerClass = `${sizeClasses[size]} ${ colorClasses[color as keyof typeof colorClasses] || colorClasses.blue } rounded-full animate-spin`; return ( <div className="flex items-center justify-center" role="status" aria-label={label} aria-live="polite" > <div className={spinnerClass}></div> <span className="sr-only">{label}</span> </div> ); }
Pattern:
role="status", aria-label, aria-live)sr-only classFrom: frontend/app/signup/page.tsx
typescript"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { api } from "@/lib/api"; import { isValidEmail, getPasswordStrength } from "@/lib/utils"; export default function SignupPage() { const router = useRouter(); const [formData, setFormData] = useState({ name: "", email: "", password: "", }); const [errors, setErrors] = useState<Record<string, string>>({}); const [isLoading, setIsLoading] = useState(false); const [apiError, setApiError] = useState(""); const validateForm = (): boolean => { const newErrors: Record<string, string> = {}; if (!formData.name.trim()) { newErrors.name = "Name is required"; } if (!formData.email.trim()) { newErrors.email = "Email is required"; } else if (!isValidEmail(formData.email)) { newErrors.email = "Please enter a valid email address"; } // More validation... setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setApiError(""); if (!validateForm()) { return; } setIsLoading(true); try { const response = await api.signup(formData); if (response.success) { router.push("/dashboard"); } else { setApiError(response.message || "Signup failed"); } } catch (error: any) { setApiError(error.message || "An error occurred"); } finally { setIsLoading(false); } }; const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); // Clear error for this field when user starts typing if (errors[name]) { setErrors((prev) => ({ ...prev, [name]: "" })); } }; return ( <form onSubmit={handleSubmit}> {/* Form fields */} </form> ); }
Pattern:
From: frontend/components/ToastNotification.tsx
typescript"use client"; import { useEffect, useState } from "react"; import { ToastMessage, ToastType } from "@/types"; export function useToast() { const [toasts, setToasts] = useState<ToastMessage[]>([]); const showToast = (type: ToastType, message: string, duration?: number) => { const id = `toast-${Date.now()}-${Math.random()}`; const newToast: ToastMessage = { id, type, message, duration, }; setToasts((prev) => [...prev, newToast]); }; const dismissToast = (id: string) => { setToasts((prev) => prev.filter((toast) => toast.id !== id)); }; return { toasts, showToast, dismissToast, success: (message: string, duration?: number) => showToast("success", message, duration), error: (message: string, duration?: number) => showToast("error", message, duration), // ... }; }
Pattern:
typescript<div className="flex items-center justify-center gap-4 p-6 bg-white rounded-lg shadow-md">
Pattern: Use Tailwind utility classes, no inline styles
cn() Utilitytypescriptimport { cn } from "@/lib/utils"; <div className={cn( "base-classes", condition && "conditional-classes", className // Allow prop override )}>
Pattern: Use cn() from @/lib/utils for conditional classes
typescript<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
Pattern: Use dark: prefix for dark mode styles
typescript<div className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4">
Pattern: Use breakpoint prefixes (sm:, md:, lg:, xl:)
typescript<button aria-label="Close dialog">×</button> <div role="status" aria-live="polite" aria-label="Loading...">
Pattern: Always provide aria-label for icon-only buttons
typescript<nav> <ul> <li><a href="/">Home</a></li> </ul> </nav>
Pattern: Use semantic HTML elements (nav, main, section, article, etc.)
typescript<button onClick={handleClick} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { handleClick(); } }} >
Pattern: Ensure keyboard accessibility for all interactive elements
typescript<span className="sr-only">Loading content</span>
Pattern: Use sr-only class for screen reader-only text
typescript<input autoFocus className="focus:outline-none focus:ring-2 focus:ring-blue-500" />
Pattern: Always provide visible focus indicators
signup.tsx, signin.tsx)TaskList.tsx, TaskItem.tsx)layout.tsxerror.tsx, not-found.tsxspecs/002-frontend-todo-app/spec.md - Component specificationsfrontend/components/*.tsx - Component examplesfrontend/app/*.tsx - Page examplesLibrary: @dnd-kit/core
typescript"use client"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; export default function SortableTaskList({ tasks, onReorder }: Props) { const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { onReorder(active.id, over.id); } }; return ( <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <SortableContext items={tasks.map(t => t.id)} strategy={verticalListSortingStrategy}> {tasks.map(task => ( <SortableTaskItem key={task.id} task={task} /> ))} </SortableContext> </DndContext> ); }
Pattern: Use @dnd-kit/core for drag and drop, handle reorder on drag end
typescript"use client"; import { useReducer } from "react"; interface HistoryState<T> { past: T[]; present: T; future: T[]; } function historyReducer<T>(state: HistoryState<T>, action: { type: string; newPresent?: T }): HistoryState<T> { const { past, present, future } = state; switch (action.type) { case "UNDO": if (past.length === 0) return state; return { past: past.slice(0, past.length - 1), present: past[past.length - 1], future: [present, ...future], }; case "REDO": if (future.length === 0) return state; return { past: [...past, present], present: future[0], future: future.slice(1), }; case "SET": if (action.newPresent === present) return state; return { past: [...past, present], present: action.newPresent!, future: [], }; default: return state; } } export function useHistory<T>(initialPresent: T) { const [state, dispatch] = useReducer(historyReducer, { past: [], present: initialPresent, future: [], }); const undo = () => dispatch({ type: "UNDO" }); const redo = () => dispatch({ type: "REDO" }); const set = (newPresent: T) => dispatch({ type: "SET", newPresent }); return { state: state.present, set, undo, redo, canUndo: state.past.length > 0, canRedo: state.future.length > 0 }; }
Pattern: Use useReducer with history pattern for undo/redo functionality
typescript"use client"; import { useEffect, useRef } from "react"; export function usePolling(callback: () => Promise<void>, interval: number = 5000) { const intervalRef = useRef<NodeJS.Timeout | null>(null); useEffect(() => { const poll = async () => { try { await callback(); } catch (error) { console.error("Polling error:", error); } }; // Initial call poll(); // Set up polling interval intervalRef.current = setInterval(poll, interval); // Cleanup return () => { if (intervalRef.current) { clearInterval(intervalRef.current); } }; }, [callback, interval]); }
Pattern: Use setInterval for polling, cleanup on unmount, handle errors gracefully
typescript"use client"; import { useState } from "react"; export default function InlineEditable({ value, onSave }: Props) { const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(value); const handleSave = () => { onSave(editValue); setIsEditing(false); }; const handleCancel = () => { setEditValue(value); setIsEditing(false); }; if (isEditing) { return ( <input value={editValue} onChange={(e) => setEditValue(e.target.value)} onBlur={handleSave} onKeyDown={(e) => { if (e.key === "Enter") handleSave(); if (e.key === "Escape") handleCancel(); }} autoFocus /> ); } return ( <span onClick={() => setIsEditing(true)} className="cursor-pointer"> {value} </span> ); }
Pattern: Toggle edit mode, save on blur/Enter, cancel on Escape
next/dynamictypescriptimport dynamic from "next/dynamic"; // Lazy load heavy components const TaskStatistics = dynamic(() => import("@/components/TaskStatistics"), { loading: () => <LoadingSpinner />, ssr: false, // Disable SSR if not needed }); const TaskDetailModal = dynamic(() => import("@/components/TaskDetailModal"), { loading: () => <LoadingSpinner />, });
Pattern: Use next/dynamic for code splitting, provide loading fallback
typescriptimport Image from "next/image"; <Image src="/image.jpg" alt="Description" width={500} height={300} loading="lazy" placeholder="blur" />
Pattern: Use Next.js Image component for automatic optimization
typescript"use client"; import React, { Component, ErrorInfo, ReactNode } from "react"; interface Props { children: ReactNode; fallback?: ReactNode; } interface State { hasError: boolean; error?: Error; } export default class ErrorBoundary extends Component<Props, State> { constructor(props: Props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error: Error): Partial<State> { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo): void { console.error("ErrorBoundary caught an error:", error, errorInfo); // Log to error tracking service } render() { if (this.state.hasError) { return ( this.props.fallback || ( <div className="p-4 bg-red-50 border border-red-200 rounded"> <h2 className="text-red-800 font-bold">Something went wrong</h2> <p className="text-red-600">{this.state.error?.message}</p> <button onClick={() => this.setState({ hasError: false })}>Try again</button> </div> ) ); } return this.props.children; } }
Pattern: Class component, catch errors, provide fallback UI, log errors
typescript// public/sw.js or use next-pwa if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker .register("/sw.js") .then((registration) => { console.log("SW registered:", registration); }) .catch((error) => { console.error("SW registration failed:", error); }); }); }
Pattern: Register service worker on page load, handle registration errors
typescriptimport { openDB, DBSchema, IDBPDatabase } from "idb"; interface TaskDB extends DBSchema { tasks: { key: number; value: Task; indexes: { "by-user-id": string }; }; } export async function getDB(): Promise<IDBPDatabase<TaskDB>> { return openDB<TaskDB>("todo-db", 1, { upgrade(db) { const taskStore = db.createObjectStore("tasks", { keyPath: "id" }); taskStore.createIndex("by-user-id", "user_id"); }, }); } export async function saveTaskOffline(task: Task) { const db = await getDB(); await db.put("tasks", task); } export async function getTasksOffline(userId: string): Promise<Task[]> { const db = await getDB(); return db.getAllFromIndex("tasks", "by-user-id", userId); }
Pattern: Use idb library for IndexedDB, create stores and indexes, handle offline data
typescriptexport async function syncOfflineChanges(userId: string) { const db = await getDB(); const offlineTasks = await db.getAllFromIndex("tasks", "by-user-id", userId); for (const task of offlineTasks) { if (task.syncStatus === "pending") { try { await api.createTask(userId, task); await db.put("tasks", { ...task, syncStatus: "synced" }); } catch (error) { console.error("Sync failed for task:", task.id, error); } } } } // Call on connection restore window.addEventListener("online", () => { syncOfflineChanges(currentUserId); });
Pattern: Track sync status, sync on connection restore, handle sync errors
typescript// Cache API responses const cache = new Map<string, { data: any; timestamp: number }>(); const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes export async function getCachedData<T>(key: string, fetcher: () => Promise<T>): Promise<T> { const cached = cache.get(key); if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { return cached.data; } const data = await fetcher(); cache.set(key, { data, timestamp: Date.now() }); return data; }
Pattern: Use Map for in-memory cache, check expiration, update cache on fetch
typescriptexport function logError(error: Error, context?: Record<string, any>) { console.error("Error:", error, context); // Send to error tracking service (e.g., Sentry) if (typeof window !== "undefined" && (window as any).Sentry) { (window as any).Sentry.captureException(error, { extra: context, }); } // Or send to custom endpoint fetch("/api/log-error", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: error.message, stack: error.stack, context, timestamp: new Date().toISOString(), }), }).catch((err) => console.error("Failed to log error:", err)); }
Pattern: Log to console, send to error tracking service, include context
"use client" only when neededcn() utility for conditional classesdark: prefix@dnd-kit/core for drag and dropuseReducer for undo/redosetInterval for polling with cleanupnext/dynamic for code splitting| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-02 | fail→pass | 43,294 | 21,352 | -51% | 1 | 1 | 0% | 7,793 | 11,142 | +43% | 0 | 0 | — |
case-01 | fail→pass | 23,971 | 34,031 | +42% | 1 | 1 | 0% | 4,027 | 13,996 | +248% | 0 | 0 | — |
case-09 | pass→pass | 25,690 | 16,711 | -35% | 1 | 1 | 0% | 2,537 | 8,759 | +245% | 0 | 0 | — |
case-03 | fail→pass | 22,226 | 30,266 | +36% | 1 | 1 | 0% | 4,096 | 11,752 | +187% | 0 | 0 | — |
case-04 | fail→pass | 13,870 | 13,974 | +1% | 1 | 1 | 0% | 2,625 | 8,800 | +235% | 0 | 0 | — |
case-05 | pass→pass | 25,510 | 16,258 | -36% | 1 | 1 | 0% | 4,191 | 8,661 | +107% | 0 | 0 | — |
case-06 | fail→pass | 20,058 | 22,669 | +13% | 1 | 1 | 0% | 4,283 | 10,246 | +139% | 0 | 0 | — |
case-07 | pass→pass | 18,591 | 21,065 | +13% | 1 | 1 | 0% | 4,114 | 9,652 | +135% | 0 | 0 | — |
case-08 | fail→fail | 26,852 | 25,162 | -6% | 1 | 1 | 0% | 4,191 | 10,782 | +157% | 0 | 0 | — |
case-10 | pass→pass | 16,655 | 16,900 | +1% | 1 | 1 | 0% | 2,279 | 9,190 | +303% | 0 | 0 | — |
case-11 | fail→fail | 25,354 | 21,712 | -14% | 1 | 1 | 0% | 3,955 | 9,970 | +152% | 0 | 0 | — |
case-12 | fail→fail | 17,482 | 16,681 | -5% | 1 | 1 | 0% | 2,827 | 9,731 | +244% | 0 | 0 | — |
case-13 | pass→pass | 13,906 | 12,752 | -8% | 1 | 1 | 0% | 2,581 | 8,190 | +217% | 0 | 0 | — |
case-14 | pass→pass | 17,525 | 16,401 | -6% | 1 | 1 | 0% | 2,731 | 8,584 | +214% | 0 | 0 | — |
case-15 | fail→pass | 24,053 | 25,652 | +7% | 1 | 1 | 0% | 5,115 | 10,851 | +112% | 0 | 0 | — |
case-16 | fail→fail | 24,861 | 24,045 | -3% | 1 | 1 | 0% | 4,127 | 10,141 | +146% | 0 | 0 | — |
case-17 | pass→pass | 18,648 | 23,924 | +28% | 1 | 1 | 0% | 4,052 | 10,210 | +152% | 0 | 0 | — |
case-18 | pass→pass | 20,262 | 12,373 | -39% | 1 | 1 | 0% | 2,541 | 7,678 | +202% | 0 | 0 | — |
case-19 | fail→pass | 18,544 | 16,338 | -12% | 1 | 1 | 0% | 2,898 | 8,563 | +195% | 0 | 0 | — |
case-20 | pass→pass | 12,663 | 8,906 | -30% | 1 | 1 | 0% | 1,669 | 8,227 | +393% | 0 | 0 | — |
case-21 | pass→pass | 14,695 | 14,598 | -1% | 1 | 1 | 0% | 1,884 | 8,297 | +340% | 0 | 0 | — |
case-22 | pass→pass | 11,594 | 9,346 | -19% | 1 | 1 | 0% | 1,183 | 8,208 | +594% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +32 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.