RefPad

Next.js

Requires Next.js 15+ / React 19 / App Router

Table of Contents

  1. File Conventions & Routing
  2. Server / Client Components
  3. Data Fetching
  4. Server Actions (Mutations)
  5. Navigation
  6. SearchParams
  7. Error Handling
  8. Metadata
  9. Route Handlers (API)
  10. Environment Variables

1. File Conventions & Routing

Special Files

FileRole
page.tsxRoute UI (the publicly accessible page)
layout.tsxShared UI for all pages beneath it. Does not re-render on navigation
template.tsxSame as layout.tsx but re-created on every navigation
loading.tsxFallback UI while the page is loading (powered by Suspense)
error.tsxFallback UI on error (must be a Client Component)
not-found.tsx404 UI
route.tsAPI endpoint (Route Handler)
global-error.tsxError UI for the root layout

layout.tsx preserves state across navigations. Use template.tsx when you need to reset state on every navigation (e.g. resetting a form).

Directory Structure & Routing

Only directories that contain a page.tsx are exposed as URLs.

app/
├── layout.tsx              → / (shared layout for all pages)
├── page.tsx                → /
├── blog/
│   ├── layout.tsx          → shared layout for /blog and below
│   ├── page.tsx            → /blog
│   └── [slug]/
│       ├── page.tsx        → /blog/:slug
│       └── not-found.tsx
└── api/
    └── posts/
        └── route.ts        → /api/posts (GET / POST etc.)

Dynamic Routing Variants

SyntaxMatched URL Exampleparams Type
[slug]/blog/hello{ slug: string }
[...slug]/blog/a/b/c (1+ segments){ slug: string[] }
[[...slug]]/blog or /blog/a/b (0+ segments){ slug?: string[] }
// app/blog/[...slug]/page.tsx
type Props = {
  params: Promise<{ slug: string[] }>
}
 
export default async function Page({ params }: Props) {
  const { slug } = await params
  // /blog/a/b/c → slug = ["a", "b", "c"]
}

Route Groups ( )

Wrapping a folder name in ( ) has no effect on the URL but lets you organize routes logically. Useful when you want a different layout.tsx per group.

app/
├── (public)/
│   ├── page.tsx            → /
│   └── about/
│       └── page.tsx        → /about
└── (admin)/
    ├── layout.tsx          ← admin-only layout (no "(admin)" in URL)
    ├── dashboard/
    │   └── page.tsx        → /dashboard
    └── settings/
        └── page.tsx        → /settings

Splitting layouts with Route Groups is handy when the header/sidebar differs significantly between authenticated and unauthenticated areas.

Root Layout (Required)

// app/layout.tsx
type Props = {
  children: React.ReactNode
}
 
export default function RootLayout({ children }: Props) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

Dynamic Route (params is a Promise since v15)

// app/blog/[slug]/page.tsx
type Props = {
  params: Promise<{ slug: string }>
}
 
export default async function Page({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)
  return <div>{post.title}</div>
}
 
