Heads‑up! A new Next.js course is coming
I’m rolling out a practical, end‑to‑end Next.js course focused on building a real PSL (soccer) admin dashboard. It’s packed with real-world patterns: data modeling, secure admin tooling, charts, tables, role-based access, server actions, and smooth deployment.
Stay tuned — kick‑off is soon. Here’s what to expect and how to prep.
Who this is for
- React/Next.js devs who want to master the App Router and Server Components
- Backend‑curious frontend engineers who want production data flows
- Builders who prefer shipping a real product vs toy demos
What we’re building
A PSL operations dashboard with:
- Authentication + role‑based access (Admin, Analyst, Editor)
- CRUD for teams, players, fixtures, and venues
- Matchday operations view (fixtures, live status, assignments)
- League table/standings and performance insights
- Data tables with filtering, sorting, pagination, CSV export
- Charts for KPIs (goals, xG snapshots, form over time)
- Real-time updates for scores using a pub/sub provider
- Audit logs and activity feed
Tech stack
- Next.js 14 (App Router, Server Components, Route Handlers)
- TypeScript, ESLint, Prettier
- Tailwind CSS + shadcn/ui
- TanStack Table for data grids
- Prisma ORM + PostgreSQL
- NextAuth (Auth.js) with Credentials/OAuth (provider optional)
- Charting via Recharts or Chart.js
- Realtime via Pusher/Supabase (pluggable)
- Deployed on Vercel; CI checks via GitHub Actions
Data model (initial sketch)
We’ll iterate during the course, but here’s the starting Prisma schema:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
ANALYST
EDITOR
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(EDITOR)
createdAt DateTime @default(now())
sessions Session[]
}
model Session {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
expires DateTime
}
model Team {
id String @id @default(cuid())
name String @unique
code String @unique
logoUrl String?
players Player[]
createdAt DateTime @default(now())
}
model Player {
id String @id @default(cuid())
firstName String
lastName String
position String
teamId String
team Team @relation(fields: [teamId], references: [id])
}
model Fixture {
id String @id @default(cuid())
homeTeamId String
awayTeamId String
homeTeam Team @relation("homeTeam", fields: [homeTeamId], references: [id])
awayTeam Team @relation("awayTeam", fields: [awayTeamId], references: [id])
venue String
kickoff DateTime
status String @default("SCHEDULED") // SCHEDULED | LIVE | FT
homeScore Int @default(0)
awayScore Int @default(0)
updatedAt DateTime @updatedAt
}
Route plan (App Router)
- /(login) — Auth gate
- /dashboard — KPIs, recent fixtures, quick actions
- /teams — CRUD + roster view
- /players — Directory + filters
- /fixtures — Scheduler, live ops
- /standings — Computed table
- /settings — Roles, audit log, API keys
Folder sketch:
app/
(auth)/login/page.tsx
dashboard/page.tsx
teams/
page.tsx
new/page.tsx
[id]/page.tsx
actions.ts // server actions
fixtures/
page.tsx
actions.ts
api/
auth/[...nextauth]/route.ts
webhooks/realtime/route.ts
middleware.ts
Pre‑course setup checklist
- Node.js 18+ (recommend 20 LTS)
- pnpm or npm (I’ll use pnpm)
- Docker Desktop (for local Postgres)
- Git + GitHub account
Start Postgres locally:
docker run --name psl-db \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_DB=psl \
-p 5432:5432 -d postgres:16
Environment file:
cp .env.example .env
.env contents (initial):
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/psl?schema=public"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="change-me"
PUSHER_KEY=""
PUSHER_SECRET=""
Install and generate:
pnpm i
pnpm prisma migrate dev --name init
pnpm prisma db seed
Sample seed (excerpt):
// prisma/seed.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
const teams = await prisma.team.createMany({
data: [
{ name: "Cape Town City", code: "CTC" },
{ name: "Orlando Pirates", code: "ORL" },
{ name: "Mamelodi Sundowns", code: "SUN" },
],
skipDuplicates: true,
});
console.log("Seeded teams:", teams.count);
}
main().finally(() => prisma.$disconnect());
Server Action: create a fixture
// app/fixtures/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { z } from "zod";
const CreateFixture = z.object({
homeTeamId: z.string().cuid(),
awayTeamId: z.string().cuid().refine((v, ctx) => v !== ctx.parent?.homeTeamId, {
message: "Home and away cannot be the same",
}),
venue: z.string().min(2),
kickoff: z.string().datetime(),
});
export async function createFixture(input: unknown) {
const data = CreateFixture.parse(input);
await prisma.fixture.create({ data: {
homeTeamId: data.homeTeamId,
awayTeamId: data.awayTeamId,
venue: data.venue,
kickoff: new Date(data.kickoff),
}});
revalidatePath("/fixtures");
}
Usage in a form component:
// app/fixtures/new/page.tsx
import { createFixture } from "../actions";
export default function NewFixturePage() {
async function action(formData: FormData) {
"use server";
await createFixture({
homeTeamId: formData.get("homeTeamId"),
awayTeamId: formData.get("awayTeamId"),
venue: formData.get("venue"),
kickoff: formData.get("kickoff"),
});
}
return (
<form action={action} className="space-y-4">
{/* select inputs populated from Teams */}
<button type="submit" className="btn btn-primary">Create</button>
</form>
);
}
Role‑based access in middleware
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
export async function middleware(req: NextRequest) {
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
const path = req.nextUrl.pathname;
if (!token && path !== "/login") {
return NextResponse.redirect(new URL("/login", req.url));
}
if (path.startsWith("/settings") || path.startsWith("/fixtures")) {
if (token?.role !== "ADMIN") {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard", "/teams/:path*", "/players/:path*", "/fixtures/:path*", "/settings/:path*"],
};
Data table pattern with TanStack Table + shadcn/ui
// app/teams/page.tsx
import { DataTable } from "@/components/data-table";
import { columns } from "./columns";
import { prisma } from "@/lib/db";
export default async function TeamsPage() {
const teams = await prisma.team.findMany({ orderBy: { name: "asc" } });
return (
<div className="p-6">
<h1 className="text-2xl font-semibold mb-4">Teams</h1>
<DataTable columns={columns} data={teams} />
</div>
);
}
Realtime scoring (concept)
- Update fixture scores via a protected server action
- Publish events to Pusher/Supabase in the action
- Subscribe on the dashboard to auto‑update cards and tables
We’ll implement a provider‑agnostic wrapper so you can swap services easily.
Testing & quality
- Vitest for units, Playwright for E2E
- ESLint + Prettier + TypeScript strict
- Optional Husky + lint‑staged for pre‑commit checks
Deployment
- Database: managed Postgres (Neon or Supabase)
- Migrations via Prisma in CI
- Vercel envs for Preview/Production
- Edge caching for public stats; revalidate on writes
Course outline (high level)
- Project setup, UI kit, linting, git hygiene
- App Router deep dive: layouts, RSC, streaming
- Auth + RBAC with NextAuth and middleware
- Data modeling with Prisma, seeding strategies
- Data tables: filters, pagination, CSV export
- Server Actions: forms, errors, optimistic UI
- Charts + KPIs + derived metrics (standings)
- Realtime updates for live fixtures
- Testing: units, integration, E2E
- Deployment, observability, and hardening
What you can do now
- Set up Node, Docker, and Postgres
- Clone the starter (link drops on day 1) and run migrations
- Join the Discord to get notified the moment it drops
- Prepare a test dataset (CSV of teams/players) if you have one
Date: TBA. Stay tuned — announcement soon on Discord and Twitter.