231 lines
6.9 KiB
Markdown
231 lines
6.9 KiB
Markdown
---
|
|
name: nextjs
|
|
description: Use when building Next.js App Router applications — server components, client components, data fetching, routing, middleware, and deployment. Triggers from next.config, layout.tsx, page.tsx, and framework file patterns.
|
|
---
|
|
|
|
# Next.js Best Practices
|
|
|
|
## App Router Architecture
|
|
|
|
```
|
|
app/
|
|
├── (auth)/ # Route group — no URL segment
|
|
│ ├── login/
|
|
│ │ └── page.tsx
|
|
│ └── register/
|
|
│ └── page.tsx
|
|
├── (dashboard)/
|
|
│ ├── layout.tsx # Shared layout for all dashboard pages
|
|
│ ├── page.tsx # /dashboard
|
|
│ └── settings/
|
|
│ └── page.tsx
|
|
├── api/ # API route handlers
|
|
│ └── users/
|
|
│ └── route.ts
|
|
├── layout.tsx # Root layout
|
|
└── page.tsx # Home page (/)
|
|
```
|
|
|
|
## Server Components (Default)
|
|
|
|
**Every component in App Router is a Server Component by default.** Use Client Components only when needed.
|
|
|
|
Server components can:
|
|
- ✅ `async` component — `async function Page() { const data = await fetch(); ... }`
|
|
- ✅ Direct database access — `await db.select().from(users)`
|
|
- ✅ Import server-only modules (DB, filesystem, tokens)
|
|
- ✅ `{children}` render client components
|
|
|
|
```typescript
|
|
// ✅ Server Component — no "use client" directive
|
|
export default async function UserPage({ params }: { params: Promise<{ id: string }> }) {
|
|
const { id } = await params;
|
|
const user = await db.select().from(users).where(eq(users.id, Number(id))).limit(1);
|
|
if (!user[0]) return notFound();
|
|
|
|
return (
|
|
<div>
|
|
<h1>{user[0].name}</h1>
|
|
<UserActions user={user[0]} /> {/* Client component */}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
## Client Components — When to Use
|
|
|
|
Add `'use client'` only when you need:
|
|
- 🔴 `useState` / `useReducer` — interactive UI state
|
|
- 🔴 `useEffect` — browser-side effects or synchronization
|
|
- 🔴 `useRouter` — programmatic navigation
|
|
- 🔴 Event handlers — `onClick`, `onSubmit`, `onChange`
|
|
- 🔴 Browser-only APIs — `localStorage`, `setInterval`, `IntersectionObserver`
|
|
- 🔴 Custom hooks that use any of the above
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useState } from 'react';
|
|
|
|
export function UserActions({ user }: { user: User }) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
return (
|
|
<button onClick={() => setIsEditing(true)}>Edit {user.name}</button>
|
|
);
|
|
}
|
|
```
|
|
|
|
## Data Fetching Patterns
|
|
|
|
### Server-side (preferred)
|
|
```typescript
|
|
// Direct DB access in server component — no waterfall, no loading states
|
|
export default async function Dashboard() {
|
|
const [stats, recentOrders, topUsers] = await Promise.all([
|
|
getStats(), getRecentOrders(), getTopUsers(),
|
|
]);
|
|
return <DashboardView stats={stats} orders={recentOrders} users={topUsers} />;
|
|
}
|
|
```
|
|
|
|
### React Cache (deduplication)
|
|
```typescript
|
|
import { cache } from 'react';
|
|
|
|
export const getItem = cache(async (id: string) => {
|
|
const item = await db.select().from(items).where(eq(items.id, id)).limit(1);
|
|
return item[0];
|
|
});
|
|
```
|
|
|
|
### Revalidation
|
|
```typescript
|
|
// Time-based (ISR)
|
|
export const revalidate = 3600; // seconds
|
|
|
|
// On-demand
|
|
import { revalidatePath, revalidateTag } from 'next/cache';
|
|
|
|
export async function updateUser(formData: FormData) {
|
|
'use server';
|
|
await db.update(users).set({ name: formData.get('name') }).where(eq(users.id, id));
|
|
revalidatePath(`/users/${id}`); // Revalidate the page
|
|
revalidateTag('users'); // Revalidate all fetch calls with this tag
|
|
}
|
|
```
|
|
|
|
## Route Handlers (APIs)
|
|
|
|
```typescript
|
|
// app/api/users/route.ts
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { z } from 'zod';
|
|
|
|
const CreateUserSchema = z.object({
|
|
email: z.string().email(),
|
|
});
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const body = await request.json();
|
|
const parsed = CreateUserSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 422 });
|
|
}
|
|
const user = await db.insert(users).values(parsed.data).returning();
|
|
return NextResponse.json(user[0], { status: 201 });
|
|
}
|
|
```
|
|
|
|
## Server Actions
|
|
|
|
```typescript
|
|
// app/users/actions.ts
|
|
'use server';
|
|
|
|
import { z } from 'zod';
|
|
import { db } from '@/db';
|
|
import { users } from '@/db/schema';
|
|
import { revalidatePath } from 'next/cache';
|
|
|
|
const CreateUserSchema = z.object({
|
|
email: z.string().email(),
|
|
name: z.string().min(1),
|
|
});
|
|
|
|
export async function createUser(formData: FormData) {
|
|
const parsed = CreateUserSchema.safeParse(Object.fromEntries(formData));
|
|
if (!parsed.success) return { error: parsed.error.flatten() };
|
|
|
|
await db.insert(users).values(parsed.data);
|
|
revalidatePath('/users');
|
|
return { success: true };
|
|
}
|
|
```
|
|
|
|
## Middleware
|
|
|
|
```typescript
|
|
// middleware.ts
|
|
import { NextResponse } from 'next/server';
|
|
import type { NextRequest } from 'next/server';
|
|
|
|
export function middleware(request: NextRequest) {
|
|
const token = request.cookies.get('session')?.value;
|
|
const { pathname } = request.nextUrl;
|
|
|
|
// Protected routes
|
|
if (pathname.startsWith('/dashboard') && !token) {
|
|
return NextResponse.redirect(new URL('/login', request.url));
|
|
}
|
|
|
|
// Redirect logged-in users away from login
|
|
if (pathname === '/login' && token) {
|
|
return NextResponse.redirect(new URL('/dashboard', request.url));
|
|
}
|
|
|
|
return NextResponse.next();
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ['/dashboard/:path*', '/login'],
|
|
};
|
|
```
|
|
|
|
## Performance
|
|
|
|
- **Server Components** over Client Components whenever possible.
|
|
- **Streaming** — use `loading.tsx` and `Suspense` boundaries.
|
|
- **Image optimization** — `next/image` with `priority` for above-the-fold.
|
|
- **Font optimization** — `next/font` (self-hosted, no layout shift).
|
|
- **Bundle analysis** — `@next/bundle-analyzer` for tracking bloat.
|
|
- **Link prefetch** — `<Link>` prefetches by default. Disable with `prefetch={false}` for low-priority links.
|
|
|
|
## Testing
|
|
|
|
```typescript
|
|
// Vitest + Testing Library (not Next.js's built-in jest-config)
|
|
import { render, screen } from '@testing-library/react';
|
|
import Page from './page';
|
|
|
|
// Mock server component — render with test data
|
|
vi.mock('@/db', () => ({ select: () => ({ from: () => ({ where: () => ({ limit: () => [mockUser] }) }) }) }));
|
|
|
|
describe('UserPage', () => {
|
|
it('renders user name', async () => {
|
|
const page = await Page({ params: Promise.resolve({ id: '1' }) });
|
|
render(page);
|
|
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
});
|
|
});
|
|
```
|
|
|
|
## Anti-patterns
|
|
|
|
- ❌ `'use client'` on every component — most components can be server components
|
|
- ❌ Data fetching in client components — prefer server components for data
|
|
- ❌ `useEffect` for data fetching — use server components or TanStack Query
|
|
- ❌ Importing server-only code in client components (DB, tokens, env)
|
|
- ❌ Large client bundles — lazy load heavy components with `next/dynamic`
|
|
- ❌ Not using `notFound()` — always handle missing data
|
|
- ❌ `router.push` for navigation that should use `<Link>` prefetch
|