Why I built this instead of just reading docs
Most "learn a framework" advice boils down to reading the docs and building a todo app. That works until production throws something at you that the docs glossed over — a redirect that silently swallows your cache invalidation, a session cookie that needs manual refresh logic, a form action that has to work with and without JavaScript. So instead of one throwaway app, I built a repo of ten focused, runnable Next.js projects, each isolating one architectural concern in the App Router: routing and rendering, data fetching, mutations, caching, optimization, and authentication. Every folder is a real, working app with its own package.json, a SQLite database, and enough surface area to hit the edge cases that matter.
The point wasn't to collect tutorials. It was to build a reference I could go back to mid-project and copy working, tested patterns from — the same reason I'm writing this post now.
The stack across all ten apps
| Concern | Choice | Why |
|---|---|---|
| Framework | Next.js App Router (JS, not TS) | Forces understanding server/client boundaries without type-level hand-holding |
| Persistence | better-sqlite3 | Zero-config, synchronous, file-based — no ORM noise between the app code and the SQL |
| Auth | Lucia + @lucia-auth/adapter-sqlite | Session-based auth with full control over the cookie lifecycle, not a black-box provider |
| Password hashing | Node's built-in crypto.scryptSync | No external hashing dependency needed — scrypt + timing-safe comparison is enough |
| Image uploads | Cloudinary, uploaded as base64 data URIs | Avoids a separate file-storage service for a learning-scale project |
| Styling | Plain CSS Modules | Keeps the focus on Next.js internals, not a UI library |
Every app is intentionally small and disposable so the pattern stays visible, not buried under abstraction.
The gotcha that actually taught me something: redirect() vs revalidatePath()
The clearest example of "docs vs. reality" showed up in the mutations module. Here's the original server action for creating a post:
'use server';
import { redirect } from 'next/navigation';
import { storePost } from '@/lib/posts';
import { uploadImage } from '@/lib/cloudinary';
import { revalidatePath } from 'next/cache';
export async function createPost(prevState, formData) {
// ...validation...
await storePost({ imageUrl, title, content, userId: 1 });
redirect('/feed');
revalidatePath('/', 'layout');
}
This looks fine at a glance — store the post, send the user to the feed, invalidate the cache so the feed shows fresh data. It doesn't work. redirect() in Next.js works by throwing a special NEXT_REDIRECT error that the framework catches higher up the call stack. Anything after redirect() in the same function — including revalidatePath() — never executes, because control never returns to that line. The fix is just reordering:
await storePost({ imageUrl, title, content, userId: 1 });
revalidatePath('/', 'layout');
redirect('/feed');
Nothing about this is documented as a warning in big red letters — you find it by watching stale data survive a mutation and tracing it back to control flow. That's exactly the kind of bug that's cheap to fix once you understand why it happens and expensive to debug blind in a client's production app.
Caching: making the invisible visible
The caching module runs two apps side by side — a small Express server that logs every hit, and a Next.js client that fetches from it — specifically so cache behavior is observable instead of theoretical. The page starts like this:
import { unstable_noStore } from 'next/cache';
// export const revalidate = 5;
// export const dynamic = 'force-dynamic';
export default async function MessagesPage() {
// unstable_noStore();
const response = await fetch('http://localhost:8080/messages');
const messages = await response.json();
...
}
Toggling each commented line on and off, then watching the Express server's console.log fire (or not fire) on refresh, makes the difference between Next's caching strategies concrete instead of memorized:
- Default fetch caching — the Express log doesn't fire on repeat visits; Next served from its Data Cache.
export const revalidate = 5— time-based revalidation; the log fires again after 5 seconds, not before.export const dynamic = 'force-dynamic'— opts the whole route out of static rendering.unstable_noStore()— a granular opt-out at the fetch call, useful when only part of a route needs to always be fresh.
Seeing the server-side log fire (or stay silent) is a much faster feedback loop than reading about cache tags in the abstract.
Auth: session cookies without a framework doing it for you
The authentication module implements email/password auth with Lucia, but the interesting part is what has to happen manually around it — because Lucia handles session storage, not the full cookie lifecycle:
export async function verifyAuth() {
const sessionCookie = (await cookies()).get(lucia.sessionCookieName);
if (!sessionCookie) return { user: null, session: null };
const result = await lucia.validateSession(sessionCookie.value);
if (result.session && result.session.fresh) {
const refreshed = lucia.createSessionCookie(result.session.id);
(await cookies()).set(refreshed.name, refreshed.value, refreshed.attributes);
}
if (!result.session) {
const blank = lucia.createBlankSessionCookie();
(await cookies()).set(blank.name, blank.value, blank.attributes);
}
return result;
}
Building this by hand forces you to internalize the actual session lifecycle: a session can be valid-and-fresh (nothing to do), valid-but-stale (needs cookie refresh so it doesn't expire under the user), or invalid (needs a blank cookie written back so a stale reference doesn't linger in the browser). Passwords are hashed with crypto.scryptSync plus a random salt and compared with crypto.timingSafeEqual — deliberately avoiding both plaintext-adjacent shortcuts and an opaque hashing library, so the security properties (salting, timing-attack resistance) are explicit in the code rather than assumed.
What this demonstrates
This repo isn't a product — it's evidence of how I close the gap between "the docs say X" and "here's what X actually does when the app is running." Each module is small enough to read end-to-end but real enough to expose the failure modes that matter: control-flow bugs around redirect(), the difference between four caching strategies you can only tell apart by watching server logs, and the manual bookkeeping session-based auth requires once you look past a library's happy path. When I bring Next.js into a client project, this is the layer of understanding that shows up as fewer surprises in production — not because I memorized the API surface, but because I've deliberately broken and rebuilt each piece of it once already.