This post teaches the core of Next.js 16 by building one small, real app: Mini Bank. It has a dashboard with your balance, a transactions list, a detail page for every transaction, a JSON API, an "Add transaction" form, loading and error states, SEO titles, and a production build.
I built it from an empty folder in the video, one lesson per git commit. Everything on screen actually ran.
Versions: Next.js 16.3.5 · React 19.2.8 · Tailwind CSS 4 · TypeScript 5 · Node.js 24
The code below is simplified to show each idea clearly. The full walkthrough, with every file typed on screen, is in the video.
0. Create the project
npx create-next-app@16.3.5 mini-bank --ts --tailwind --eslint --app --import-alias "@/*" --use-npm --yes
cd mini-bank
npm run dev
The flags pick TypeScript, Tailwind, ESLint and the App Router, the modern way to build Next.js apps. Install took about a minute and a half on my Mac, and the dev server was ready on localhost:3000 in under a second.
New in 16: the project includes an AGENTS.md file that tells AI coding tools to read the docs for this exact version, which ship inside node_modules/next/dist/docs.
Lesson 1: Your first page
In the App Router, a page.tsx file inside app becomes a page on your site. app/page.tsx is the homepage, and it's just a React component exported as the default export:
export default function Home() {
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="text-4xl font-bold">Mini Bank</h1>
<p className="text-gray-500">Your money, at a glance.</p>
</main>
);
}
Save, and the browser updates on its own.
Lesson 2: Layouts and navigation
A layout wraps your pages. Whatever you put in app/layout.tsx shows on every page, and children is the current page. When you move between pages, the layout doesn't re-render. It keeps its state, and only the page part changes.
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
<Header />
{children}
</body>
</html>
);
}
LayoutProps<"/"> is a type Next.js generates from your folders. In the header, use Link from next/link instead of <a>. It switches pages without a full reload and prefetches linked pages before you click.
The rule that makes Next.js easy: a folder inside app becomes a URL. app/transactions/page.tsx is the page at /transactions, with no router setup.
Lesson 3: Server Components and real data
This is the big idea in modern Next.js. By default, components in app run on the server. That means a component can read files, talk to a database or use secret keys, and none of that code is sent to the browser.
The data is a JSON file with seven transactions (positive amounts are money in, negative are money out) and a small lib/data.ts that reads it from disk:
// app/page.tsx
export default async function Home() {
const transactions = await getTransactions();
const balance = getBalance(transactions);
return (
<main>
<p className="text-4xl font-bold">{formatMoney(balance)}</p>
{transactions.slice(0, 5).map((t) => <TransactionRow key={t.id} t={t} />)}
</main>
);
}
The component itself is async and simply awaits the data. No useEffect, no loading variables, no fetching in the browser.
So when do you need a Client Component? Only for state, click handlers, effects, or browser APIs like localStorage. Then you add "use client" at the top of the file.
Next.js 16 change: data requests aren't cached by default. You get fresh data unless you opt in with "use cache".
Lesson 4: Dynamic routes
Every transaction gets its own page with square brackets: a folder named [id] matches any value in that part of the URL, so /transactions/d1 and /transactions/d2 use the same file with different data.
// app/transactions/[id]/page.tsx
export default async function Page({ params }: PageProps<"/transactions/[id]">) {
const { id } = await params; // params is a Promise in Next.js 16
const t = await getTransaction(id);
if (!t) notFound(); // stops here, shows not-found.tsx
return <h1>{t.description}</h1>;
}
This changed in recent versions: params is a Promise, and you have to await it. Many older tutorials get this wrong. notFound() stops the page right there, so the code below it never runs with missing data.
Lesson 5: Build an API
Instead of page.tsx, create route.ts. Export a function named after the HTTP method:
// app/api/transactions/route.ts
export async function GET() {
return Response.json(await getTransactions());
}
Open /api/transactions and there's your JSON, ready for a mobile app or anyone else. You can also export POST, PUT, PATCH and DELETE. Call a method you didn't export, and Next.js answers 405 Method Not Allowed.
Use an API when another app needs your data. Your own pages can read data directly in Server Components, like in Lesson 3.
Lesson 6: Forms with Server Actions
This is my favorite lesson. "use server" at the top of a file turns every function in it into a server function:
"use server";
export async function createTransaction(formData: FormData) {
const description = String(formData.get("description") ?? "").trim();
const amount = Number(formData.get("amount"));
if (!description || !(amount > 0)) return; // save nothing
const signed = formData.get("type") === "out" ? -amount : amount;
await addTransaction({ description, amount: signed, date: today() });
revalidatePath("/");
revalidatePath("/transactions");
redirect("/transactions");
}
And the form needs just one prop:
<form action={createTransaction}>
<input name="description" />
<input name="amount" type="number" step="0.01" />
<select name="type"><option value="out">Money out</option><option value="in">Money in</option></select>
<button>Save</button>
</form>
No API route, no fetch, no useState. The inputs only need name attributes so the server can read them.
Two details:
-
redirect()works by throwing, so nothing after it runs. That's whyrevalidatePathcomes first. - Server Functions can be called directly, so in a real app, always check the user is logged in inside them.
In the real test, I added "Lunch with the team" as money out. It appeared at the top of the list, and the dashboard balance dropped by exactly that amount.
Lesson 7: Loading and error states
To see loading clearly, I added a fake 1.2-second database delay. Then:
-
loading.tsxnext to a page is shown instantly while the page loads. Next.js wraps the page in a React Suspense boundary and uses it as the fallback. Mine is a pulsing gray skeleton. -
error.tsxcatches errors in its folder and below. It must be a Client Component ("use client").
"use client";
export default function Error({ error, retry }: { error: Error; retry: () => void }) {
return (
<div>
<p>{error.message}</p>
<button onClick={() => retry()}>Try again</button>
</div>
);
}
Next.js 16.3 change: the error page receives retry(), which fetches the data again and re-renders. Older tutorials call it reset().
The real test: I broke the data file on purpose, and the page showed the error with a "Try again" button. I fixed the file, clicked "Try again", and it loaded without a page refresh.
Lesson 8: SEO metadata and images
Export a metadata object from the root layout:
export const metadata: Metadata = {
title: { template: "%s | Mini Bank", default: "Mini Bank" },
description: "A simple banking dashboard built with Next.js 16.",
};
Pages can set their own title, and the detail page uses generateMetadata, which gets the same params as the page, so each transaction's description becomes its tab title. These are the titles people see in Google, in browser tabs and in shared links.
For the logo, use Image from next/image with a width and height so the page doesn't jump while loading. Files in public/ are served from the root of your site.
Lesson 9: Production build
npm run build
npm start
The build compiled in 2.4 seconds with no type errors. If there were a type error anywhere, the build would stop and tell you before your users ever saw the bug.
Read the route table it prints:
- ○ (empty circle) = static, built ahead of time, so it loads fast. The homepage is static.
- ƒ = dynamic, built on each request. The detail page (the ID comes from the URL) and the API route are dynamic.
Does saving a transaction still update the static dashboard in production? I tested it on the real production server, and yes. That's exactly what revalidatePath is for.
Recap
-
page.tsxmakes a route, andlayout.tsxwraps pages. - Server Components load data on the server.
-
[id]folders make dynamic routes, andparamsmust be awaited. -
route.tsbuilds an API. -
"use server"turns a function into a form action. -
loading.tsxanderror.tsx(withretry()) handle the in-between moments. -
metadataandgenerateMetadatahandle SEO.
That's the core of Next.js 16, and the whole project is about 10 files. Build it yourself, change the data, add your own page. That's how it sticks.
Next steps: swap the JSON file for a real database, add login, and deploy.
Full video, every lesson typed live: youtu.be/cncAXhX75yw
Which should I teach next: a real database, login, or deploying this app? Tell me in the comments.
Sources: Next.js blog, Next.js 16.3 (nextjs.org/blog/next-16-3) · Next.js docs shipped with next@16.3.5
Drafted with AI assistance. The code and results come from the recorded build. I make AI, coding and tech tutorials on RainyTech.
Top comments (0)