
Introduction
Modern React applications demand fast load times, strong SEO, and a smooth developer experience. Next.js 14 builds on these needs by combining React Server Components, the App Router, and powerful data-fetching patterns into a single framework. Whether you’re building a marketing site, dashboard, or full-stack application, Next.js 14 provides the foundation for production-ready React apps. In this comprehensive guide, you will learn how to build a React app with Next.js 14 from scratch, understand its core concepts including Server Components and the App Router, implement data fetching patterns, and apply best practices for scalable and performant applications.
Why Choose Next.js 14
Next.js extends React with features that solve common production problems. Many teams adopt it as their default React framework because it eliminates the need to configure routing, bundling, and rendering strategies manually.
- Built-in routing with the App Router and file-system based routes
- Server-side rendering and static generation out of the box
- React Server Components by default for smaller bundles
- Optimized data fetching with automatic caching and deduplication
- Excellent SEO support with per-route metadata
- Production-ready performance without additional configuration
Project Setup
# Create a new Next.js 14 app
npx create-next-app@latest my-app
# Options during setup:
# ✔ Would you like to use TypeScript? Yes
# ✔ Would you like to use ESLint? Yes
# ✔ Would you like to use Tailwind CSS? Yes
# ✔ Would you like to use `src/` directory? Yes
# ✔ Would you like to use App Router? Yes
# ✔ Would you like to customize the default import alias? No
cd my-app
npm run dev
Project Structure
my-app/
├── src/
│ ├── app/ # App Router directory
│ │ ├── layout.tsx # Root layout
│ │ ├── page.tsx # Home page (/)
│ │ ├── globals.css # Global styles
│ │ ├── loading.tsx # Loading UI
│ │ ├── error.tsx # Error boundary
│ │ ├── not-found.tsx # 404 page
│ │ ├── blog/
│ │ │ ├── page.tsx # /blog
│ │ │ └── [slug]/
│ │ │ └── page.tsx # /blog/[slug]
│ │ ├── dashboard/
│ │ │ ├── layout.tsx # Dashboard layout
│ │ │ └── page.tsx # /dashboard
│ │ └── api/
│ │ └── route.ts # API route
│ ├── components/ # React components
│ │ ├── ui/ # UI components
│ │ └── features/ # Feature components
│ └── lib/ # Utilities and helpers
├── public/ # Static assets
├── next.config.js # Next.js configuration
├── tailwind.config.ts # Tailwind configuration
└── tsconfig.json # TypeScript configuration
The App Router
The App Router is the foundation of modern Next.js applications. Instead of defining routes manually, the file system becomes the router.
Root Layout
// src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App',
},
description: 'A modern Next.js application',
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://myapp.com',
siteName: 'My App',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
{children}
);
}
Pages and Routes
// src/app/page.tsx - Home page (/)
export default function HomePage() {
return (
Welcome to My App
A modern Next.js 14 application
);
}
// src/app/blog/page.tsx - Blog listing (/blog)
import Link from 'next/link';
import { getPosts } from '@/lib/posts';
export const metadata = {
title: 'Blog',
description: 'Read our latest articles',
};
export default async function BlogPage() {
const posts = await getPosts();
return (
Blog
{posts.map((post) => (
{post.title}
{post.excerpt}
))}
);
}
// src/app/blog/[slug]/page.tsx - Dynamic blog post (/blog/[slug])
import { getPost, getPosts } from '@/lib/posts';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
interface Props {
params: { slug: string };
}
// Generate static params for all posts
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ slug: post.slug }));
}
// Generate dynamic metadata
export async function generateMetadata({ params }: Props): Promise {
const post = await getPost(params.slug);
if (!post) {
return { title: 'Post Not Found' };
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.date,
},
};
}
export default async function BlogPostPage({ params }: Props) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return (
{post.title}
);
}
Server Components vs Client Components
Next.js 14 uses React Server Components by default. This means most components run on the server unless explicitly marked as client components.
// Server Component (default) - No 'use client' directive
// Can: fetch data, access backend resources, keep secrets
// Cannot: use hooks, browser APIs, event handlers
async function ProductList() {
// This runs on the server
const products = await db.products.findMany();
return (
{products.map((product) => (
- {product.name}
))}
);
}
// Client Component - Has 'use client' directive
// Can: use hooks, browser APIs, event handlers
// Cannot: directly fetch server-only data
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
);
}
// Mixing Server and Client Components
// Server Component can render Client Component
// Client Component cannot import Server Component directly
import { Counter } from '@/components/Counter';
async function Dashboard() {
const stats = await getStats(); // Server-side fetch
return (
Dashboard
Total Users: {stats.users}
{/* Client Component */}
);
}
Data Fetching Patterns
// src/lib/posts.ts
export interface Post {
slug: string;
title: string;
excerpt: string;
content: string;
date: string;
}
const API_URL = process.env.API_URL;
// Static data - cached indefinitely (default)
export async function getPosts(): Promise {
const res = await fetch(`${API_URL}/posts`, {
// This is the default - data is cached
cache: 'force-cache',
});
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
}
// Dynamic data - no caching
export async function getPost(slug: string): Promise {
const res = await fetch(`${API_URL}/posts/${slug}`, {
// Fetch fresh data on every request
cache: 'no-store',
});
if (!res.ok) return null;
return res.json();
}
// Revalidated data - ISR pattern
export async function getProducts() {
const res = await fetch(`${API_URL}/products`, {
// Revalidate every 60 seconds
next: { revalidate: 60 },
});
return res.json();
}
// Tagged revalidation
export async function getProductById(id: string) {
const res = await fetch(`${API_URL}/products/${id}`, {
next: { tags: [`product-${id}`] },
});
return res.json();
}
// Trigger revalidation from API route or Server Action
import { revalidateTag, revalidatePath } from 'next/cache';
export async function updateProduct(id: string, data: any) {
await db.products.update({ where: { id }, data });
// Revalidate specific tag
revalidateTag(`product-${id}`);
// Or revalidate entire path
revalidatePath('/products');
}
Server Actions
Server Actions allow you to run server-side code directly from components without creating API routes.
// src/app/contact/page.tsx
import { submitContactForm } from './actions';
export default function ContactPage() {
return (
);
}
// src/app/contact/actions.ts
'use server';
import { z } from 'zod';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
const contactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
});
export async function submitContactForm(formData: FormData) {
// Validate form data
const result = contactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
});
if (!result.success) {
return { error: 'Invalid form data' };
}
// Save to database
await db.contacts.create({
data: result.data,
});
// Send notification email
await sendEmail({
to: 'admin@example.com',
subject: 'New Contact Form Submission',
body: `From: ${result.data.name} (${result.data.email})\n\n${result.data.message}`,
});
// Revalidate and redirect
revalidatePath('/admin/contacts');
redirect('/contact/success');
}
Loading and Error States
// src/app/blog/loading.tsx
export default function Loading() {
return (
{[1, 2, 3].map((i) => (
))}
);
}
// src/app/blog/error.tsx
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log error to monitoring service
console.error(error);
}, [error]);
return (
Something went wrong!
{error.message}
);
}
// src/app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
404
Page not found
Go back home
);
}
API Routes
// src/app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const posts = await db.posts.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
});
return NextResponse.json({
posts,
pagination: { page, limit },
});
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const post = await db.posts.create({
data: {
title: body.title,
content: body.content,
slug: generateSlug(body.title),
},
});
return NextResponse.json(post, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create post' },
{ status: 500 }
);
}
}
// src/app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface RouteParams {
params: { id: string };
}
export async function GET(request: NextRequest, { params }: RouteParams) {
const post = await db.posts.findUnique({
where: { id: params.id },
});
if (!post) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
);
}
return NextResponse.json(post);
}
export async function PUT(request: NextRequest, { params }: RouteParams) {
const body = await request.json();
const post = await db.posts.update({
where: { id: params.id },
data: body,
});
return NextResponse.json(post);
}
export async function DELETE(request: NextRequest, { params }: RouteParams) {
await db.posts.delete({
where: { id: params.id },
});
return new NextResponse(null, { status: 204 });
}
Middleware
// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Check authentication for protected routes
const token = request.cookies.get('auth-token');
const isAuthPage = request.nextUrl.pathname.startsWith('/login');
const isProtectedRoute = request.nextUrl.pathname.startsWith('/dashboard');
if (isProtectedRoute && !token) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (isAuthPage && token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Add custom headers
const response = NextResponse.next();
response.headers.set('x-custom-header', 'my-value');
return response;
}
export const config = {
matcher: [
// Match all routes except static files and api
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
Common Mistakes to Avoid
Mistake 1: Overusing Client Components
// WRONG - Making entire page a client component
'use client';
export default function ProductPage() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products').then(res => res.json()).then(setProducts);
}, []);
return ;
}
// CORRECT - Use Server Component for data fetching
export default async function ProductPage() {
const products = await getProducts(); // Server-side fetch
return (
{/* Only this needs to be client */}
);
}
Mistake 2: Ignoring Caching Behavior
// WRONG - Data might be stale
export default async function DashboardPage() {
const stats = await fetch('/api/stats'); // Cached by default!
return ;
}
// CORRECT - Use appropriate caching strategy
export default async function DashboardPage() {
const stats = await fetch('/api/stats', {
cache: 'no-store', // Always fresh for dashboard
});
return ;
}
Mistake 3: Prop Drilling Instead of Parallel Fetching
// WRONG - Sequential fetches, prop drilling
export default async function Page() {
const user = await getUser();
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
return ;
}
// CORRECT - Parallel fetches, component composition
export default async function Page() {
return (
}>
{/* Fetches its own data */}
}>
{/* Fetches its own data */}
);
}
Deployment
# Build for production
npm run build
# Start production server
npm start
# Deploy to Vercel (recommended)
npx vercel
# Or use Docker
# Dockerfile
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
Conclusion
Next.js 14 provides a modern and powerful way to build React applications that are fast, scalable, and SEO-friendly. By leveraging the App Router, Server Components, Server Actions, and built-in data fetching with caching, you can reduce complexity while improving performance. The key is understanding when to use Server vs Client Components, choosing the right caching strategy for your data, and taking advantage of streaming and Suspense for optimal loading experiences.
For TypeScript patterns, read Advanced TypeScript Types & Generics: Utility Types Explained. For project organization at scale, see Monorepos with Nx or Turborepo: When and Why. Reference the official Next.js documentation and React documentation for the latest updates.
1 Comment