Before You Begin

  • Node.js 20+ (LTS recommended)
  • npm or yarn
  • A code editor (VS Code preferred)
  • Basic familiarity with React (components, hooks)
  • No prior Next.js experience required — we start from zero

Why Next.js 16 Changes How You Build for the Web

Most tutorials teach Next.js like it's still 2022: pages router, getServerSideProps, client-side everything. That approach is now legacy. Next.js 16 with App Router flips the model: server-first by default. If you're coming from older Next.js or plain React, the mental shift is significant.

Compare:

  • Old Next.js: You decide server vs client per page. Data fetching lives in special functions.
  • Next.js 16: Every component is a Server Component unless you explicitly mark it 'use client'. Data fetching is just async/await in the component.

The result: less boilerplate, better performance, simpler mental model. But it also means you must think differently about state, effects, and browser APIs.

We'll build a notes app called "PandaNotes" — a simple Markdown note-taker where users can create, edit, and delete notes. The animal theme runs through file names, variable names, and UI labels where natural.

Step 1: Project Setup — The Bear Minimum

Instead of create-next-app with all defaults, we'll start manual to understand every piece.

mkdir pandanotes && cd pandanotes
npm init -y
npm install next@latest react@latest react-dom@latest

Add scripts to package.json:

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  }
}

Create app/layout.tsx:

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <main style={{ maxWidth: 800, margin: '0 auto', padding: '1rem' }}>
          {children}
        </main>
      </body>
    </html>
  );
}

And app/page.tsx:

export default function HomePage() {
  return <h1>🐼 PandaNotes</h1>;
}

Run npm run dev. You should see "PandaNotes" at localhost:3000. This is your first Server Component — no 'use client', no hooks, just an async-friendly function.

Step 2: Server Components vs Client Components — The Great Divide

A common StackOverflow pitfall: "Why can't I use useState in my Next.js component?" Answer: You're in a Server Component. The official Next.js docs draw a clear line:

  • Server Components (default): Run on the server. Can await data, access databases, read files. Cannot use hooks, event handlers, or browser APIs.
  • Client Components ('use client'): Run in the browser. Full React feature set, but no direct server access.

The rule of thumb from engineering blogs at Vercel: "Push state as low as possible. Fetch data as high as possible."

In PandaNotes, we'll keep the note list as a Server Component (fetching from a local JSON file), and only make the editor a Client Component.

Create lib/foxNotes.ts — a data layer using a simple array as stand-in for a database:

// lib/foxNotes.ts
// 🦊 Fox-themed note storage — our pretend database
export type FoxNote = {
  id: string;
  title: string;
  content: string;
  createdAt: string;
};

const notes: FoxNote[] = [
  { id: '1', title: 'Gathering Berries', content: '# Berry season
Collect raspberries before the bears wake up.', createdAt: new Date().toISOString() },
  { id: '2', title: 'Hollow Tree Map', content: '## Oak hollow
Coordinates: 42.36, -71.05', createdAt: new Date().toISOString() },
];

export function getFoxNotes(): FoxNote[] {
  return notes;
}

Update app/page.tsx:

import { getFoxNotes } from '@/lib/foxNotes';
import NoteList from '@/components/NoteList';

export default async function HomePage() {
  const notes = getFoxNotes();
  return (
    <>
      <h1>🐼 PandaNotes</h1>
      <NoteList notes={notes} />
    </>
  );
}

Note: async is allowed in Server Components. This is a key difference from client components.

Step 3: Dynamic Routes — One Note at a Time

Instead of the typical "create a [slug] folder" explanation, let's contrast with how you'd do it in Express.js:

  • Express: router.get('/notes/:id', handler)
  • Next.js App Router: File app/notes/[id]/page.tsx

Create app/notes/[id]/page.tsx:

import { getFoxNotes } from '@/lib/foxNotes';
import { notFound } from 'next/navigation';

export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;  // ⚠️ Always await params — this trips up many devs
  const notes = getFoxNotes();
  const note = notes.find(n => n.id === id);
  
  if (!note) {
    notFound();  // Shows Next.js default 404
  }

  return (
    <article>
      <h2>{note.title}</h2>
      <pre>{note.content}</pre>
    </article>
  );
}
Sponsored Deal

Common issue on StackOverflow: forgetting await params in App Router. The error message is cryptic — something like "params.then is not a function". Always destructure after await.

Step 4: Client-Side Interactivity — The Raccoon Editor

For the note editor, we need useState and useEffect. That means a Client Component. Create components/RaccoonEditor.tsx:

'use client';  // 🔴 Required for hooks

import { useState } from 'react';

type Props = {
  initialTitle: string;
  initialContent: string;
  noteId: string;
};

export default function RaccoonEditor({ initialTitle, initialContent, noteId }: Props) {
  const [title, setTitle] = useState(initialTitle);
  const [content, setContent] = useState(initialContent);

  const handleSave = async () => {
    // Will connect to API route later
    console.log('Saving', title, content);
  };

  return (
    <div>
      <input value={title} onChange={e => setTitle(e.target.value)} />
      <textarea value={content} onChange={e => setContent(e.target.value)} />
      <button onClick={handleSave}>Save 🦝</button>
    </div>
  );
}

Use it in app/notes/[id]/page.tsx by importing — but be careful: you can't pass a Server Component as a direct child of a Client Component without using children or serializable props. The rule: Client Components can import Server Components, but only as children or through composition.

Here's the pattern recommended by Next.js docs:

// app/notes/[id]/page.tsx
export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  // ...fetch note
  return (
    <article>
      <h2>{note.title}</h2>
      <RaccoonEditor initialTitle={note.title} initialContent={note.content} noteId={id} />
    </article>
  );
}