// Specify paths to statically generate at build time
export async function generateStaticParams() {
  const posts = await getPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

2. Server / Client Components

All components are Server Components by default.

When to Use Each

Use Server ComponentUse Client Component
Fetching data from DB / APIuseState / useEffect and other hooks
Accessing secrets (API keys, etc.)Click and other event handlers
Reducing JS bundle sizeBrowser APIs (localStorage, etc.)

Client Component

Add 'use client' at the top of the file.

// app/ui/counter.tsx
'use client'
 
import { useState } from 'react'
 
export default function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

Passing Data from Server to Client

Fetch data in a Server Component and pass it to a Client Component as props.

// app/page.tsx (Server Component)
import LikeButton from '@/app/ui/like-button'
 
type Props = {
  params: Promise<{ id: string }>
}
 
export default async function Page({ params }: Props) {
  const { id } = await params
  const post = await getPost(id)
  return <LikeButton likes={post.likes} />
}
// app/ui/like-button.tsx (Client Component)
'use client'
 
type Props = {
  likes: number
}
 
export default function LikeButton({ likes }: Props) {
  const [count, setCount] = useState(likes)
  return <button onClick={() => setCount(c => c + 1)}>Like {count}</button>
}

Context Provider Pattern

Context cannot be used in Server Components, so wrap with a 'use client' provider.

// app/providers/theme-provider.tsx
'use client'
 
import { createContext, useContext, useState } from 'react'
 
type Theme = 'light' | 'dark'
const ThemeContext = createContext<Theme>('light')
 
type Props = {
  children: React.ReactNode
}
 
export function ThemeProvider({ children }: Props) {
  const [theme, setTheme] = useState<Theme>('light')
  return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
}
 
export const useTheme = () => useContext(ThemeContext)
// app/layout.tsx (remains a Server Component)
import { ThemeProvider } from './providers/theme-provider'
 
type Props = {
  children: React.ReactNode
}
 
export default function RootLayout({ children }: Props) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

3. Data Fetching

fetch in a Server Component

// app/blog/page.tsx
export default async function Page() {
  const res = await fetch('https://api.example.com/posts')
  if (!res.ok) return <p>Failed to load</p>
  const posts: Post[] = await res.json()
 
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

fetch responses are not cached by default (changed in v15).

Parallel Data Fetching

// Chaining awaits is sequential — use Promise.all for parallel fetching
type Props = {
  params: Promise<{ username: string }>
}
 
export default async function Page({ params }: Props) {
  const { username } = await params
 
  const [artist, albums] = await Promise.all([
    getArtist(username),
    getAlbums(username),
  ])
 
  return (
    <>
      <h1>{artist.name}</h1>
      <Albums list={albums} />
    </>
  )
}

Streaming (loading.tsx / <Suspense>)

// app/blog/loading.tsx → shown automatically while navigating to /blog
export default function Loading() {
  return <p>Loading...</p>
}
// app/blog/page.tsx (fine-grained control with Suspense)
import { Suspense } from 'react'
import BlogList from '@/components/BlogList'
 
export default function Page() {
  return (
    <div>
      <h1>Blog</h1>
      {/* Show fallback until BlogList's fetch completes */}
      <Suspense fallback={<p>Loading articles...</p>}>
        <BlogList />
      </Suspense>
    </div>
  )
}

React.cache for Deduplication

Useful when both generateMetadata and the page body need the same data.

// app/lib/data.ts
import { cache } from 'react'
 
export const getPost = cache(async (slug: string) => {
  const res = await fetch(`https://api.example.com/posts/${slug}`)
  return res.json() as Promise<Post>
})

4. Server Actions (Mutations)

Defining a Server Action ('use server')

// app/lib/actions.ts
'use server'
 
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
 
export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
 
  await db.posts.create({ title }) // write to DB
 
  revalidatePath('/posts') // invalidate cache and re-fetch
  redirect('/posts')       // redirect after completion
}

Calling from a Form

// app/ui/new-post-form.tsx
import { createPost } from '@/app/lib/actions'
 
export function NewPostForm() {
  return (
    <form action={createPost}>
      <input type="text" name="title" placeholder="Title" required />
      <button type="submit">Post</button>
    </form>
  )
}

Managing Errors / Pending State with useActionState

// app/ui/new-post-form.tsx
'use client'
 
import { useActionState } from 'react'
import { createPost } from '@/app/lib/actions'
 
type State = { message: string }
 
export function NewPostForm() {
  const [state, formAction, pending] = useActionState<State, FormData>(
    createPost,
    { message: '' }
  )
 
  return (
    <form action={formAction}>
      <input type="text" name="title" required />
      {state.message && <p role="alert">{state.message}</p>}
      <button disabled={pending}>
        {pending ? 'Submitting...' : 'Post'}
      </button>
    </form>
  )
}
// app/lib/actions.ts (returning errors)
'use server'
 
export async function createPost(
  prevState: { message: string },
  formData: FormData
) {
  const title = formData.get('title') as string
  if (!title) return { message: 'Title is required' }
 
  await db.posts.create({ title })
  revalidatePath('/posts')
  redirect('/posts')
}

Calling from an Event Handler

'use client'
 
import { useState } from 'react'
import { incrementLike } from '@/app/lib/actions'
 
type Props = {
  initialLikes: number
}
 
export default function LikeButton({ initialLikes }: Props) {
  const [likes, setLikes] = useState(initialLikes)
 
  return (
    <button
      onClick={async () => {
        const updated = await incrementLike()
        setLikes(updated)
      }}
    >
      Like {likes}
    </button>
  )
}

cookies / headers (async since v15)

// app/lib/actions.ts
'use server'
 
import { cookies, headers } from 'next/headers'
 
export async function exampleAction() {
  const cookieStore = await cookies()
  const headersList = await headers()
 
  cookieStore.get('token')?.value       // read
  cookieStore.set('token', 'abc123')   // set
  cookieStore.delete('token')          // delete
 
  headersList.get('user-agent')         // read header
}

5. Navigation

Links entering the viewport are automatically prefetched.

import Link from 'next/link'
 
// Basic
<Link href="/blog">Blog</Link>
 
// Dynamic route
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
 
// Disable prefetch
<Link href="/dashboard" prefetch={false}>Dashboard</Link>
 
// Active link styling (combine with usePathname)

useRouter (Client Component)

Use for programmatic navigation.

'use client'
 
import { useRouter } from 'next/navigation'
 
export default function BackButton() {
  const router = useRouter()
 
  return (
    <>
      <button onClick={() => router.push('/dashboard')}>Go to Dashboard</button>
      <button onClick={() => router.back()}>Back</button>
      <button onClick={() => router.refresh()}>Refresh</button>
    </>
  )
}

redirect (Server Component / Server Action)

import { redirect } from 'next/navigation'
 
export default async function Page() {
  const session = await getSession()
  if (!session) redirect('/login') // ← code below this line does not run
  // ...
}

usePathname / useSearchParams (Client Component)

'use client'
 
import { usePathname, useSearchParams } from 'next/navigation'
 
type Props = {
  href: string
  label: string
}
 
export default function NavLink({ href, label }: Props) {
  const pathname = usePathname()
  const isActive = pathname === href
 
  return (
    <Link href={href} className={isActive ? 'font-bold' : ''}>
      {label}
    </Link>
  )
}

6. SearchParams

Read and update URL query parameters (?q=hello&page=2, etc.).

Reading in a Server Component (async since v15)

// app/search/page.tsx
type Props = {
  searchParams: Promise<{ q?: string; page?: string }>
}
 
export default async function Page({ searchParams }: Props) {
  const { q = '', page = '1' } = await searchParams
  const results = await searchPosts(q, Number(page))
  return <SearchResults results={results} />
}

Reading in a Client Component (useSearchParams)

'use client'
 
import { useSearchParams } from 'next/navigation'
 
export default function SearchInput() {
  const searchParams = useSearchParams()
  const q = searchParams.get('q') ?? ''
 
  return <input defaultValue={q} placeholder="Search..." />
}

useSearchParams may cause a build error if not wrapped in <Suspense>.

Updating the URL (useRouter + usePathname)

Use URLSearchParams to build the URL when rewriting query strings.

'use client'
 
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
 
export default function SearchForm() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
 
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams.toString())
    if (term) {
      params.set('q', term)
    } else {
      params.delete('q')
    }
    params.set('page', '1') // reset to page 1 on new search
    router.replace(`${pathname}?${params.toString()}`)
  }
 
  return (
    <input
      defaultValue={searchParams.get('q') ?? ''}
      onChange={(e) => handleSearch(e.target.value)}
      placeholder="Search..."
    />
  )
}

