Goswami Digital WorldBlog & Insights
All PostsMain SiteContact Us
← All Posts·Web Development

Getting Started with Next.js 14 — A Complete Guide

Learn how to build production-ready web applications with Next.js 14, the App Router, and Server Components.

A
Ankur Goswami
15 January 2026 · 15 min read
👁views|
❤️likes|
🔗shares
#nextjs#react#javascript

Introduction

Next.js 14 is not just a React framework — it's a complete full-stack platform. With the stable App Router, React Server Components, Server Actions, and built-in optimizations for images, fonts, and scripts, you can build production-grade applications without stitching together dozens of separate tools.

This guide covers everything you need to go from zero to a production-ready Next.js 14 app — project setup, routing, data fetching, Server Components vs Client Components, layouts, metadata, deployment, and more. Whether you're coming from the Pages Router or starting fresh, this is the complete picture.


Setting Up Your Project

Prerequisites

  • Node.js 18.17 or later
  • npm, yarn, or pnpm (pnpm recommended for speed)

Create a new app

npx create-next-app@latest my-app

The CLI will ask you a few questions:

✔ 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 the `src/` directory? → No
✔ Would you like to use App Router? (recommended) → Yes
✔ Would you like to customize the default import alias (@/*)? → Yes
cd my-app
npm run dev

Open http://localhost:3000 — your app is running.

Project structure

my-app/
├── app/
│   ├── layout.tsx        ← Root layout (wraps everything)
│   ├── page.tsx          ← Home page (route: /)
│   ├── globals.css
│   └── favicon.ico
├── public/               ← Static assets
├── components/           ← Reusable UI components
├── lib/                  ← Utility functions, db clients
├── next.config.js
├── tailwind.config.ts
└── tsconfig.json

The App Router — File-System Based Routing

The App Router lives inside the app/ directory. Every folder represents a URL segment. A page.tsx file inside a folder makes that route publicly accessible.

app/
├── page.tsx              → /
├── about/
│   └── page.tsx          → /about
├── blog/
│   ├── page.tsx          → /blog
│   └── [slug]/
│       └── page.tsx      → /blog/:slug
├── dashboard/
│   ├── layout.tsx        → shared layout for /dashboard/*
│   ├── page.tsx          → /dashboard
│   └── settings/
│       └── page.tsx      → /dashboard/settings

Special files in the App Router

File Purpose
page.tsx Unique UI for a route — makes it publicly accessible
layout.tsx Shared UI that wraps child routes — persists across navigation
loading.tsx Automatic loading UI using React Suspense
error.tsx Error boundary — catches errors in the route segment
not-found.tsx Custom 404 UI for the segment
route.ts API endpoint (replaces pages/api/)
template.tsx Like layout but re-renders on every navigation

Layouts — Persistent UI Across Pages

Layouts wrap child routes and don't re-render when the user navigates between children. Perfect for navbars, sidebars, and footers.

// app/layout.tsx — Root layout
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: {
    template: '%s | My App',
    default: 'My App',
  },
  description: 'Built with Next.js 14',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <nav className="border-b px-6 py-4 flex items-center justify-between">
          <span className="font-bold text-lg">My App</span>
          <div className="flex gap-6 text-sm text-gray-600">
            <a href="/">Home</a>
            <a href="/blog">Blog</a>
            <a href="/about">About</a>
          </div>
        </nav>
        <main>{children}</main>
        <footer className="border-t px-6 py-8 text-center text-sm text-gray-500">
          © 2026 My App
        </footer>
      </body>
    </html>
  )
}

Nested layouts work automatically — create a layout.tsx inside any subfolder:

// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex min-h-screen">
      <aside className="w-64 border-r px-4 py-6">
        <nav className="flex flex-col gap-2">
          <a href="/dashboard">Overview</a>
          <a href="/dashboard/analytics">Analytics</a>
          <a href="/dashboard/settings">Settings</a>
        </nav>
      </aside>
      <div className="flex-1 p-8">{children}</div>
    </div>
  )
}

Dynamic Routes

// app/blog/[slug]/page.tsx
type Props = {
  params: { slug: string }
}

export default function BlogPost({ params }: Props) {
  return <h1>Post: {params.slug}</h1>
}

// Generate static pages at build time
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())
  return posts.map((post: { slug: string }) => ({
    slug: post.slug,
  }))
}

Catch-all routes:

app/docs/[...slug]/page.tsx  → /docs/a, /docs/a/b, /docs/a/b/c
app/docs/[[...slug]]/page.tsx → /docs, /docs/a, /docs/a/b  (optional)

Server Components vs Client Components

This is the most important mental model shift in Next.js 14. By default, every component in the App Router is a Server Component.

Server Components

  • Run only on the server
  • Never ship their JavaScript to the browser
  • Can async/await directly — no useEffect, no loading states
  • Can access databases, file systems, environment variables directly
  • Cannot use browser APIs, event handlers, or React hooks (useState, useEffect)
// app/page.tsx — Server Component (default)
// No 'use client' directive needed

async function getLatestPosts() {
  // This runs on the server — API keys safe, no CORS issues
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 }, // Cache for 1 hour
  })
  if (!res.ok) throw new Error('Failed to fetch posts')
  return res.json()
}

export default async function HomePage() {
  const posts = await getLatestPosts()

  return (
    <main className="max-w-4xl mx-auto px-6 py-12">
      <h1 className="text-4xl font-bold mb-8">Latest Posts</h1>
      <div className="grid gap-6">
        {posts.map((post: { id: string; title: string; excerpt: string }) => (
          <article key={post.id} className="border rounded-xl p-6">
            <h2 className="text-xl font-semibold">{post.title}</h2>
            <p className="text-gray-600 mt-2">{post.excerpt}</p>
          </article>
        ))}
      </div>
    </main>
  )
}

Client Components

Add 'use client' at the top of the file. Client Components work like traditional React components — they support hooks, event handlers, and browser APIs. They still get server-rendered for the initial HTML, then hydrate on the client.

// components/Counter.tsx
'use client'

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div className="flex items-center gap-4">
      <button
        onClick={() => setCount(c => c - 1)}
        className="px-4 py-2 border rounded-lg hover:bg-gray-50"
      >
        −
      </button>
      <span className="text-2xl font-bold tabular-nums">{count}</span>
      <button
        onClick={() => setCount(c => c + 1)}
        className="px-4 py-2 border rounded-lg hover:bg-gray-50"
      >
        +
      </button>
    </div>
  )
}

When to use which

Need Component Type
Fetch data from a database or API Server Component
Access backend env variables Server Component
Heavy data processing Server Component
useState, useEffect, hooks Client Component
Event listeners (onClick, onChange) Client Component
Browser APIs (localStorage, window) Client Component
Third-party libraries that use client APIs Client Component

The key rule: Push Client Components as far down the tree as possible. Keep parents as Server Components and only opt specific interactive leaves into client rendering.

// ✅ Good — only the interactive part is a Client Component
// app/blog/[slug]/page.tsx (Server Component)
import { LikeButton } from '@/components/LikeButton'  // Client Component

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)  // runs on server
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      <LikeButton postId={post.id} />  {/* only this is client */}
    </article>
  )
}

