Next.js 15 Complete Guide 2026: App Router, Server Components, and Performance

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Next.js 15 in 2026: The Full-Stack React Framework

Next.js 15 is the most capable version yet — App Router is now the default, Server Components are production-ready, and Turbopack makes development lightning-fast.

Create a New Project

npx create-next-app@latest my-app --typescript --tailwind --app --src-dir
cd my-app
npm run dev

Project structure:

src/
  app/
    layout.tsx        # Root layout
    page.tsx          # Home page /
    globals.css
    dashboard/
      layout.tsx      # Dashboard layout
      page.tsx        # /dashboard
      loading.tsx     # Streaming loading UI
      error.tsx       # Error boundary
    api/
      users/
        route.ts      # API route /api/users

React Server Components (RSC)

Server Components run on the server — zero JS sent to the client:

// app/products/page.tsx — runs on server by default
import { db } from '@/lib/db'

// Direct database access in a component
export default async function ProductsPage() {
  const products = await db.product.findMany({
    orderBy: { createdAt: 'desc' },
    take: 20,
  })

  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

// ProductCard is also a server component
function ProductCard({ product }: { product: Product }) {
  return (
    <div className="border rounded-lg p-4">
      <h2>{product.name}</h2>
      <p className="text-gray-600">{product.description}</p>
      <span className="font-bold">${product.price}</span>
      <AddToCartButton productId={product.id} /> {/* Client component */}
    </div>
  )
}
// components/AddToCartButton.tsx — Client component for interactivity
'use client'
import { useState } from 'react'

export function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false)

  return (
    <button
      onClick={() => {
        addToCart(productId)
        setAdded(true)
      }}
      className={added ? 'bg-green-500' : 'bg-blue-500'}
    >
      {added ? 'Added!' : 'Add to Cart'}
    </button>
  )
}

Server Actions: Forms Without API Routes

// app/actions.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'

const CreatePostSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().min(10),
})

export async function createPost(formData: FormData) {
  const validated = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  })

  if (!validated.success) {
    return { error: validated.error.flatten().fieldErrors }
  }

  await db.post.create({ data: validated.data })
  revalidatePath('/posts')
  return { success: true }
}
// app/posts/new/page.tsx
import { createPost } from '../actions'

export default function NewPostPage() {
  return (
    <form action={createPost} className="space-y-4">
      <input name="title" placeholder="Post title" className="input" />
      <textarea name="content" placeholder="Content" className="textarea" />
      <button type="submit" className="btn-primary">Publish</button>
    </form>
  )
}

Caching in Next.js 15

// 1. Static data — cached indefinitely
const data = await fetch('https://api.example.com/static')

// 2. Revalidate every N seconds
const data = await fetch('https://api.example.com/news', {
  next: { revalidate: 3600 }  // 1 hour
})

// 3. No caching — always fresh
const data = await fetch('https://api.example.com/live', {
  cache: 'no-store'
})

// 4. On-demand revalidation
import { revalidateTag, revalidatePath } from 'next/cache'

// Tag data at fetch time
const data = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] }
})

// Invalidate by tag (from Server Action or API route)
export async function refreshPosts() {
  'use server'
  revalidateTag('posts')
}

Streaming with Suspense

// app/dashboard/page.tsx
import { Suspense } from 'react'

export default function DashboardPage() {
  return (
    <div className="grid grid-cols-3 gap-4">
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />              {/* Streams in when ready */}
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />       {/* Streams in when ready */}
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />       {/* Streams in when ready */}
      </Suspense>
    </div>
  )
}

// Each component fetches its own data independently
async function Stats() {
  const stats = await getStats()  // Slow query
  return <StatsCard data={stats} />
}

Parallel Routes

// app/dashboard/@analytics/page.tsx
// app/dashboard/@team/page.tsx
// app/dashboard/layout.tsx

export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div>
      {children}
      <div className="grid grid-cols-2 gap-4">
        {analytics}
        {team}
      </div>
    </div>
  )
}

API Routes with Route Handlers

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'

export async function GET(request: NextRequest) {
  const session = await auth()
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const { searchParams } = new URL(request.url)
  const page = parseInt(searchParams.get('page') ?? '1')
  const limit = Math.min(parseInt(searchParams.get('limit') ?? '20'), 100)

  const [users, total] = await Promise.all([
    db.user.findMany({ skip: (page - 1) * limit, take: limit }),
    db.user.count(),
  ])

  return NextResponse.json({
    users,
    pagination: { page, limit, total, pages: Math.ceil(total / limit) },
  })
}

export async function POST(request: NextRequest) {
  const body = await request.json()
  const user = await db.user.create({ data: body })
  return NextResponse.json(user, { status: 201 })
}

Metadata and SEO

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  const post = await getPost(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage, width: 1200, height: 630 }],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
    },
  }
}

Performance Tips

TipImpactImplementation
Use next/imageHighAutomatic WebP, lazy loading
Use next/fontMediumZero layout shift fonts
Route groupsMediumCode split by feature
Partial PrerenderingHighStatic shell + dynamic streams
unstable_cacheHighCache expensive DB queries

Next.js 15 with App Router is now the default choice for production React apps. The combination of Server Components + Server Actions eliminates most API boilerplate.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro