Why we build 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, we have engineers on our growth track build 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 on client engagements.
The point isn't to collect tutorials. It's to build a reference an engineer can go back to mid-project and copy working, tested patterns from — the same reference other engineers on the team pull from when a Next.js question comes up in review.
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 teaches something: redirect() vs revalidatePath()
The clearest example of "docs vs. reality" shows up in the mutations module, and it's the one we point new engineers to first. 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 — an engineer finds 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, which is why it's now a standing item in our onboarding notes for anyone new to the App Router.
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 for an engineer 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 an engineer 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 program demonstrates
This repo isn't a product — it's evidence of how we close the gap, team-wide, 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 we bring an engineer onto a client's Next.js project, this is the layer of understanding that shows up as fewer surprises in production — not because they memorized the API surface, but because our growth program made sure they'd deliberately broken and rebuilt each piece of it once already.