Works fine because RaccoonEditor is a Client Component leaf.

Step 5: API Routes — The Badger Backend

Next.js API routes in App Router live under app/api/. Let's build a POST endpoint for saving notes.

Create app/api/notes/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const saveNoteSchema = z.object({
  id: z.string(),
  title: z.string().min(1, 'Title is required'),
  content: z.string().min(1, 'Content is required'),
});

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const result = saveNoteSchema.safeParse(body);
    if (!result.success) {
      return NextResponse.json({ error: result.error.format() }, { status: 400 });
    }
    // In production, save to database here
    console.log('Saving note:', result.data);
    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('Badger error:', error);
    return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
  }
}

This follows the pattern from Express.js (Zod validation, centralized error handling) but adapted for Next.js. The key difference: Next.js API routes are still server-side, but they're file-based rather than code-based.

Step 6: Data Persistence with Server Actions (The Otter Way)

Server Actions are a Next.js 16 feature that lets you run server code directly from Client Components without writing API routes. Compare:

  • API route: Client fetches POST /api/notes → Server handles → Returns JSON
  • Server Action: Client calls function directly → Server runs it → Returns result

Server Actions reduce boilerplate. Create lib/otterActions.ts:

'use server';

import { z } from 'zod';
import { getFoxNotes } from './foxNotes';

const schema = z.object({
  id: z.string(),
  title: z.string().min(1),
  content: z.string().min(1),
});

export async function saveNoteAction(formData: FormData) {
  const raw = {
    id: formData.get('id'),
    title: formData.get('title'),
    content: formData.get('content'),
  };
  const result = schema.safeParse(raw);
  if (!result.success) {
    return { error: result.error.format() };
  }
  // In production, update database
  console.log('Otter saved:', result.data);
  return { success: true };
}

Update RaccoonEditor to use the Server Action:

'use client';
import { saveNoteAction } from '@/lib/otterActions';
import { useState } from 'react';

export default function RaccoonEditor({ initialTitle, initialContent, noteId }: Props) {
  const [title, setTitle] = useState(initialTitle);
  const [content, setContent] = useState(initialContent);

  const handleSave = async () => {
    const formData = new FormData();
    formData.set('id', noteId);
    formData.set('title', title);
    formData.set('content', content);
    const result = await saveNoteAction(formData);
    if (result.error) {
      alert('Save failed: ' + JSON.stringify(result.error));
    }
  };

  // ... rest of component
}

No API route, no fetch, no JSON parsing. The Server Action automatically serializes the FormData and runs on the server. This is the pattern recommended by the Next.js team for form mutations.

Step 7: Error Handling and Loading States

Next.js App Router supports error boundaries and loading states as file conventions. Create app/error.tsx:

'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong 🐻</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

And app/loading.tsx:

export default function Loading() {
  return <p>🐿️ Loading notes...</p>;
}

These files automatically wrap your page and segment. No manual Suspense boundaries needed for basic cases.

Common Issues

  1. "params is a Promise": Always await params in page and layout components. Forgetting this causes cryptic "params.then is not a function" errors.
  2. "Cannot use useState outside of a Client Component": Add 'use client' at the very top of the file. The directive must be the first line.
  3. "Hydration mismatch": Often caused by using Date.now() or Math.random() in Server Components. The server and client must render the same HTML. Use useEffect for client-only values.
  4. Server Action not updating UI: Server Actions don't automatically revalidate data. Call revalidatePath() from next/cache inside the action to refresh the page cache.

Where to Go Next

Clone the complete PandaNotes repo from GitHub to see the full implementation. The official Next.js documentation covers advanced patterns like parallel routes, intercepting routes, and middleware — all of which build on the foundation you've learned here.