This final chapter assembles the course into one application: a multi-user task manager with authentication, a database, Server Actions, optimistic UI, tests and a production build. It shows the skeleton and the decisions behind each piece. After this lesson you will be able to start a full-stack Next.js project with a structure that scales.
Features: sign up and log in, a private task list with instant create and toggle, a public landing page, SEO metadata, tests and a Docker image.
| Concern | Choice | Lesson |
|---|---|---|
| Database | Drizzle with SQLite locally, PostgreSQL in production | Database Access |
| Auth | jose session cookie verified in a DAL | Authentication Patterns |
| Mutations | Server Actions, Zod, useActionState, useOptimistic | Server Actions, Form State |
| Tests | Vitest for the DAL, Playwright for flows | Testing |
| Deploy | output: "standalone" and a Dockerfile | Docker and Self-Hosting |
src/
├── app/
│ ├── (marketing)/page.tsx # public landing page
│ ├── (auth)/login/page.tsx # login form + action
│ ├── (app)/
│ │ ├── layout.tsx # verifySession(), nav
│ │ └── tasks/
│ │ ├── page.tsx # loads tasks (server)
│ │ ├── actions.ts # createTask, toggleTask
│ │ ├── TaskForm.tsx # useActionState
│ │ ├── TaskList.tsx # useOptimistic
│ │ └── loading.tsx, error.tsx
│ └── layout.tsx # root layout, fonts, metadata
├── db/ (schema.ts, index.ts)
├── lib/ (dal.ts, session.ts, env.ts)
└── proxy.ts # redirects anonymous users from /tasksRoute groups separate the three shells, and all data access goes through lib/dal.ts.
// src/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { users } from "./users"; // id, email, passwordHash
export const tasks = sqliteTable("tasks", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id),
title: text("title").notNull(),
done: integer("done", { mode: "boolean" }).notNull().default(false),
});src/db/index.ts exports db from drizzle with the better-sqlite3 driver; npx drizzle-kit push creates the tables. The DAL function getTasks() calls verifySession() first and filters by userId, as do the actions in the sample at the top of this lesson, so cross-user access is impossible by construction.
// src/app/(app)/tasks/page.tsx
import { getTasks } from "@/lib/dal";
import { TaskForm } from "./TaskForm";
import { TaskList } from "./TaskList";
export default async function TasksPage() {
const tasks = await getTasks();
return (
<main>
<h1>Tasks</h1>
<TaskForm />
<TaskList tasks={tasks} />
</main>
);
}TaskForm wires createTask through useActionState and shows the returned error. TaskList wraps toggleTask in useOptimistic so a checkbox flips instantly and reverts if the action fails. loading.tsx shows a skeleton and error.tsx offers a retry. The page is a Server Component, so the query never reaches the browser.
/tasks to /login when the session cookie is absent; the DAL remains the real check.lib/env.ts validates DATABASE_URL and SESSION_SECRET with Zod.metadata; sitemap.ts lists public routes and robots.ts disallows /tasks.getTasks scoping with a mocked session; Playwright adds a task and asserts it persists.next build should show the landing page as static and /tasks as dynamic. Ship with the standalone Dockerfile and point DATABASE_URL at PostgreSQL.Where does the capstone enforce that users only see their own tasks?
Next lesson: What to learn next — deepen React 19 (Suspense, transitions, the Compiler), study relational database design, and set up CI/CD so every push runs your tests and builds.