Getting Started with Next.js 16, Without Fighting the Framework
A small, current Next.js 16 App Router foundation that keeps server and client responsibilities clear.
Next.js is easiest to learn when it is treated as a server-first application framework, not as a React single-page application with extra folders. The App Router gives each route a server-rendered starting point. Client code is added only where the browser must hold state or respond directly to interaction.
This guide targets Next.js 16 and React 19. The official App Router documentation remains the authority if an API changes.
Start with a small route
Create a project with TypeScript and the App Router:
bunx create-next-app@latest signal-notes --typescript --tailwind --eslint --app cd signal-notes bun dev
A page is a Server Component unless it begins with "use client". That default is useful. It means data access and secrets can stay on the server while the browser receives less JavaScript.
// app/notes/page.tsx import { NoteList } from "@/features/notes/note-list"; import { noteRepository } from "@/features/notes/note.repository"; export default async function NotesPage() { const notes = await noteRepository.listPublished(); return ( <main> <h1>Notes</h1> <NoteList notes={notes} /> </main> ); }
The repository runs on the server. NoteList can also remain a Server Component if it only renders data.
Add a client boundary deliberately
Search input needs browser state, so it becomes a small Client Component:
"use client"; import { useMemo, useState } from "react"; export function NoteFilter({ notes, }: { notes: Array<{ id: string; title: string }>; }) { const [query, setQuery] = useState(""); const filtered = useMemo( () => notes.filter((note) => note.title.toLowerCase().includes(query.toLowerCase()), ), [notes, query], ); return ( <> <label> Search notes <input value={query} onChange={(event) => setQuery(event.target.value)} /> </label> <ul> {filtered.map((note) => ( <li key={note.id}>{note.title}</li> ))} </ul> </> ); }
The client boundary is narrow. Data loading does not move into an effect, and the initial page still contains useful HTML.
Mutations belong behind validation
Server Actions are convenient, but they are public server entry points. Validate the input and authorize the action inside the function.
"use server"; import { z } from "zod"; import { requireAdmin } from "@/features/auth/require-admin"; const noteSchema = z.object({ title: z.string().trim().min(3).max(120), content: z.string().trim().min(20).max(50_000), }); export async function saveNote(formData: FormData) { const admin = await requireAdmin(); const input = noteSchema.parse({ title: formData.get("title"), content: formData.get("content"), }); return noteRepository.save(input, admin.id); }
Do not rely on a hidden button, a protected layout, or a cookie merely existing. Authorization is a business rule and should be checked again where data changes.
Lazy-load the expensive part
The official lazy-loading guide recommends deferring Client Components and browser-only libraries that are not needed for the initial route. A 3D scene is a good example:
"use client"; import dynamic from "next/dynamic"; const Scene = dynamic(() => import("./scene"), { ssr: false, loading: () => <div aria-label="Scene loading" />, }); export function SceneSlot() { return <Scene />; }
Keep the real heading, copy, links, and form outside the scene. WebGL then adds context instead of becoming a gate.
A practical default
Use Server Components for data and stable presentation. Add Client Components for direct interaction. Put authorization and validation inside every mutation. Lazy-load large browser libraries. Measure the result before adding another abstraction.
That small set of boundaries is enough for many Next.js applications. The framework works best when each layer is given a clear job.