2026-04-19

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)

  1. Project setup, UI kit, linting, git hygiene
  2. App Router deep dive: layouts, RSC, streaming
  3. Auth + RBAC with NextAuth and middleware
  4. Data modeling with Prisma, seeding strategies
  5. Data tables: filters, pagination, CSV export
  6. Server Actions: forms, errors, optimistic UI
  7. Charts + KPIs + derived metrics (standings)
  8. Realtime updates for live fixtures
  9. Testing: units, integration, E2E
  10. 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.