Data Fetching in the App Router

fetch with caching

Next.js extends the native fetch API with caching options. You control caching per request.

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

// Revalidate every 60 seconds (ISR-style)
const posts = await fetch('https://api.example.com/posts', {
  next: { revalidate: 60 },
})

// No cache — always fresh (SSR-style)
const user = await fetch('https://api.example.com/me', {
  cache: 'no-store',
})

Parallel data fetching

// ❌ Sequential — slow (waterfall)
const user = await getUser(id)
const posts = await getPosts(id)

// ✅ Parallel — fast
const [user, posts] = await Promise.all([
  getUser(id),
  getPosts(id),
])

Loading UI with Suspense

// app/blog/loading.tsx
export default function Loading() {
  return (
    <div className="max-w-4xl mx-auto px-6 py-12">
      <div className="h-10 w-48 bg-gray-200 rounded animate-pulse mb-8" />
      <div className="grid gap-6">
        {[...Array(3)].map((_, i) => (
          <div key={i} className="border rounded-xl p-6 space-y-3">
            <div className="h-6 w-3/4 bg-gray-200 rounded animate-pulse" />
            <div className="h-4 w-full bg-gray-100 rounded animate-pulse" />
          </div>
        ))}
      </div>
    </div>
  )
}

For more granular control, use Suspense directly:

import { Suspense } from 'react'
import { PostList } from '@/components/PostList'
import { Sidebar } from '@/components/Sidebar'

export default function BlogPage() {
  return (
    <div className="flex gap-8">
      <Suspense fallback={<PostListSkeleton />}>
        <PostList />
      </Suspense>
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
      </Suspense>
    </div>
  )
}

Both PostList and Sidebar fetch independently and stream in as they resolve — no one waits for the other.


Server Actions — Forms and Mutations Without API Routes

Server Actions let you run server-side code directly from your components — no separate API route needed. They're the recommended way to handle form submissions and data mutations in Next.js 14.

// app/contact/page.tsx
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

async function submitContact(formData: FormData) {
  'use server'  // ← This makes it a Server Action

  const name = formData.get('name') as string
  const email = formData.get('email') as string
  const message = formData.get('message') as string

  // Validate
  if (!name || !email || !message) {
    throw new Error('All fields required')
  }

  // Save to database (runs on server — DB credentials safe)
  await db.contact.create({ data: { name, email, message } })

  // Revalidate or redirect
  revalidatePath('/contact')
  redirect('/contact/thanks')
}

export default function ContactPage() {
  return (
    <form action={submitContact} className="max-w-lg mx-auto space-y-4">
      <input name="name" placeholder="Your name" className="w-full border rounded-lg px-4 py-2" required />
      <input name="email" type="email" placeholder="Email" className="w-full border rounded-lg px-4 py-2" required />
      <textarea name="message" rows={5} placeholder="Message" className="w-full border rounded-lg px-4 py-2" required />
      <button type="submit" className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700">
        Send Message
      </button>
    </form>
  )
}

Works without JavaScript enabled. Progressive enhancement out of the box.

Using Server Actions with useFormState for validation feedback:

'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { createPost } from '@/lib/actions'

function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button disabled={pending} type="submit">
      {pending ? 'Publishing...' : 'Publish Post'}
    </button>
  )
}

export function CreatePostForm() {
  const [state, dispatch] = useFormState(createPost, { error: null })

  return (
    <form action={dispatch}>
      {state.error && <p className="text-red-500 text-sm">{state.error}</p>}
      <input name="title" placeholder="Post title" />
      <SubmitButton />
    </form>
  )
}

API Routes with Route Handlers

For building REST APIs or handling webhooks, use Route Handlers in app/api/.

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const page = searchParams.get('page') ?? '1'

  const posts = await db.post.findMany({
    take: 10,
    skip: (parseInt(page) - 1) * 10,
    orderBy: { createdAt: 'desc' },
  })

  return NextResponse.json({ posts, page: parseInt(page) })
}

export async function POST(request: NextRequest) {
  const body = await request.json()

  const post = await db.post.create({
    data: {
      title: body.title,
      content: body.content,
      authorId: body.authorId,
    },
  })

  return NextResponse.json(post, { status: 201 })
}

Dynamic API routes:

// app/api/posts/[id]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const post = await db.post.findUnique({ where: { id: params.id } })
  if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 })
  return NextResponse.json(post)
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  await db.post.delete({ where: { id: params.id } })
  return new NextResponse(null, { status: 204 })
}

Metadata and SEO

Next.js 14 has a first-class Metadata API that handles <title>, <meta>, Open Graph, Twitter cards, and more.

// Static metadata
export const metadata = {
  title: 'Blog — My App',
  description: 'Read the latest articles on web development.',
  openGraph: {
    title: 'Blog — My App',
    description: 'Read the latest articles on web development.',
    images: [{ url: '/og-blog.png', width: 1200, height: 630 }],
  },
  twitter: {
    card: 'summary_large_image',
    creator: '@ankurgoswami',
  },
}

// Dynamic metadata — for pages with dynamic content
export async function generateMetadata({ params }: { params: { slug: string } }) {
  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 }],
      type: 'article',
      publishedTime: post.publishedAt,
    },
    alternates: {
      canonical: `https://myapp.com/blog/${params.slug}`,
    },
  }
}

Image and Font Optimization

next/image — Automatic image optimization

import Image from 'next/image'

