Best practices and conventions for working with Next.js 16 App Router projects using TypeScript, Tailwind CSS v4, and React 19. Use when working on Next.js components, pages, API routes, server actions, writing tests, or configuring Next.js features.
This project uses Next.js 16 App Router with TypeScript and Tailwind CSS v4.
app/ - App Router directory (routes, layouts, pages)app/[route]/page.tsx - Page componentsapp/[route]/layout.tsx - Layout componentsapp/[route]/loading.tsx - Loading UIapp/[route]/error.tsx - Error boundariesapp/[route]/not-found.tsx - 404 pagesapp/api/ - API routes (Route Handlers)components/ - Shared React componentscomponents/[component]/[Component].test.tsx - Component tests (colocated)lib/ - Utility functions and helperslib/[util].test.ts - Utility tests (colocated)public/ - Static assetsUse Server Components by default. They run on the server, reducing client bundle size.
// ✅ Server Component (default)
export default async function Page() {
const data = await fetch('https://api.example.com/data');
return <div>{/* render data */}</div>;
}
Use 'use client' directive only when needed:
'use client';
import { useState } from 'react';
export default function ClientComponent() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
// app/products/page.tsx (Server Component)
import ProductList from '@/components/products/ProductList';
import ProductFilters from '@/components/products/ProductFilters';
export default async function ProductsPage() {
const products = await getProducts();
return (
<div>
<ProductFilters /> {/* Client Component */}
<ProductList products={products} /> {/* Server Component */}
</div>
);
}
app/page.tsx → /app/about/page.tsx → /aboutapp/blog/[slug]/page.tsx → /blog/:slugapp/shop/[...slug]/page.tsx → /shop/* (catch-all)app/docs/[[...slug]]/page.tsx → /docs and /docs/* (optional catch-all)// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return <article>{post.content}</article>;
}
Use (groupName) for organization without affecting URL:
app/
(marketing)/
about/
contact/
(dashboard)/
admin/
settings/
Fetch data directly in Server Components:
export default async function Page() {
// ✅ Fetch in Server Component
const res = await fetch('https://api.example.com/data', {
cache: 'no-store', // or 'force-cache', { next: { revalidate: 3600 } }
});
const data = await res.json();
return <div>{data.title}</div>;
}
Use Server Actions for mutations:
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title');
// Validate and save
revalidatePath('/posts');
redirect('/posts');
}
// app/components/CreatePostForm.tsx
'use client';
import { createPost } from '@/app/actions';
export default function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
);
}
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const posts = await getPosts();
return NextResponse.json(posts);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const post = await createPost(body);
return NextResponse.json(post, { status: 201 });
}
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Page Title',
description: 'Page description',
openGraph: {
title: 'OG Title',
description: 'OG Description',
},
};
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
};
}
dark:bg-zinc-900<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
<h1 className="text-3xl font-semibold text-black dark:text-zinc-50">
Title
</h1>
</div>
<div className="flex flex-col sm:flex-row md:grid lg:flex">
{/* Mobile-first responsive classes */}
</div>
Readonly<> for props when appropriateinterface PageProps {
params: Readonly<{ slug: string }>;
searchParams: Readonly<{ [key: string]: string | string[] | undefined }>;
}
export default function Page({ params, searchParams }: PageProps) {
// ...
}
Use @/ alias for imports:
import { Button } from '@/components/ui/Button';
import { formatDate } from '@/lib/utils';
Always use next/image:
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Description"
width={800}
height={600}
priority // For above-the-fold images
/>
Use next/font:
import { Geist } from 'next/font/google';
const geist = Geist({
variable: '--font-geist',
subsets: ['latin'],
});
Create loading.tsx files:
// app/dashboard/loading.tsx
export default function Loading() {
return <div>Loading...</div>;
}
Create error.tsx files:
'use client';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={reset}>Try again</button>
</div>
);
}
'use client';
import { useFormState } from 'react-dom';
import { submitForm } from './actions';
export default function Form() {
const [state, formAction] = useFormState(submitForm, null);
return (
<form action={formAction}>
<input name="email" type="email" required />
{state?.error && <p>{state.error}</p>}
<button type="submit">Submit</button>
</form>
);
}
import { revalidatePath, revalidateTag } from 'next/cache';
// Revalidate specific path
revalidatePath('/posts');
// Revalidate by tag
revalidateTag('posts');
Create unit tests for:
lib/ directory) - Pure functions, helpers, formattersColocate test files next to the code they test:
Button.tsx → Button.test.tsxutils.ts → utils.test.tsroute.ts → route.test.ts# Run all tests
npm test
# Run tests in watch mode (development)
npm test -- --watch
# Run tests for specific file
npm test Button.test.tsx
# Run tests with coverage
npm test -- --coverage
Utility Function:
// lib/formatDate.test.ts
import { formatDate } from './formatDate';
describe('formatDate', () => {
it('formats date correctly', () => {
expect(formatDate(new Date('2024-01-15'))).toBe('Jan 15, 2024');
});
});
Client Component:
// components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button', () => {
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
Server Action:
// app/actions.test.ts
import { createPost } from './actions';
describe('createPost', () => {
it('validates required fields', async () => {
const formData = new FormData();
const result = await createPost(formData);
expect(result.error).toBeDefined();
});
});
API Route:
// app/api/posts/route.test.ts
import { GET } from './route';
import { NextRequest } from 'next/server';
describe('GET /api/posts', () => {
it('returns posts', async () => {
const request = new NextRequest('http://localhost/api/posts');
const response = await GET(request);
expect(response.status).toBe(200);
});
});
Ensure test scripts are configured in package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch"
}
}
For detailed testing patterns, see the nextjs-testing skill.
next/imageloading.tsx fileserror.tsx filespage.tsx, layout.tsx, loading.tsx, error.tsxuseFormState and useFormStatus hooks for form handling