Flex Engineers
5 min read

What Our Engineers Actually Learn Building a Next.js App Router Reference Lab

Next.jsApp RouterServer ActionsSoftware ArchitectureTechnical Mentoring

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

ConcernChoiceWhy
FrameworkNext.js App Router (JS, not TS)Forces understanding server/client boundaries without type-level hand-holding
Persistencebetter-sqlite3Zero-config, synchronous, file-based — no ORM noise between the app code and the SQL
AuthLucia + @lucia-auth/adapter-sqliteSession-based auth with full control over the cookie lifecycle, not a black-box provider
Password hashingNode's built-in crypto.scryptSyncNo external hashing dependency needed — scrypt + timing-safe comparison is enough
Image uploadsCloudinary, uploaded as base64 data URIsAvoids a separate file-storage service for a learning-scale project
StylingPlain CSS ModulesKeeps 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.