| name | react-nextjs-patterns |
| description | Best practices for React 19 and Next.js 16 App Router. Use when creating components, hooks, or pages. Enforces server-first rendering, proper client boundaries, and modern React patterns. |
React 19 & Next.js 16 Best Practices
This skill enforces modern React and Next.js patterns for this project. Follow these guidelines to ensure optimal performance, proper server/client boundaries, and idiomatic code.
🎯 Core Principles
- Server-First: Default to Server Components
- Minimal Client: Add
"use client" only at leaf components
- No Legacy Patterns: Use App Router conventions, not Pages Router
- Modern React: Use React 19 features (use hook, actions, etc.)
📦 Server Components (Default)
✅ When to Use Server Components
- Data fetching
- Accessing backend resources
- Rendering static content
- SEO-critical content
- Any component that doesn't need:
useState
useEffect
- Browser APIs
- Event handlers (onClick, onChange, etc.)
Example: Server Component (Default)
import { fetchEvents } from "@lib/api/events";
import { EventList } from "@components/ui/events/EventList";
export default async function PlacePage({
params,
}: {
params: { place: string };
}) {
const events = await fetchEvents({ place: params.place });
return (
<main>
<h1 className="heading-1">{params.place}</h1>
<EventList events={events} />
</main>
);
}
Key Points:
- No
"use client" directive
- Can use
async/await directly
- Data fetching at component level
- Pass data down to children
🖥️ Client Components (Minimal)
✅ When to Add "use client"
ONLY add "use client" when you need:
useState, useReducer
useEffect, useLayoutEffect
- Browser APIs (
window, document, localStorage)
- Event handlers (onClick, onChange, onSubmit)
- Third-party libraries that require client-side
✅ Pattern: Small Client Boundaries
"use client";
export function EventPage({ events }) {
const [filter, setFilter] = useState("all");
return (
<div>
<h1>Events</h1> {/* Could be server-rendered */}
<p>Description...</p> {/* Could be server-rendered */}
<FilterSelect value={filter} onChange={setFilter} /> {/* Needs client */}
<EventList events={events} filter={filter} /> {/* Could be partially server */}
</div>
);
}
"use client";
import type { FilterSelectProps } from "types/props";
export function FilterSelect({ value, onChange }: FilterSelectProps) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="all">All</option>
<option value="free">Free</option>
</select>
);
}
import { FilterSelect } from "@components/ui/filters/FilterSelect";
export default async function PlacePage() {
const events = await fetchEvents();
return (
<div>
<h1>Events</h1> {/* Server-rendered */}
<p>Description...</p> {/* Server-rendered */}
<FilterSelect /> {/* Client island */}
<EventList events={events} /> {/* Server-rendered */}
</div>
);
}
🔗 Link Component (i18n)
⚠️ CRITICAL: Always Use i18n Link
import Link from "next/link";
<Link href="/barcelona">Barcelona</Link>;
import { Link } from "@i18n/routing";
<Link href="/barcelona">Barcelona</Link>;
ESLint warns on import Link from 'next/link' - use @i18n/routing instead.
🪝 Hooks Best Practices
useRef vs useState for Flags
const [hasTracked, setHasTracked] = useState(false);
useEffect(() => {
if (!hasTracked) {
trackAnalytics();
setHasTracked(true);
}
}, [hasTracked]);
const hasTracked = useRef(false);
useEffect(() => {
if (!hasTracked.current) {
trackAnalytics();
hasTracked.current = true;
}
}, []);
Dependency Arrays
useEffect(() => {
fetchData(userId);
}, []);
useEffect(() => {
fetchData(userId);
}, [userId]);
const isMounted = useRef(true);
useEffect(() => {
if (isMounted.current) {
fetchData();
}
}, []);
Memoization (Don't Over-Optimize)
const formattedDate = useMemo(() => date.toLocaleDateString(), [date]);
const sortedEvents = useMemo(
() => events.sort((a, b) => new Date(a.date) - new Date(b.date)),
[events]
);
const handleClick = useCallback(() => {
setCount((c) => c + 1);
}, []);
🚫 Forbidden Patterns
❌ No next/dynamic with ssr: false in Server Components
import dynamic from "next/dynamic";
const ClientComponent = dynamic(() => import("./ClientComponent"), {
ssr: false,
});
export default function ServerPage() {
return <ClientComponent />;
}
import { ClientComponent } from "./ClientComponent";
export default function ServerPage() {
return <ClientComponent />;
}
❌ No Local Barrel Files Mixing Route Contexts
export { default as SponsorBannerSlot } from "./SponsorBannerSlot";
export { default as CheckoutButton } from "./CheckoutButton";
export { default as PricingSectionClient } from "./PricingSectionClient";
import { SponsorBannerSlot } from "@components/ui/sponsor";
import SponsorBannerSlot from "@components/ui/sponsor/SponsorBannerSlot";
Why: In Next.js RSC, every "use client" module re-exported from a barrel gets registered in the client-reference-manifest of every route that imports from that barrel — even unused exports. optimizePackageImports (next.config.js) only works for npm packages, not local barrels.
❌ No Reading searchParams in Listing Pages
export default function PlacePage({
params,
searchParams, // FORBIDDEN in app/[place]/*
}: {
params: { place: string };
searchParams: { search?: string };
}) {
const events = await fetchEvents({ search: searchParams.search });
}
export default async function PlacePage({
params,
}: {
params: { place: string };
}) {
const events = await fetchEvents({ place: params.place });
return <EventsPageClient initialEvents={events} />;
}
("use client");
export function EventsPageClient({ initialEvents }) {
const searchParams = useSearchParams();
const { data } = useSWR(
`/api/events?search=${searchParams.get("search") || ""}`,
fetcher
);
return <EventList events={data || initialEvents} />;
}
❌ No Legacy Pages Router Patterns
export async function getServerSideProps() {
const events = await fetchEvents();
return { props: { events } };
}
export default async function Page() {
const events = await fetchEvents();
return <EventList events={events} />;
}
❌ No Conditional <script> Rendering Without connection() (cacheComponents)
With cacheComponents: true, React caches server component output and expects identical tree structure on replay. If a server component conditionally renders <JsonLdServer> (which outputs <script>) based on data that changes over time, the cached tree shape can differ from the replayed one → "Expected the resume to render <Fragment> in this slot but instead it rendered <script>" errors → full client-side fallback (kills SSR + SEO).
async function StructuredData({ dataPromise }) {
const events = await dataPromise;
const schema = events.length > 0 ? generateSchema(events) : null;
return schema ? <JsonLdServer data={schema} /> : null;
}
import { connection } from "next/server";
async function StructuredData({ dataPromise }) {
await connection();
const events = await dataPromise;
const schema = events.length > 0 ? generateSchema(events) : null;
return schema ? <JsonLdServer data={schema} /> : null;
}
Rule: Any server component with conditional rendering that produces different element types/counts based on data or time must call await connection() before the conditional logic.
📝 Component Structure Template
"use client";
import type { MyComponentProps } from "types/props";
import { useCallback, useState } from "react";
import { Link } from "@i18n/routing";
import { formatDate } from "@utils/date-helpers";
export function MyComponent({ title, items, onSelect }: MyComponentProps) {
const [selected, setSelected] = useState<string | null>(null);
const handleSelect = useCallback(
(id: string) => {
setSelected(id);
onSelect?.(id);
},
[onSelect]
);
if (items.length === 0) {
return <p className="body-normal text-foreground/60">No items</p>;
}
return (
<div className="card-bordered">
<h2 className="heading-3">{title}</h2>
<ul className="stack">
{items.map((item) => (
<li key={item.id}>
<button onClick={() => handleSelect(item.id)}>{item.name}</button>
</li>
))}
</ul>
</div>
);
}
🔄 Data Fetching Patterns
Server Component (Recommended)
export default async function EventsPage() {
const events = await fetchEvents();
return <EventList events={events} />;
}
Client Component with SWR
"use client";
import useSWR from "swr";
import { getInternalApiUrl } from "@utils/api-helpers";
export function EventsFeed({ place }: { place: string }) {
const { data, error, isLoading } = useSWR(
getInternalApiUrl("/api/events", { place }),
fetcher
);
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage />;
return <EventList events={data?.content || []} />;
}
Hybrid Pattern (SSR + Client Enhancement)
export default async function PlacePage({ params }) {
const events = await fetchEvents({ place: params.place });
return (
<>
{/* Static SSR list */}
<EventList events={events} />
{/* Client enhancement for infinite scroll */}
<ClientInfiniteScroll initialEvents={events} place={params.place} />
</>
);
}
✅ Checklist Before Committing
🆕 React 19 Features (Use When Appropriate)
use Hook (Suspense Integration)
import { use } from "react";
function EventDetails({ eventPromise }) {
const event = use(eventPromise);
return <h1>{event.title}</h1>;
}
Server Actions
"use server";
export async function createEvent(formData: FormData) {
const title = formData.get("title");
await db.events.create({ title });
revalidatePath("/events");
}
<form action={createEvent}>
<input name="title" />
<button type="submit">Create</button>
</form>;
Remember: Server-first, minimal client boundaries, modern patterns.
Last Updated: January 15, 2026