7. Error Handling

error.tsx (Must Be a Client Component)

// app/dashboard/error.tsx
'use client'
 
import { useEffect } from 'react'
 
type Props = {
  error: Error & { digest?: string }
  reset: () => void
}
 
export default function ErrorPage({ error, reset }: Props) {
  useEffect(() => {
    console.error(error)
  }, [error])
 
  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={reset}>Try again</button>
    </div>
  )
}

not-found.tsx and notFound()

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
 
type Props = {
  params: Promise<{ slug: string }>
}
 
export default async function Page({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)
 
  if (!post) notFound() // → renders the nearest not-found.tsx
 
  return <div>{post.title}</div>
}
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
  return <div>404 - Post not found</div>
}

8. Metadata

Static Metadata

// app/blog/layout.tsx
import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  title: 'Blog',
  description: 'Latest articles',
  openGraph: {
    title: 'Blog',
    description: 'Latest articles',
    images: [{ url: '/og-image.png' }],
  },
}
 
type Props = {
  children: React.ReactNode
}
 
export default function Layout({ children }: Props) {
  return children
}

Dynamic Metadata

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { cache } from 'react'
 
// Share data between generateMetadata and the page body via React.cache
const getPost = cache(async (slug: string) => {
  const res = await fetch(`https://api.example.com/posts/${slug}`)
  return res.json() as Promise<Post>
})
 
