wsimmonds/nextjs-anti-patterns
Identify and fix common Next.js App Router anti-patterns and mistakes. Use when reviewing code for Next.js best practices, debugging performance issues, migrating from Pages Router patterns, or preventing common pitfalls. Activates for code review, performance optimization, or detecting inappropriate useEffect/useState usage. CRITICAL: For browser detection, keep the logic in the user-facing component (or a composed helper that it renders) rather than isolating it in unused files.
npx skills add https://github.com/wsimmonds/claude-nextjs-skills --skill nextjs-anti-patterns
Identify and correct common anti-patterns in Next.js App Router applications, focusing on misuse of useEffect, improper data fetching, unnecessary client-side state, and incorrect component boundaries.
any TypeCRITICAL RULE: This codebase has @typescript-eslint/no-explicit-any enabled. Using any will cause build failures.
❌ WRONG:
function handleSubmit(e: any) { ... }
const data: any[] = [];
✅ CORRECT:
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { ... }
const data: string[] = [];
// Page props
function Page({ params }: { params: { slug: string } }) { ... }
function Page({ searchParams }: { searchParams: { [key: string]: string | string[] | undefined } }) { ... }
// Form events
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { ... }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { ... }
// Server actions
async function myAction(formData: FormData) { ... }
Use this skill when:
When requirements call for a page or component to present specific UI (e.g., display a banner or guard message), place that rendering responsibility in the exported component that callers actually use. Helper components are fine, but make sure they are composed so the main entry point still outputs the expected elements.
// page.tsx
'use client';
import { BrowserGuard } from './BrowserGuard';
export default function Page() {
return <BrowserGuard />;
}
// BrowserGuard.tsx
'use client';
export function BrowserGuard() {
const isSafari = typeof navigator !== 'undefined' &&
/Safari/.test(navigator.userAgent) &&
!/Chrome/.test(navigator.userAgent);
if (isSafari) {
return <h1>Unsupported Browser</h1>;
}
return <h1>Welcome</h1>;
}
This keeps the logic modular while ensuring the visible component still renders the appropriate message. Apply the same principle to other requirements—compose helpers, but never leave the primary component without the mandated UI.
❌ WRONG - useEffect with useState:
'use client';
import { useEffect, useState } from 'react';
export default function BrowserCheck() {
const [isSafari, setIsSafari] = useState(false);
useEffect(() => {
setIsSafari(/Safari/.test(navigator.userAgent));
}, []);
return <div>{isSafari ? 'Unsupported' : 'Welcome'}</div>;
}
Why it's wrong:
✅ CORRECT - Direct browser detection in component body:
'use client';
export default function BrowserCheck() {
// Direct detection without useState or useEffect
const isSafari = typeof navigator !== 'undefined' &&
/Safari/.test(navigator.userAgent) &&
!/Chrome/.test(navigator.userAgent);
const isFirefox = typeof navigator !== 'undefined' &&
/Firefox/.test(navigator.userAgent);
if (isSafari || isFirefox) {
return <h1>Unsupported Browser</h1>;
}
return <h1>Welcome</h1>;
}
Key points:
typeof navigator !== 'undefined' for SSR safety!/Chrome/.test(...))Alternative: Use CSS media queries for responsive:
export default function ResponsiveComponent() {
return (
<div>
<div className="block md:hidden"><MobileView /></div>
<div className="hidden md:block"><DesktopView /></div>
</div>
);
}
Wrong:
'use client';
import { useEffect, useState } from 'react';
export default function BlogPosts() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(data => {
setPosts(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Why it's wrong:
Correct:
// Server Component (no 'use client')
export default async function BlogPosts() {
const response = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // Cache for 1 hour
});
if (!response.ok) {
throw new Error('Failed to fetch posts');
}
const posts = await response.json();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Benefits:
Wrong:
'use client';
import { useEffect, useState } from 'react';
export default function ShareButton() {
const [url, setUrl] = useState('');
useEffect(() => {
setUrl(window.location.href);
}, []);
const handleShare = () => {
navigator.share({ url });
};
return <button onClick={handleShare}>Share</button>;
}
Correct:
'use client';
export default function ShareButton() {
const handleShare = () => {
// Access URL directly when needed
const url = window.location.href;
navigator.share({ url });
};
return <button onClick={handleShare}>Share</button>;
}
Wrong:
'use client';
import { useState, useEffect } from 'react';
export default function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, [userId]);
if (!user) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
Correct:
// Server Component
export default async function UserProfile({ userId }: { userId: string }) {
const response = await fetch(`https://api.example.com/users/${userId}`);
const user = await response.json();
return <div>{user.name}</div>;
}
Wrong:
'use client';
import { useState, useEffect } from 'react';
export default function ProductList({ products }: { products: Product[] }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(products.reduce((sum, p) => sum + p.price, 0));
}, [products]);
return <div>Total: ${total}</div>;
}
Correct:
'use client';
export default function ProductList({ products }: { products: Product[] }) {
// Calculate directly - no state needed
const total = products.reduce((sum, p) => sum + p.price, 0);
return <div>Total: ${total}</div>;
}
Or, if truly expensive calculation:
'use client';
import { useMemo } from 'react';
export default function ProductList({ products }: { products: Product[] }) {
const total = useMemo(
() => products.reduce((sum, p) => sum + p.price, 0),
[products]
);
return <div>Total: ${total}</div>;
}
Wrong:
// This doesn't work in App Router!
export async function getServerSideProps() {
const res = await fetch('https://api.example.com/data');
const data = await res.json();
return { props: { data } };
}
export default function Page({ data }) {
return <div>{data.title}</div>;
}
Correct:
// App Router: Server Component with async
export default async function Page() {
const res = await fetch('https://api.example.com/data', {
cache: 'no-store' // Equivalent to getServerSideProps
});
const data = await res.json();
return <div>{data.title}</div>;
}
Wrong:
// This doesn't work in App Router!
export async function getStaticProps() {
const res = await fetch('https://api.example.com/data');
const data = await res.json();
return { props: { data }, revalidate: 60 };
}
Correct:
// App Router: Server Component with revalidation
export default async function Page() {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 60 } // Revalidate every 60 seconds
});
const data = await res.json();
return <div>{data.title}</div>;
}
Wrong:
import Head from 'next/head';
export default function Page() {
return (
<>
<Head>
<title>My Page</title>
<meta name="description" content="Description" />
</Head>
<main>Content</main>
</>
);
}
Correct:
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'My Page',
description: 'Description',
};
export default function Page() {
return <main>Content</main>;
}
Wrong:
export default async function Dashboard() {
// This takes 3 seconds total if each request takes 1 second
const user = await fetchUser();
const posts = await fetchPosts(); // Waits for user
const comments = await fetchComments(); // Waits for posts
return (
<div>
<UserInfo user={user} />
<Posts posts={posts} />
<Comments comments={comments} />
</div>
);
}
Correct:
export default async function Dashboard() {
// This takes 1 second total (parallel fetching)
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments(),
]);
return (
<div>
<UserInfo user={user} />
<Posts posts={posts} />
<Comments comments={comments} />
</div>
);
}
Even Better: Use Suspense for progressive rendering:
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<Posts />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</div>
);
}
async function UserInfo() {
const user = await fetchUser();
return <div>{user.name}</div>;
}
async function Posts() {
const posts = await fetchPosts();
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
async function Comments() {
const comments = await fetchComments();
return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}
Wrong:
// app/layout.tsx
'use client'; // Unnecessary - makes entire app client-side!
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
);
}
Correct:
// app/layout.tsx
// No 'use client' - keep as Server Component
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
);
}
Rule: Only add 'use client' to the lowest level component that needs it.
Wrong:
// app/page.tsx
'use client'; // Entire page becomes client component
export default function Page() {
return (
<div>
<Header />
<StaticContent />
<InteractiveButton />
</div>
);
}
Correct:
// app/page.tsx - Server Component
export default function Page() {
return (
<div>
<Header />
<StaticContent />
<InteractiveButton /> {/* Only this needs 'use client' */}
</div>
);
}
// InteractiveButton.tsx
'use client';
export default function InteractiveButton() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
Wrong:
// ClientComponent.tsx
'use client';
import ServerComponent from './ServerComponent'; // Becomes client component!
export default function ClientComponent() {
return <div><ServerComponent /></div>;
}
Correct:
// ParentComponent.tsx (Server Component)
import ClientComponent from './ClientComponent';
import ServerComponent from './ServerComponent';
export default function ParentComponent() {
return (
<ClientComponent>
<ServerComponent /> {/* Stays as Server Component */}
</ClientComponent>
);
}
// ClientComponent.tsx
'use client';
export default function ClientComponent({ children }) {
return <div className="wrapper">{children}</div>;
}
Wrong:
'use client';
export default function NavButton() {
const handleClick = () => {
window.location.href = '/dashboard'; // Full page reload!
};
return <button onClick={handleClick}>Go to Dashboard</button>;
}
Correct:
'use client';
import { useRouter } from 'next/navigation';
export default function NavButton() {
const router = useRouter();
const handleClick = () => {
router.push('/dashboard'); // Client-side navigation
};
return <button onClick={handleClick}>Go to Dashboard</button>;
}
Even Better: Use Link component:
import Link from 'next/link';
export default function NavButton() {
return <Link href="/dashboard">Go to Dashboard</Link>;
}
Wrong:
// Server Component
import { useRouter } from 'next/navigation'; // ERROR!
export default function Page() {
const router = useRouter(); // This will fail
return <div>...</div>;
}
Correct for Server Components:
// Server Component - use redirect
import { redirect } from 'next/navigation';
export default async function Page() {
const user = await getUser();
if (!user) {
redirect('/login');
}
return <div>Welcome, {user.name}</div>;
}
Correct for Client Components:
// Client Component
'use client';
import { useRouter } from 'next/navigation';
export default function Page() {
const router = useRouter();
const handleLogout = () => {
router.push('/login');
};
return <button onClick={handleLogout}>Logout</button>;
}
Wrong:
// app/api/posts/route.ts
export async function GET() {
const posts = await db.posts.findMany();
return Response.json(posts);
}
// app/posts/page.tsx
'use client';
import { useEffect, useState } from 'react';
export default function Posts() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/api/posts')
.then(r => r.json())
.then(setPosts);
}, []);
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
Correct:
// app/posts/page.tsx - Direct database access
import { db } from '@/lib/db';
export default async function Posts() {
const posts = await db.posts.findMany();
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
When API routes ARE appropriate:
Wrong:
// No loading UI - page blocks until all data loads
export default async function Dashboard() {
const data = await fetchSlowData(); // Takes 5 seconds
return <div>{data.content}</div>;
}
Correct:
import { Suspense } from 'react';
export default function Dashboard() {
return (
<Suspense fallback={<LoadingSpinner />}>
<DashboardContent />
</Suspense>
);
}
async function DashboardContent() {
const data = await fetchSlowData();
return <div>{data.content}</div>;
}
Despite all these anti-patterns, Client Components are still necessary and correct for:
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Clicks: {count}
</button>
);
}
'use client';
import { useState } from 'react';
export default function SearchForm() {
const [query, setQuery] = useState('');
return (
<form>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
</form>
);
}
'use client';
import { useEffect } from 'react';
export default function ScrollTracker() {
useEffect(() => {
const handleScroll = () => {
console.log('Scroll position:', window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return <div>Scroll tracker active</div>;
}
'use client';
import { useEffect, useRef } from 'react';
import mapboxgl from 'mapbox-gl';
export default function Map() {
const mapContainer = useRef(null);
useEffect(() => {
const map = new mapboxgl.Map({
container: mapContainer.current,
style: 'mapbox://styles/mapbox/streets-v11',
});
return () => map.remove();
}, []);
return <div ref={mapContainer} />;
}
'use client';
import { useContext } from 'react';
import { ThemeContext } from './ThemeContext';
export default function ThemedButton() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
);
}
When reviewing Next.js App Router code, check for:
useEffect used for data fetching → Replace with Server ComponentuseEffect used for browser detection → Do it directly or use CSSuseState used for server data → Replace with Server ComponentgetServerSideProps or getStaticProps → Migrate to async Server Componentsnext/head imports → Replace with metadata exportsawait statements → Use Promise.all or Suspense'use client' on static components → Remove directivewindow.location.href for navigation → Use Link or useRouterWhen migrating code with anti-patterns:
Take wsimmonds/nextjs-anti-patterns from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.