export function HeroSection() {
  return (
    <div className="relative h-[500px]">
      {/* Local image */}
      <Image
        src="/hero.jpg"
        alt="Hero image"
        fill
        className="object-cover"
        priority  // ← Add for above-the-fold images (improves LCP)
      />

      {/* Remote image */}
      <Image
        src="https://images.unsplash.com/photo-example"
        alt="Remote image"
        width={800}
        height={600}
        className="rounded-xl"
      />
    </div>
  )
}

Configure allowed remote image domains in next.config.js:

// next.config.js
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
      },
    ],
  },
}

next/font — Zero layout shift fonts

// app/layout.tsx
import { Inter, JetBrains_Mono } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',
})

const jetbrainsMono = JetBrains_Mono({
  subsets: ['latin'],
  variable: '--font-mono',
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  )
}

Fonts are downloaded at build time, self-hosted, and never make a request to Google at runtime. Zero layout shift guaranteed.


Middleware — Edge Logic Before Requests Resolve

Middleware runs before a request completes — perfect for auth checks, redirects, A/B testing, and geolocation.

// middleware.ts (at project root)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')
  const isProtectedRoute = request.nextUrl.pathname.startsWith('/dashboard')

  // Redirect unauthenticated users
  if (isProtectedRoute && !token) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('redirect', request.nextUrl.pathname)
    return NextResponse.redirect(loginUrl)
  }

  // Add custom headers
  const response = NextResponse.next()
  response.headers.set('x-current-path', request.nextUrl.pathname)
  return response
}

// Define which routes middleware runs on
export const config = {
  matcher: ['/dashboard/:path*', '/api/:path*'],
}

Deployment

Vercel (recommended — zero config)

npm install -g vercel
vercel

That's it. Vercel automatically detects Next.js, configures edge functions, CDN, image optimization, and analytics.

Self-hosting with Docker

FROM node:18-alpine AS base

FROM base AS deps
WORKDIR /app
COPY package.json package-lock.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"]

Add output: 'standalone' to next.config.js for the standalone build mode.

Environment variables

# .env.local — never commit this
DATABASE_URL="postgresql://..."
NEXTAUTH_SECRET="your-secret-here"
STRIPE_SECRET_KEY="sk_live_..."

# .env — safe to commit (no secrets)
NEXT_PUBLIC_APP_URL="https://myapp.com"
NEXT_PUBLIC_GA_ID="G-XXXXXXXXXX"

Variables prefixed with NEXT_PUBLIC_ are exposed to the browser. Everything else stays server-only.


Common Patterns and Best Practices

Database with Prisma

npm install prisma @prisma/client
npx prisma init
// lib/db.ts — singleton pattern to avoid multiple connections in dev
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }

export const db = globalForPrisma.prisma ?? new PrismaClient()

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db

Error handling

// app/dashboard/error.tsx
'use client'

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
      <h2 className="text-xl font-semibold text-red-600">Something went wrong</h2>
      <p className="text-gray-600">{error.message}</p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
      >
        Try again
      </button>
    </div>
  )
}

Type-safe navigation with next/navigation

'use client'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'

export function SearchBar() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()

  const handleSearch = (term: string) => {
    const params = new URLSearchParams(searchParams)
    if (term) {
      params.set('q', term)
    } else {
      params.delete('q')
    }
    router.replace(`${pathname}?${params.toString()}`)
  }

  return (
    <input
      placeholder="Search..."
      defaultValue={searchParams.get('q') ?? ''}
      onChange={e => handleSearch(e.target.value)}
      className="border rounded-lg px-4 py-2 w-full"
    />
  )
}

Conclusion

Next.js 14 has matured into one of the best full-stack frameworks available. The App Router's mental model — Server Components by default, Client Components only where needed — leads to naturally fast applications with less JavaScript shipped to the browser. Server Actions eliminate entire layers of boilerplate for data mutations. And the built-in optimizations for images, fonts, and Core Web Vitals mean you're starting production-ready from day one.

The key concepts to internalize:

  • Every component is a Server Component unless you add 'use client'
  • Push Client Components as far down the tree as possible
  • Use fetch with next: { revalidate } to control caching per request
  • Server Actions replace API routes for form submissions and mutations
  • The app/ directory structure IS your routing

Once these click, Next.js becomes incredibly intuitive. Build something — the best way to learn is to ship.

Enjoyed this post?
...
Share this post
💬𝕏inF
← Back to BlogWork With Us →