type Props = {
  params: Promise<{ slug: string }>
}
 
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  return {
    title: post.title,
    description: post.description,
  }
}
 
export default async function Page({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug) // cached — no duplicate fetch
  return <article>{post.title}</article>
}

9. Route Handlers (API)

Create a route.ts file under app/api/.

// app/api/posts/route.ts
import { NextRequest } from 'next/server'
 
export async function GET(request: NextRequest) {
  const q = request.nextUrl.searchParams.get('q')
  const posts = await getPosts(q)
  return Response.json(posts)
}
 
export async function POST(request: NextRequest) {
  const body = await request.json()
  const post = await createPost(body)
  return Response.json(post, { status: 201 })
}

Dynamic Route Handler

// app/api/posts/[id]/route.ts
type RouteContext = {
  params: Promise<{ id: string }>
}
 
export async function GET(_request: Request, { params }: RouteContext) {
  const { id } = await params
  const post = await getPost(id)
 
  if (!post) return new Response('Not Found', { status: 404 })
  return Response.json(post)
}
 
export async function DELETE(_request: Request, { params }: RouteContext) {
  const { id } = await params
  await deletePost(id)
  return new Response(null, { status: 204 })
}

Server Actions vs Route Handlers

ItemServer ActionsRoute Handlers
Primary useForm handling, DB writes tightly coupled to UIREST API, externally exposed endpoints
How to callImport and call directly as a functionSend an HTTP request via fetch
External accessGenerally not accessible externallyAccessible (e.g. Webhooks from services)
Auth / header controlHandled implicitly on the serverControlled explicitly via Request object
Read data (GET)Not recommended (use Server Component directly)Common

Decision guide

  • Form operations / DB writes inside the app → Server Actions
  • Fetching data from CSR (Client Component) via fetchRoute Handlers
  • Exposing an API to external services / receiving Webhooks → Route Handlers

10. Environment Variables

# .env.local (exclude from version control)
DATABASE_URL="postgres://..."       # server-side only
NEXT_PUBLIC_API_URL="https://..."   # exposed to the client as well
// Server-side only (Server Component / Route Handler / Server Action)
process.env.DATABASE_URL
 
// Available on the client (NEXT_PUBLIC_ prefix required)
process.env.NEXT_PUBLIC_API_URL

Variables without the NEXT_PUBLIC_ prefix return an empty string when accessed on the client.


Tips

Optimize Images with next/image

import Image from 'next/image'
 
<Image
  src="/hero.png"
  alt="Hero image"
  width={1200}
  height={630}
  priority  // set for LCP images (large images shown first)
/>

Optimize Fonts with next/font

// app/layout.tsx
import { Inter } from 'next/font/google'
 
const inter = Inter({
  subsets: ['latin'],
  weight: ['400', '700'],
})
 
type Props = {
  children: React.ReactNode
}
 
export default function RootLayout({ children }: Props) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  )
}

Key Changes in v15

ChangeUp to v14v15 and Later
params / searchParamsSynchronousAsynchronous (await required)
cookies() / headers()SynchronousAsynchronous (await required)
fetch cachingCached by defaultNot cached by default