745 lines
22 KiB
Markdown
745 lines
22 KiB
Markdown
# Les 6: Next.js — QuickPoll Compleet - Slide Overzicht
|
|
|
|
> Versie 3 — **Van scratch tot werkende app in één les.** Geen apart theorieblok.
|
|
>
|
|
> Stap 0-3 = recap (sneller, studenten hebben dit al gezien in Les 5).
|
|
> Stap 4-8 = nieuw materiaal.
|
|
> Theorie wordt uitgelegd **op het moment dat we het tegenkomen.**
|
|
|
|
---
|
|
|
|
## Slide 1: Titel + Plan
|
|
|
|
### Op de Slide
|
|
- Titel: **Les 6: QuickPoll — Van Scratch tot Werkend**
|
|
- Subtitel: "De hele app, stap voor stap"
|
|
- **Les 6 van 18**
|
|
- Next.js logo
|
|
|
|
- **Plan vandaag:**
|
|
- Stap 0-3: Project opzetten, types, layout, homepage, GET route *(recap)*
|
|
- Stap 4: POST vote route *(nieuw)*
|
|
- Stap 5: Poll detail pagina *(nieuw)*
|
|
- Stap 6: VoteForm component *(nieuw)*
|
|
- Stap 7: Loading, Error & Not-Found *(nieuw)*
|
|
- Stap 8: Middleware *(nieuw)*
|
|
|
|
### Docentnotities
|
|
"Welkom terug! Vorige les hebben jullie kennis gemaakt met Next.js. Vandaag bouwen we de hele QuickPoll app van scratch tot werkend. Ik code, jullie volgen mee."
|
|
|
|
"Stap 0-3 kennen jullie al — dat gaat lekker snel. Stap 4-8 is nieuw, daar nemen we de tijd voor. Laten we beginnen."
|
|
|
|
---
|
|
|
|
## Slide 2: Stap 0 — Project Aanmaken
|
|
|
|
### Op de Slide
|
|
- **Terminal:**
|
|
```bash
|
|
npx create-next-app@latest quickpoll
|
|
```
|
|
- **Opties:**
|
|
- TypeScript? → **Yes**
|
|
- ESLint? → **Yes**
|
|
- Tailwind CSS? → **Yes**
|
|
- `src/` directory? → **Yes**
|
|
- App Router? → **Yes**
|
|
- Import alias? → **@/***
|
|
|
|
```bash
|
|
cd quickpoll
|
|
npm run dev
|
|
```
|
|
|
|
- **Check:** `localhost:3000` → Next.js standaard pagina
|
|
|
|
### Docentnotities
|
|
*Tim opent terminal, typt het commando.*
|
|
|
|
"Stap 0 kennen jullie. `create-next-app` met TypeScript, Tailwind en App Router. Selecteer dezelfde opties als op het scherm."
|
|
|
|
*Tim wacht tot iedereen `npm run dev` draait.*
|
|
|
|
"Zie je de Next.js pagina? Dan gaan we door."
|
|
|
|
---
|
|
|
|
## Slide 3: Stap 1 — Types & Data
|
|
|
|
### Op de Slide
|
|
- **Maak `src/types/index.ts`:**
|
|
|
|
```tsx
|
|
export interface Poll {
|
|
id: string;
|
|
question: string;
|
|
options: string[];
|
|
votes: number[];
|
|
}
|
|
```
|
|
|
|
- **Maak `src/lib/data.ts`:**
|
|
|
|
```tsx
|
|
import type { Poll } from "@/types";
|
|
|
|
let polls: Poll[] = [
|
|
{
|
|
id: "1",
|
|
question: "Wat is je favoriete programmeer taal?",
|
|
options: ["JavaScript", "Python", "TypeScript", "Rust"],
|
|
votes: [45, 32, 28, 15],
|
|
},
|
|
{
|
|
id: "2",
|
|
question: "Hoe veel uur slaap krijg je per nacht?",
|
|
options: ["< 6 uur", "6-8 uur", "8+ uur"],
|
|
votes: [12, 68, 35],
|
|
},
|
|
{
|
|
id: "3",
|
|
question: "Welke framework gebruik je het meest?",
|
|
options: ["React", "Vue", "Svelte", "Angular"],
|
|
votes: [89, 34, 12, 8],
|
|
},
|
|
];
|
|
|
|
let nextId = 4;
|
|
|
|
export function getPolls(): Poll[] {
|
|
return polls;
|
|
}
|
|
|
|
export function getPollById(id: string): Poll | undefined {
|
|
return polls.find((poll) => poll.id === id);
|
|
}
|
|
|
|
export function votePoll(pollId: string, optionIndex: number): Poll | undefined {
|
|
const poll = polls.find((p) => p.id === pollId);
|
|
if (!poll || optionIndex < 0 || optionIndex >= poll.options.length) return undefined;
|
|
poll.votes[optionIndex]++;
|
|
return poll;
|
|
}
|
|
```
|
|
|
|
### Docentnotities
|
|
**💡 Theorie-moment: TypeScript interfaces in Next.js**
|
|
|
|
"Stap 1: de basis. Een `Poll` interface en in-memory data. Herkennen jullie dit van Les 4?"
|
|
|
|
*Tim typt de interface.*
|
|
|
|
"Een poll heeft een id, vraag, opties, en stemmen. Die `votes` array loopt parallel met `options` — index 0 van votes hoort bij index 0 van options."
|
|
|
|
*Tim typt data.ts.*
|
|
|
|
"Dit is onze 'database' — gewoon een array in het geheugen. `getPolls`, `getPollById`, `votePoll`. Straks bij de Supabase lessen vervangen we dit door een echte database."
|
|
|
|
*Checkpoint: "Heeft iedereen beide bestanden?"*
|
|
|
|
---
|
|
|
|
## Slide 4: Stap 2 — Layout & Homepage
|
|
|
|
### Op de Slide
|
|
- **Vervang `src/app/layout.tsx`:**
|
|
|
|
```tsx
|
|
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import "./globals.css";
|
|
|
|
export const metadata: Metadata = {
|
|
title: "QuickPoll",
|
|
description: "Een snelle polling app met Next.js",
|
|
};
|
|
|
|
export default function RootLayout({
|
|
children,
|
|
}: Readonly<{ children: React.ReactNode }>) {
|
|
return (
|
|
<html lang="nl">
|
|
<body className="bg-gray-50 min-h-screen flex flex-col">
|
|
<nav className="bg-white shadow-sm border-b border-gray-200">
|
|
<div className="container mx-auto px-4 py-4 flex items-center gap-8">
|
|
<Link href="/"
|
|
className="text-2xl font-bold text-purple-600 hover:text-purple-700 transition-colors">
|
|
QuickPoll
|
|
</Link>
|
|
<Link href="/"
|
|
className="text-gray-700 hover:text-purple-600 transition-colors font-medium">
|
|
Home
|
|
</Link>
|
|
</div>
|
|
</nav>
|
|
<main className="flex-1">{children}</main>
|
|
<footer className="bg-white border-t border-gray-200 mt-12">
|
|
<div className="container mx-auto px-4 py-6 text-center text-gray-600 text-sm">
|
|
© 2026 QuickPoll — Built with Next.js 15
|
|
</div>
|
|
</footer>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
- **`Link`** — client-side navigatie (geen page reload)
|
|
- **`metadata`** — SEO titel & beschrijving
|
|
- **`{children}`** — hier komt de pagina-inhoud
|
|
|
|
### Docentnotities
|
|
**💡 Theorie-moment: Layout & Link**
|
|
|
|
"Stap 2: de layout. Dit bestand wrapt ELKE pagina in je app. De navbar en footer staan hier — die veranderen nooit."
|
|
|
|
"`Link` is de Next.js versie van `<a>`. Het verschil: Link navigeert client-side — geen page reload, veel sneller."
|
|
|
|
"`metadata` is voor SEO. Kijk in je browser tab: 'QuickPoll'."
|
|
|
|
*Tim wacht even, dan door naar de homepage.*
|
|
|
|
---
|
|
|
|
## Slide 5: Stap 2 vervolg — Homepage
|
|
|
|
### Op de Slide
|
|
- **Vervang `src/app/page.tsx`:**
|
|
|
|
```tsx
|
|
import Link from "next/link";
|
|
import { getPolls } from "@/lib/data";
|
|
|
|
export default function Home() {
|
|
const polls = getPolls();
|
|
|
|
return (
|
|
<div className="container mx-auto py-12 px-4">
|
|
<div className="mb-12">
|
|
<h1 className="text-4xl font-bold text-gray-900">QuickPoll</h1>
|
|
<p className="text-gray-600 mt-2">Kies een poll en stem af in een oogwenk</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{polls.map((poll) => {
|
|
const totalVotes = poll.votes.reduce((sum, v) => sum + v, 0);
|
|
return (
|
|
<Link key={poll.id} href={`/poll/${poll.id}`}>
|
|
<div className="bg-white rounded-lg border border-gray-200 p-6
|
|
hover:shadow-lg transition-shadow cursor-pointer h-full">
|
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
|
{poll.question}
|
|
</h2>
|
|
<div className="flex items-center justify-between text-sm text-gray-600">
|
|
<span>{poll.options.length} opties</span>
|
|
<span>{totalVotes} stemmen</span>
|
|
</div>
|
|
<div className="mt-4 text-purple-600 font-semibold text-sm">
|
|
Stemmen →
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- **Dit is een Server Component** — geen `"use client"`, data direct ophalen
|
|
- **`.map()`** — render een card per poll
|
|
- **`.reduce()`** — tel totaal stemmen
|
|
- **Check:** `localhost:3000` → 3 poll cards
|
|
|
|
### Docentnotities
|
|
**💡 Theorie-moment: Server Components**
|
|
|
|
"Dit is een Server Component. Geen `'use client'` bovenaan — dus deze code draait op de server. Je kunt direct data ophalen, geen useEffect, geen loading state nodig."
|
|
|
|
"De homepage haalt alle polls op en rendert een card per poll. De `.map()` loop kennen jullie uit JavaScript — zelfde verhaal, maar dan in JSX."
|
|
|
|
*Checkpoint: "Zie je 3 cards op localhost:3000? Mooi."*
|
|
|
|
---
|
|
|
|
## Slide 6: Stap 3 — GET API Route
|
|
|
|
### Op de Slide
|
|
- **Maak `src/app/api/polls/[id]/route.ts`:**
|
|
|
|
```tsx
|
|
import { NextResponse } from "next/server";
|
|
import { getPollById } from "@/lib/data";
|
|
|
|
export async function GET(
|
|
request: Request,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const { id } = await params;
|
|
const poll = getPollById(id);
|
|
|
|
if (!poll) {
|
|
return NextResponse.json({ error: "Poll not found" }, { status: 404 });
|
|
}
|
|
|
|
return NextResponse.json(poll);
|
|
}
|
|
```
|
|
|
|
- **Folder = URL:** `api/polls/[id]/route.ts` → `/api/polls/1`
|
|
- **`[id]`** — dynamic route parameter
|
|
- **`await params`** — Next.js 15 pattern (params is een Promise)
|
|
- **`NextResponse.json()`** — stuur JSON response
|
|
- **Test:** `localhost:3000/api/polls/1` → JSON in browser
|
|
|
|
### Docentnotities
|
|
**💡 Theorie-moment: API Routes & Dynamic Routes**
|
|
|
|
"Stap 3: onze eerste API route. In Next.js is de folder-structuur je URL. `api/polls/[id]/route.ts` wordt `/api/polls/1`."
|
|
|
|
"Die `[id]` met vierkante haakjes is een dynamic route. Het getal in de URL wordt de `id` parameter."
|
|
|
|
"Let op: `await params` — in Next.js 15 zijn params een Promise. Vergeet de `await` niet, anders werkt het niet."
|
|
|
|
*Tim opent browser, gaat naar localhost:3000/api/polls/1.*
|
|
|
|
"JSON! Onze API werkt. Probeer `/api/polls/999` — daar krijg je een 404 error."
|
|
|
|
*Checkpoint: "Werkt je API? Dan zijn we klaar met de recap. Nu het nieuwe werk."*
|
|
|
|
---
|
|
|
|
## Slide 7: Stap 4 — POST /api/polls/[id]/vote
|
|
|
|
### Op de Slide
|
|
- **Maak `src/app/api/polls/[id]/vote/route.ts`:**
|
|
|
|
```tsx
|
|
import { NextResponse } from "next/server";
|
|
import { votePoll } from "@/lib/data";
|
|
|
|
interface RouteParams {
|
|
params: Promise<{ id: string }>;
|
|
}
|
|
|
|
interface VoteBody {
|
|
optionIndex: number;
|
|
}
|
|
|
|
export async function POST(
|
|
request: Request,
|
|
{ params }: RouteParams
|
|
): Promise<NextResponse> {
|
|
const { id } = await params;
|
|
const body: VoteBody = await request.json();
|
|
|
|
if (typeof body.optionIndex !== "number") {
|
|
return NextResponse.json(
|
|
{ error: "optionIndex is verplicht" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const updatedPoll = votePoll(id, body.optionIndex);
|
|
|
|
if (!updatedPoll) {
|
|
return NextResponse.json(
|
|
{ error: "Poll niet gevonden of ongeldige optie" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(updatedPoll);
|
|
}
|
|
```
|
|
|
|
- **Test met browser console:**
|
|
```javascript
|
|
fetch('/api/polls/1/vote', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ optionIndex: 0 })
|
|
}).then(r => r.json()).then(console.log)
|
|
```
|
|
|
|
### Docentnotities
|
|
**💡 Theorie-moment: POST vs GET**
|
|
|
|
"Nu begint het nieuwe werk. GET = data ophalen, POST = data wijzigen. Vijf stappen in elke POST route: params, body, validatie, actie, response."
|
|
|
|
*Tim typt langzaam, stopt na elke sectie.*
|
|
|
|
"`request.json()` leest de body — wat de client meestuurt. Hier de `optionIndex`: welke optie gestemd is."
|
|
|
|
"Twee error checks: 400 als de data ongeldig is, 404 als de poll niet bestaat. Altijd beide afvangen."
|
|
|
|
*Tim opent console, test de fetch.*
|
|
|
|
"Plak dit in je console. Zie je? Votes veranderd. Onze stem-API werkt."
|
|
|
|
*Checkpoint: "Heeft iedereen een JSON resultaat?"*
|
|
|
|
---
|
|
|
|
## Slide 8: Stap 5 — Poll Detail Pagina
|
|
|
|
### Op de Slide
|
|
- **Maak `src/app/poll/[id]/page.tsx`:**
|
|
|
|
```tsx
|
|
import { notFound } from "next/navigation";
|
|
import { getPollById } from "@/lib/data";
|
|
import VoteForm from "@/components/VoteForm";
|
|
import type { Metadata } from "next";
|
|
|
|
interface PageProps {
|
|
params: Promise<{ id: string }>;
|
|
}
|
|
|
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
const { id } = await params;
|
|
const poll = getPollById(id);
|
|
if (!poll) return { title: "Poll niet gevonden" };
|
|
return {
|
|
title: `${poll.question} — QuickPoll`,
|
|
description: `Stem op: ${poll.options.join(", ")}`,
|
|
};
|
|
}
|
|
|
|
export default async function PollPage({ params }: PageProps) {
|
|
const { id } = await params;
|
|
const poll = getPollById(id);
|
|
|
|
if (!poll) {
|
|
notFound();
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl mx-auto py-12 px-4">
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
|
{poll.question}
|
|
</h1>
|
|
<VoteForm poll={poll} />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- **`generateMetadata`** — dynamische titel per poll in browser tab
|
|
- **`notFound()`** — toont 404 als poll niet bestaat
|
|
- **Server Component** die Client Component (`VoteForm`) rendert
|
|
|
|
### Docentnotities
|
|
**💡 Theorie-moment: generateMetadata & notFound**
|
|
|
|
"`generateMetadata`: elke poll krijgt z'n eigen titel. Open straks `/poll/1` en kijk naar je browser tab."
|
|
|
|
"`notFound()`: als de poll niet bestaat, roep je dit aan. Next.js toont dan automatisch een 404 pagina. Geen if/else redirect nodig."
|
|
|
|
"En kijk: Server Component rendert een Client Component. Server haalt data, client doet interactie. Dat is de kern van Next.js."
|
|
|
|
*Checkpoint: "/poll/1 geeft een error — VoteForm bestaat nog niet. Dat klopt."*
|
|
|
|
---
|
|
|
|
## Slide 9: Pauze ☕
|
|
|
|
### Op de Slide
|
|
- **PAUZE — 15 minuten**
|
|
- Check: stap 0-5 af? Alles draait?
|
|
- Na de pauze: VoteForm (het leukste stuk)
|
|
|
|
### Docentnotities
|
|
"Pauze! We zijn halverwege. Stap 0-5 staan er. Na de pauze bouwen we de VoteForm — dat is het interactieve hart van de app."
|
|
|
|
*Tim loopt rond, helpt waar nodig.*
|
|
|
|
---
|
|
|
|
## Slide 10: Stap 6 — VoteForm (Deel 1: Logica)
|
|
|
|
### Op de Slide
|
|
- **Maak `src/components/VoteForm.tsx`:**
|
|
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import type { Poll } from "@/types";
|
|
|
|
interface VoteFormProps {
|
|
poll: Poll;
|
|
}
|
|
|
|
export default function VoteForm({ poll }: VoteFormProps) {
|
|
const [selectedOption, setSelectedOption] = useState<number | null>(null);
|
|
const [hasVoted, setHasVoted] = useState<boolean>(false);
|
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
|
const [currentPoll, setCurrentPoll] = useState<Poll>(poll);
|
|
|
|
const totalVotes: number = currentPoll.votes.reduce(
|
|
(sum, v) => sum + v, 0
|
|
);
|
|
|
|
function getPercentage(votes: number): number {
|
|
if (totalVotes === 0) return 0;
|
|
return Math.round((votes / totalVotes) * 100);
|
|
}
|
|
|
|
async function handleVote(): Promise<void> {
|
|
if (selectedOption === null || isSubmitting) return;
|
|
setIsSubmitting(true);
|
|
|
|
const response = await fetch(`/api/polls/${currentPoll.id}/vote`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ optionIndex: selectedOption }),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const updatedPoll: Poll = await response.json();
|
|
setCurrentPoll(updatedPoll);
|
|
setHasVoted(true);
|
|
}
|
|
|
|
setIsSubmitting(false);
|
|
}
|
|
|
|
// UI op volgende slide...
|
|
```
|
|
|
|
- **`"use client"`** — dit component draait in de browser
|
|
- **4 state variables** met TypeScript types
|
|
- **`handleVote()`** — POST naar de API route uit stap 4
|
|
|
|
### Docentnotities
|
|
**💡 Theorie-moment: Client Components & useState**
|
|
|
|
"Dit is het hart van de app. `'use client'` bovenaan — alles met interactiviteit moet een Client Component zijn."
|
|
|
|
"Vier states: welke optie geselecteerd (`number | null`), al gestemd, bezig met submitten, en de huidige poll data."
|
|
|
|
"`handleVote` is dezelfde fetch als we in stap 4 in de console testten. Nu zit het in een component."
|
|
|
|
*Checkpoint: "Heeft iedereen de vier useState regels en de handleVote functie?"*
|
|
|
|
---
|
|
|
|
## Slide 11: Stap 6 — VoteForm (Deel 2: UI)
|
|
|
|
### Op de Slide
|
|
```tsx
|
|
return (
|
|
<div className="space-y-3">
|
|
{currentPoll.options.map((option, index) => {
|
|
const percentage = getPercentage(currentPoll.votes[index]);
|
|
const isSelected = selectedOption === index;
|
|
|
|
return (
|
|
<button
|
|
key={index}
|
|
onClick={() => !hasVoted && setSelectedOption(index)}
|
|
disabled={hasVoted}
|
|
className={`w-full text-left p-4 rounded-lg border-2 transition-all
|
|
relative overflow-hidden ${
|
|
hasVoted
|
|
? "border-gray-200 cursor-default"
|
|
: isSelected
|
|
? "border-purple-500 bg-purple-50"
|
|
: "border-gray-200 hover:border-purple-300 cursor-pointer"
|
|
}`}
|
|
>
|
|
{hasVoted && (
|
|
<div
|
|
className="absolute inset-0 bg-purple-100 transition-all duration-500"
|
|
style={{ width: `${percentage}%` }}
|
|
/>
|
|
)}
|
|
<div className="relative flex justify-between items-center">
|
|
<span className="font-medium">{option}</span>
|
|
{hasVoted && (
|
|
<span className="text-sm font-semibold text-purple-700">
|
|
{percentage}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
{!hasVoted && (
|
|
<button
|
|
onClick={handleVote}
|
|
disabled={selectedOption === null || isSubmitting}
|
|
className="w-full bg-purple-600 text-white py-3 rounded-lg font-medium
|
|
hover:bg-purple-700 disabled:bg-gray-300 transition-colors mt-4"
|
|
>
|
|
{isSubmitting ? "Bezig met stemmen..." : "Stem!"}
|
|
</button>
|
|
)}
|
|
|
|
{hasVoted && (
|
|
<p className="text-center text-green-600 font-medium mt-4">
|
|
Bedankt voor je stem! Totaal: {totalVotes} stemmen
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- **Twee UI states:** vóór stemmen (selecteer) en na stemmen (percentage bars)
|
|
- **Percentage bar:** `absolute inset-0` + `width: percentage%` + `transition-all`
|
|
- **Conditional classes:** ternary in className
|
|
- **Test:** `localhost:3000/poll/1` → stem en zie de animatie!
|
|
|
|
### Docentnotities
|
|
"De UI heeft twee toestanden. Vóór stemmen: klik een optie, paarse border. Na stemmen: geanimeerde percentage bars."
|
|
|
|
"De percentage bar: een div die de achtergrond vult. De breedte is het percentage. `transition-all duration-500` animeert het. Pure CSS via Tailwind."
|
|
|
|
*Tim opent /poll/1, klikt een optie, stemt.*
|
|
|
|
"Werkt het? Klik op een optie, klik Stem. Zie je de animatie?"
|
|
|
|
*Tim loopt rond. Dit is het lastigste stuk — neem 10-15 min extra als nodig.*
|
|
|
|
---
|
|
|
|
## Slide 12: Stap 7 — Loading, Error & Not-Found
|
|
|
|
### Op de Slide
|
|
**`src/app/loading.tsx`:**
|
|
```tsx
|
|
export default function Loading() {
|
|
return (
|
|
<div className="container mx-auto py-12 px-4 space-y-4">
|
|
<div className="animate-pulse">
|
|
<div className="h-8 bg-gray-200 rounded w-1/3 mb-2" />
|
|
<div className="h-4 bg-gray-200 rounded w-1/2 mb-8" />
|
|
</div>
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="animate-pulse bg-white rounded-xl border border-gray-200 p-6">
|
|
<div className="h-5 bg-gray-200 rounded w-3/4 mb-3" />
|
|
<div className="h-4 bg-gray-200 rounded w-1/4" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**`src/app/error.tsx`** *(let op: `"use client"` verplicht!)*:
|
|
```tsx
|
|
"use client";
|
|
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
|
|
return (
|
|
<div className="text-center py-16">
|
|
<h2 className="text-2xl font-bold text-red-600 mb-4">Er ging iets mis!</h2>
|
|
<p className="text-gray-600 mb-6">{error.message}</p>
|
|
<button onClick={() => reset()}
|
|
className="bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700">
|
|
Probeer opnieuw
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**`src/app/not-found.tsx`:**
|
|
```tsx
|
|
import Link from "next/link";
|
|
export default function NotFound() {
|
|
return (
|
|
<div className="text-center py-16">
|
|
<h2 className="text-4xl font-bold text-gray-900 mb-4">404</h2>
|
|
<p className="text-gray-600 mb-6">Deze pagina bestaat niet.</p>
|
|
<Link href="/" className="bg-purple-600 text-white px-6 py-3 rounded-lg
|
|
hover:bg-purple-700 inline-block">Terug naar home</Link>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- **Test:** `/poll/999` → not-found pagina
|
|
|
|
### Docentnotities
|
|
**💡 Theorie-moment: Speciale bestanden**
|
|
|
|
"In gewoon React bouw je dit zelf. In Next.js maak je een bestand aan en het werkt."
|
|
|
|
"Drie bestanden: `loading.tsx` met skeleton UI (`animate-pulse`), `error.tsx` als error boundary (**altijd `'use client'`!**), en `not-found.tsx` voor 404's."
|
|
|
|
"Test: ga naar `/poll/999`. Daar is je 404. Dat werkt door `notFound()` in de poll pagina."
|
|
|
|
---
|
|
|
|
## Slide 13: Stap 8 — Middleware
|
|
|
|
### Op de Slide
|
|
- **Maak `src/middleware.ts`** *(in src/ root, NIET in een folder!)*:
|
|
|
|
```tsx
|
|
import { NextResponse } from "next/server";
|
|
import type { NextRequest } from "next/server";
|
|
|
|
export function middleware(request: NextRequest): NextResponse {
|
|
const start = Date.now();
|
|
console.log(`[${request.method}] ${request.nextUrl.pathname}`);
|
|
|
|
const response = NextResponse.next();
|
|
response.headers.set("x-request-time", String(Date.now() - start));
|
|
return response;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ["/api/:path*", "/poll/:path*"],
|
|
};
|
|
```
|
|
|
|
- **Locatie:** altijd `src/middleware.ts` — nergens anders
|
|
- **`matcher`** — welke routes de middleware triggert
|
|
- **Test:** terminal logs + DevTools Headers → `x-request-time`
|
|
|
|
### Docentnotities
|
|
**💡 Theorie-moment: Middleware**
|
|
|
|
"Middleware is een portier — elke request passeert hier eerst. Nu loggen we alleen, maar bij Supabase wordt dit authenticatie: is de user ingelogd?"
|
|
|
|
"Let op de locatie: `src/middleware.ts`. Niet in `app/`, niet in een subfolder. Dit is het enige bestand in Next.js waar de locatie echt uitmaakt."
|
|
|
|
"Open je terminal. Klik op een poll. Zie je `[GET] /poll/1`? Dat is middleware."
|
|
|
|
---
|
|
|
|
## Slide 14: Huiswerk & Afsluiting
|
|
|
|
### Op de Slide
|
|
- **Wat we gebouwd hebben:** De hele QuickPoll app van scratch
|
|
- Types & in-memory data
|
|
- Layout, homepage, API routes (GET + POST)
|
|
- Poll detail pagina met dynamic metadata
|
|
- VoteForm met interactieve UI
|
|
- Loading, Error, Not-Found states
|
|
- Middleware
|
|
|
|
- **De Next.js flow:**
|
|
Route (folder) → Page (server) → Client Component (interactie) → API Route (data) → Response
|
|
|
|
- **Huiswerk:**
|
|
- App niet af? → Afmaken
|
|
- App af? → Bonus: "Nieuwe Poll Aanmaken" pagina (`/create`)
|
|
- Zet je code op GitHub
|
|
|
|
- **Volgende les:** Tailwind CSS & shadcn/ui
|
|
|
|
### Docentnotities
|
|
"In één les van nul naar werkende app. Dat is Next.js. Routing, server components, client components, API routes, middleware — jullie snappen de kern."
|
|
|
|
"Bonus: maak een `/create` pagina met een form en POST naar `/api/polls`. Goed oefenmateriaal."
|
|
|
|
"Volgende les: styling met Tailwind en shadcn/ui."
|
|
|
|
"Goed gedaan!"
|