What You'll Build
You will build an authenticated CRUD application — a personal cricket-match tracker where a logged-in user can create, read, update, and delete their own match records — that integrates every Module 4 concept into one secure, real-data application. It combines authentication and sessions to identify the user, an ORM-backed database for persistence, action-bound forms with server validation and pending states for every mutation, optimized images and fonts for the UI, and proper authorization so each user can only touch their own records. By the end you will have an application where a user logs in, sees a list of only their matches rendered from the database, adds a match through a validated form, edits and deletes existing matches, and is prevented at the data layer from accessing anyone else's data.
This is the most security-sensitive project in the course because it is the first that combines real persistent data with real user accounts, which is exactly the combination where authorization mistakes become data breaches. Every concept from Module 4 has a specific job here: sessions establish who the user is, the ORM persists the data safely with parameterized queries, the forms validate authoritatively on the server and stay forgiving for the user, and authorization checks at the data layer ensure that identifying a user is not the same as letting them do anything. The central discipline you will practise is that every read and every write independently verifies both authentication — is there a logged-in user — and authorization — does this specific user own this specific record. Getting that discipline right across a full CRUD surface is the skill that separates building a toy from building something you could actually let real users' real data into.
Prerequisites
- A working App Router project, plus a database the ORM can connect to (a local or hosted instance) and the ORM installed.
- Understanding of sessions and reading the authenticated user on the server from an httpOnly cookie via a cached helper.
- Familiarity with defining an ORM schema, running migrations, and querying directly from Server Components and Actions.
- Knowledge of action-bound forms with server-side validation, useActionState for errors and values, and useFormStatus for pending state.
- Awareness of the distinction between authentication (who you are) and authorization (what you may do to a specific resource).
- Comfort with revalidatePath after mutations and with next/image and next/font for the UI.
Setup & Project Structure
Plan the full structure so the security boundaries are visible from the layout. You will have an ORM schema with a Match model owned by a user, a shared database client and a cached current-user helper, middleware gating the authenticated area, a matches list and form under that area, and an actions file whose every function verifies authentication and ownership before touching data. Laying this out first makes the central rule concrete: every action in the actions file will begin with the same authenticate-and-authorize preamble before any database operation, and the structure should make that pattern obvious and consistent.
# Target structure for the authenticated CRUD app:
# middleware.js \u2190 gate the matches area
# prisma/schema.prisma \u2190 Match model owned by User
# lib/
# db.js \u2190 single reused ORM client
# auth.js \u2190 cached getCurrentUser() from httpOnly cookie
# app/
# login/ \u2190 login form + action (sets session cookie)
# (app)/
# matches/
# page.js \u2190 list ONLY the user's matches (read)
# new/page.js \u2190 create form
# [id]/edit/page.js \u2190 edit form (authorized)
# actions.js \u2190 create/update/delete: authn + authz each
npm run dev # http://localhost:3000Step 1 — Foundation
Step 1 establishes the schema, the database client, and the authentication helper — the three foundations every later step depends on. The Match model is defined with an owner relation so every record is tied to a user, which is what makes ownership-based authorization possible. The database client is instantiated once and reused, and the getCurrentUser helper reads the session cookie and returns the user, cached so it verifies once per render. Building these first means the ownership link and the identity read — the two halves of authorization — exist before any feature uses them.
// prisma/schema.prisma (conceptual)
// model User { id Int @id @default(autoincrement()) email String @unique matches Match[] }
// model Match { id Int @id @default(autoincrement()) opponent String result String
// ownerId Int owner User @relation(fields:[ownerId], references:[id]) }
// lib/db.js \u2014 single reused client
import { PrismaClient } from '@prisma/client';
const g = globalThis;
export const db = g.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') g.prisma = db;
// lib/auth.js \u2014 cached current-user read from the httpOnly session cookie
import { cookies } from 'next/headers';
import { cache } from 'react';
export const getCurrentUser = cache(async () => {
const token = (await cookies()).get('session')?.value;
if (!token) return null;
try { return await verifySessionToken(token); } catch { return null; }
});Step 2 — Core Logic
Step 2 builds the authenticated read and the create operation, establishing the authenticate-then-scope pattern for reads and the authenticate-validate-write pattern for mutations. The matches list queries only matches owned by the current user, so the query itself is scoped by ownership rather than fetching everything and filtering in the UI. The create action verifies authentication, validates the form input, and writes the new match tied to the current user's id — never trusting a client-supplied owner id. This step makes the two core security patterns concrete before the more dangerous update and delete operations build on them.
// app/(app)/matches/page.js \u2014 read ONLY the user's matches
import { redirect } from 'next/navigation';
import { db } from '../../../lib/db';
import { getCurrentUser } from '../../../lib/auth';
export default async function MatchesPage() {
const user = await getCurrentUser();
if (!user) redirect('/login');
const matches = await db.match.findMany({ where: { ownerId: user.id } }); // scoped
return (
<div>
<h1>\ud83c\udfcf My matches</h1>
<ul>{matches.map((m) => <li key={m.id}>{m.opponent} \u2014 {m.result}</li>)}</ul>
</div>
);
}
// app/(app)/matches/actions.js \u2014 create: authn + validate + write under user.id
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '../../../lib/db';
import { getCurrentUser } from '../../../lib/auth';
export async function createMatch(prev, formData) {
const user = await getCurrentUser();
if (!user) return { error: 'Not authenticated' };
const opponent = String(formData.get('opponent') ?? '').trim();
const result = String(formData.get('result') ?? '').trim();
if (opponent.length < 2) return { error: 'Opponent required', values: { opponent, result } };
await db.match.create({ data: { opponent, result, ownerId: user.id } }); // never client id
revalidatePath('/matches');
return { ok: true };
}Step 3 — Integration & Enhancement
Step 3 adds update and delete — the operations where authorization matters most — plus the forgiving form experience. Both update and delete must verify not just that a user is logged in but that they own the specific record being modified, by fetching the record and confirming its ownerId matches the current user before proceeding; without this check, a user could edit or delete another user's match by sending its id. The forms use useActionState and useFormStatus so errors and pending states are handled, and the UI uses next/image and next/font. This is the integration heart: the full CRUD surface, every operation independently authenticated and authorized at the data layer.
// app/(app)/matches/actions.js \u2014 update and delete with OWNERSHIP checks
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '../../../lib/db';
import { getCurrentUser } from '../../../lib/auth';
async function requireOwnedMatch(id, user) {
const match = await db.match.findUnique({ where: { id } });
if (!match || match.ownerId !== user.id) return null; // authz: must own it
return match;
}
export async function updateMatch(prev, formData) {
const user = await getCurrentUser();
if (!user) return { error: 'Not authenticated' };
const id = Number(formData.get('id'));
const owned = await requireOwnedMatch(id, user);
if (!owned) return { error: 'Not allowed' }; // someone else's record \u2192 refuse
const result = String(formData.get('result') ?? '').trim();
if (!result) return { error: 'Result required' };
await db.match.update({ where: { id }, data: { result } });
revalidatePath('/matches');
return { ok: true };
}
export async function deleteMatch(formData) {
const user = await getCurrentUser();
if (!user) return { error: 'Not authenticated' };
const id = Number(formData.get('id'));
const owned = await requireOwnedMatch(id, user);
if (!owned) return { error: 'Not allowed' }; // ownership enforced before delete
await db.match.delete({ where: { id } });
revalidatePath('/matches');
redirect('/matches');
}
// app/(app)/matches/new/MatchForm.js \u2014 forgiving form (pending + errors + values)
// 'use client'; uses useActionState(createMatch, ...) and a <Submit/> child with useFormStatus()Step 4 — Testing & Verification
Verify both the functionality and, crucially, the security, because the most important checks here are the ones that confirm a user cannot reach another user's data. Test the full CRUD flow as one user, then — the essential security test — attempt to edit or delete another user's match by its id and confirm the action refuses, and attempt to view the matches list as a second user and confirm it shows only their own records. Also verify the unauthenticated redirect, the form's validation and pending behaviour, and that input is preserved on error. The cross-user access tests are the ones that distinguish a genuinely secure app from one that merely works for a single well-behaved user.
# With the dev server running, run migrations then verify:
# npx prisma migrate dev
#
# As User A (logged in):
# 1. /matches \u2192 shows only A's matches.
# 2. Add a match \u2192 appears in the list; invalid input shows an error, keeps input.
# 3. Edit / delete A's own match \u2192 succeeds, list updates without reload.
#
# SECURITY TESTS (the important ones):
# 4. As User A, submit updateMatch/deleteMatch with User B's match id
# \u2192 MUST return 'Not allowed' and change nothing.
# 5. Log in as User B \u2192 /matches shows ONLY B's matches, never A's.
# 6. Visit /matches with no session cookie
# \u2192 redirected to /login.
# 7. Inspect: session cookie is httpOnly (not readable via document.cookie).Warning: The critical bug to test for is missing per-record authorization. An update or delete action that checks only that a user is logged in, without confirming the user owns the specific record being modified, lets any authenticated user edit or delete anyone's data by sending its id. Every mutation on a specific record must fetch it and verify its ownerId matches the current user before proceeding. Scoping the read query by ownerId is equally essential so the list never leaks other users' records.
Extension Challenge: Add roles so an admin user can view all matches while regular users see only their own, which forces you to express authorization as a function of both ownership and role. Then add an edit form using useActionState that pre-fills from the owned record and preserves input on validation error, wrap a multi-field update in a transaction, and add optimistic UI with useOptimistic so the list updates instantly before the server confirms. Together these exercise role-based authorization, forgiving forms, transactional writes, and optimistic updates on top of the secured CRUD core.
- Every record carries an owner relation, which is what makes ownership-based authorization possible across the CRUD surface.
- Reads are scoped by ownerId in the query itself, so the list fetches only the user's own records rather than over-fetching and filtering in the UI.
- Writes bind the record to the verified current user's id from the session, never to a client-supplied owner id that could be forged.
- Update and delete must fetch the target record and confirm its ownerId matches the current user before proceeding, or any authenticated user could modify anyone's data.
- Every action independently verifies authentication and authorization at the data layer, since actions are directly invokable and the UI is no protection.
- Forms use useActionState and useFormStatus for errors, preserved values, and pending state, and the session cookie stays httpOnly while secrets stay server-side.