This commit is contained in:
2026-03-25 08:58:03 +01:00
parent 8df4087cfd
commit a0c7413c14
12 changed files with 3216 additions and 0 deletions

View File

@@ -0,0 +1,946 @@
# Les 7 — Live Coding Guide
## Van In-Memory naar Supabase
> **Jouw spiekbriefje.** Dit bestand staat op je privéscherm. Op de beamer draait Cursor.
> Volg stap voor stap. Typ exact wat hier staat. Leg uit met de "Vertel:" blokken.
---
## Planning
| Blok | Tijd | Onderwerp |
|------|------|-----------|
| Deel 1 | 09:00 09:30 | Poll-app afmaken (stemmen werkend) |
| Deel 2 | 09:30 10:15 | Supabase introductie — No Code |
| Pauze | 10:15 10:30 | — |
| Deel 3 | 10:30 11:30 | Supabase koppelen aan Next.js |
| Afsluiting | 11:30 12:00 | Testen, Q&A, huiswerk |
---
# DEEL 1: Poll-app afmaken (09:00 09:30)
> **Vertel:** "Vorige les hebben we de QuickPoll app gebouwd, maar het stemmen werkt nog niet echt. De POST route logt alleen 'hello from server'. Vandaag maken we dat eerst werkend, en daarna koppelen we alles aan een echte database: Supabase."
---
## Stap 1.1 — `votePoll()` functie toevoegen aan `data.ts`
> **Vertel:** "We hebben `getPolls()` en `getPollById()` maar nog geen functie om een stem te verwerken. Die voegen we nu toe."
Open `lib/data.ts` en voeg onderaan toe:
```typescript
export function votePoll(id: string, optionIndex: number): Poll | undefined {
const poll = polls.find((p) => p.id === id);
if (!poll) return undefined;
if (optionIndex < 0 || optionIndex >= poll.options.length) return undefined;
poll.votes[optionIndex]++;
return poll;
}
```
> **Vertel:** "Simpel: we zoeken de poll, checken of de index geldig is, en verhogen de votes. We returnen de poll zodat de API de updated data kan terugsturen."
---
## Stap 1.2 — POST route werkend maken
> **Vertel:** "Nu gaan we de POST route fixen. Die deed nog niks — alleen console.log. We moeten de body uitlezen, votePoll aanroepen, en een response terugsturen."
Open `app/api/polls/[id]/route.ts` en vervang de hele inhoud:
```typescript
import { NextResponse } from "next/server";
import { votePoll } from "@/lib/data";
interface RouteParams {
params: Promise<{ id: string }>;
}
export async function POST(request: Request, { params }: RouteParams) {
const { id } = await params;
const body = await request.json();
const optionIndex = body.optionIndex;
const updatedPoll = votePoll(id, optionIndex);
if (!updatedPoll) {
return NextResponse.json(
{ error: "Poll niet gevonden of ongeldige optie" },
{ status: 400 }
);
}
return NextResponse.json(updatedPoll);
}
```
> **Vertel:** "We lezen de body uit met `request.json()`, halen de `optionIndex` eruit, en roepen onze nieuwe `votePoll` functie aan. Als het mislukt sturen we een 400 error terug, anders de updated poll."
---
## Stap 1.3 — Server Component + VoteForm split
> **Vertel:** "Nu gaan we de detail pagina goed opzetten. Op dit moment is het hele bestand een Client Component met `'use client'`. Maar dat is niet hoe Next.js bedoeld is. De pagina zelf moet een Server Component zijn — die haalt de data op. En alleen het stukje dat interactief is (stemmen), dat wordt een apart Client Component. Dit is het belangrijkste patroon in Next.js."
### Stap 1.3a — VoteForm component aanmaken
Maak een nieuw bestand `components/VoteForm.tsx`:
```typescript
'use client'
import { Poll } from "@/types";
import { PollItem } from "./PollItem";
import { useState } from "react";
export function VoteForm({ poll: initialPoll }: { poll: Poll }) {
const [poll, setPoll] = useState(initialPoll);
const onVote = async (option: string) => {
// Zoek de INDEX van de option in de array
const optionIndex = poll.options.indexOf(option);
const response = await fetch(`/api/polls/${poll.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ optionIndex }),
});
if (response.ok) {
const updatedPoll = await response.json();
setPoll(updatedPoll);
}
};
return <PollItem poll={poll} onOptionClick={onVote} />;
}
```
> **Vertel:** "Dit is het Client Component — het enige stuk dat `'use client'` nodig heeft. Het ontvangt de poll als prop, slaat die op in state, en handelt het stemmen af. Na het stemmen updaten we de state met de response van de API."
### Stap 1.3b — Page als Server Component
Open `app/poll/[id]/page.tsx` en vervang de **hele** inhoud:
```typescript
import { getPollById } from "@/lib/data";
import { VoteForm } from "@/components/VoteForm";
import { notFound } from "next/navigation";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function PollPage({ params }: PageProps) {
const { id } = await params;
const poll = getPollById(id);
if (!poll) notFound();
return (
<div className="w-full p-4">
<h2 className="text-2xl font-bold mb-4">{poll.question}</h2>
<VoteForm poll={poll} />
</div>
);
}
```
> **Vertel:** "Kijk wat hier gebeurt: de pagina is een Server Component. Geen `'use client'`, geen `useState`, geen `useEffect`. De data wordt direct opgehaald met `getPollById()` — op de server. Dan geven we de poll als prop door aan de VoteForm. Dat is het patroon: **Server Component haalt data, Client Component doet interactie.**"
>
> **Vertel:** "En we gebruiken `notFound()` — als de poll niet bestaat, toont Next.js automatisch een 404 pagina. Dat hadden we vorige les geleerd."
---
## Stap 1.4 — GET route toevoegen
> **Vertel:** "We hebben een POST route, maar nog geen GET. Die hebben we nodig voor de VoteForm — na het stemmen fetcht die de updated poll via de API."
Open `app/api/polls/[id]/route.ts` en vervang met de complete versie met GET + POST:
```typescript
import { NextResponse } from "next/server";
import { getPollById, votePoll } from "@/lib/data";
interface RouteParams {
params: Promise<{ id: string }>;
}
export async function GET(request: Request, { params }: RouteParams) {
const { id } = await params;
const poll = getPollById(id);
if (!poll) {
return NextResponse.json({ error: "Poll niet gevonden" }, { status: 404 });
}
return NextResponse.json(poll);
}
export async function POST(request: Request, { params }: RouteParams) {
const { id } = await params;
const body = await request.json();
const optionIndex = body.optionIndex;
const updatedPoll = votePoll(id, optionIndex);
if (!updatedPoll) {
return NextResponse.json(
{ error: "Poll niet gevonden of ongeldige optie" },
{ status: 400 }
);
}
return NextResponse.json(updatedPoll);
}
```
---
## Stap 1.5 — Visuele feedback toevoegen aan PollItem
> **Vertel:** "Laten we het iets mooier maken. We tonen nu het aantal votes en een simpele progress bar per optie."
Open `components/PollItem.tsx` en vervang de inhoud:
```typescript
'use client'
import { Poll } from "@/types"
type PollItemProps = {
poll: Poll,
onOptionClick?: (option: string) => void
}
type PollItemOptionProps = {
option: string
votes: number
percentage: number
onClick?: (option: string) => void
}
export const PollItemOption = ({ option, votes, percentage, onClick }: PollItemOptionProps) => {
return (
<div
onClick={() => onClick?.(option)}
className="relative my-2 p-3 border rounded cursor-pointer hover:bg-gray-50 overflow-hidden"
>
{/* Achtergrond bar */}
<div
className="absolute top-0 left-0 h-full bg-blue-100 transition-all duration-300"
style={{ width: `${percentage}%` }}
/>
{/* Tekst bovenop de bar */}
<div className="relative flex justify-between">
<span>{option}</span>
<span className="text-gray-500">{votes} ({percentage}%)</span>
</div>
</div>
)
}
export const PollItem = ({ poll, onOptionClick }: PollItemProps) => {
const totalVotes = poll.votes.reduce((sum, v) => sum + v, 0);
return (
<section className="w-full my-6">
<h2 className="text-xl font-bold mb-2">{poll.question}</h2>
<p className="text-sm text-gray-500 mb-3">{totalVotes} stemmen</p>
{poll.options.map((option, index) => {
const votes = poll.votes[index];
const percentage = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0;
return (
<PollItemOption
key={option}
option={option}
votes={votes}
percentage={percentage}
onClick={onOptionClick}
/>
)
})}
</section>
)
}
```
> **Vertel:** "Nu tonen we bij elke optie het aantal stemmen, een percentage, en een visuele balk. De balk groeit mee met het percentage. We gebruiken `transition-all` zodat het soepel animeert."
---
## ✅ Check: Deel 1
> **Check:** Start de dev server (`npm run dev`), ga naar `http://localhost:3000`, klik op een poll, stem op een optie. Je ziet nu:
> - De stem wordt verwerkt
> - De votes updaten direct in de UI
> - De percentage bars verschijnen
>
> **Vertel:** "Dit werkt nu, maar er is een probleem: als je de pagina refresht, zijn je stemmen nog steeds bewaard — maar als je de server herstart, is alles weg. Dat is omdat onze data in het geheugen staat. Vandaag gaan we dat oplossen met een echte database."
---
# DEEL 2: Introductie Supabase — No Code (09:30 10:15)
> **Vertel:** "Nu gaan we het over een echte database hebben. We gaan Supabase gebruiken. Eerst zonder code — puur via de website."
---
## Stap 2.1 — Wat is Supabase?
> **Vertel:** "Supabase is een open-source alternatief voor Firebase. Het geeft je een hele backend out of the box:"
>
> - **PostgreSQL database** — een echte, professionele SQL database
> - **Authenticatie** — login, registratie, OAuth (Google, GitHub, etc.)
> - **Storage** — bestanden uploaden (afbeeldingen, PDF's, etc.)
> - **Realtime** — live updates als data verandert
> - **Edge Functions** — serverless functies
>
> **Vertel:** "Het grote verschil met Firebase: Supabase gebruikt PostgreSQL. Dat is een open-source database die al 30+ jaar bestaat. Als je Supabase leert, leer je ook SQL — en dat is een skill die je overal kunt gebruiken."
>
> **Vertel:** "En het mooie: er is een gratis tier waarmee je prima kunt werken voor kleine projecten en leren."
---
## Stap 2.2 — Supabase project aanmaken
> **Vertel:** "We gaan nu samen een project aanmaken. Open je browser en ga naar supabase.com."
### Stappen op het scherm:
1. Ga naar **supabase.com** → klik **Start your project** (of **Sign In** als je al een account hebt)
2. Log in met **GitHub** (makkelijkst)
3. Klik **New Project**
4. Vul in:
- **Organization**: kies je org (of maak er een aan)
- **Project name**: `quickpoll`
- **Database Password**: genereer een sterk wachtwoord (sla het op!)
- **Region**: `West EU (Frankfurt)` — dichtste bij Nederland
5. Klik **Create new project**
6. Wacht ~30 seconden tot het project klaar is
> **Vertel:** "Je ziet nu het dashboard. Hier vind je alles: je database, je API keys, de Table Editor, SQL Editor. Laten we beginnen met tabellen maken."
---
## Stap 2.3 — `polls` tabel aanmaken via Table Editor
> **Vertel:** "We gaan nu onze database structuur opzetten. In onze code hadden we één array met Poll objecten. In een database splitsen we dat op in twee tabellen: polls en options. Dat heet normalisatie — elke tabel heeft zijn eigen verantwoordelijkheid."
### Stappen op het scherm:
1. Klik **Table Editor** in het linkermenu
2. Klik **Create a new table**
3. Vul in:
- **Name**: `polls`
- **Enable Row Level Security (RLS)**: laat aan staan (maar we gaan er zo een policy voor maken)
4. Kolommen:
- `id` → staat er al (uuid, primary key) ✅
- `created_at` → staat er al (timestamptz, default `now()`) ✅
- Klik **Add column**:
- **Name**: `question`
- **Type**: `text`
- Vink **Is Nullable** UIT (niet null)
5. Klik **Save**
> **Vertel:** "We hebben nu een polls tabel met drie kolommen: een automatisch gegenereerd id (uuid — dat is een uniek ID), een question (tekst), en created_at (wanneer de poll is aangemaakt). Supabase maakt id en created_at automatisch voor je."
---
## Stap 2.4 — `options` tabel aanmaken
> **Vertel:** "Nu de options tabel. Elke poll heeft meerdere opties. In plaats van een array in één kolom, maken we een aparte tabel met een verwijzing (foreign key) naar de poll."
### Stappen op het scherm:
1. Klik **New Table**
2. Vul in:
- **Name**: `options`
- **Enable RLS**: aan
3. Kolommen (naast id en created_at):
- Klik **Add column**:
- **Name**: `poll_id`
- **Type**: `uuid`
- **Is Nullable**: UIT
- Klik **Add column**:
- **Name**: `text`
- **Type**: `text`
- **Is Nullable**: UIT
- Klik **Add column**:
- **Name**: `votes`
- **Type**: `int8`
- **Default value**: `0`
4. Nu de foreign key: klik op het **link-icoontje** naast `poll_id`
- **Foreign table**: `polls`
- **Foreign column**: `id`
- **On delete**: `CASCADE` (als een poll wordt verwijderd, verdwijnen de opties ook)
5. Klik **Save**
> **Vertel:** "CASCADE betekent: als je een poll verwijdert, worden automatisch alle opties van die poll ook verwijderd. Dat voorkomt 'wees-data' — opties die naar een poll verwijzen die niet meer bestaat."
---
## Stap 2.5 — RLS policies instellen
> **Vertel:** "Supabase heeft Row Level Security — dat betekent dat standaard NIEMAND je data kan lezen of schrijven via de API. We moeten expliciet toestemming geven. Voor onze app willen we dat iedereen polls kan lezen en stemmen."
### Stappen op het scherm:
1. Ga naar **Authentication****Policies** (of via Table Editor → polls → **RLS Policies**)
2. Bij de `polls` tabel, klik **New Policy**
- **Policy name**: `Allow public read`
- **Allowed operation**: `SELECT`
- **Target roles**: `anon`
- **USING expression**: `true`
- Klik **Save**
3. Bij de `options` tabel, maak twee policies:
- **Policy 1 — Lezen**:
- **Name**: `Allow public read`
- **Operation**: `SELECT`
- **Target roles**: `anon`
- **USING**: `true`
- **Policy 2 — Updaten (stemmen)**:
- **Name**: `Allow public vote`
- **Operation**: `UPDATE`
- **Target roles**: `anon`
- **USING**: `true`
- **WITH CHECK**: `true`
> **Vertel:** "We geven de `anon` rol (dat zijn niet-ingelogde gebruikers) toestemming om polls en opties te lezen, en om opties te updaten (voor het stemmen). In een productie-app zou je dit veel strakker instellen, maar voor ons leerproject is dit prima."
---
## Stap 2.6 — Testdata toevoegen
> **Vertel:** "Laten we onze twee polls toevoegen, dezelfde als in onze code."
### In de Table Editor → `polls`:
1. Klik **Insert row**
2. Vul in: `question`: `Ik ben een vraag` (id en created_at worden automatisch ingevuld)
3. Klik **Save**
4. Voeg nog een rij toe: `question`: `Ik ben een vraag 2`
5. **Kopieer de id's** van beide polls — die heb je nodig voor de options!
### In de Table Editor → `options`:
1. Voeg rijen toe voor poll 1 (gebruik het id van de eerste poll als `poll_id`):
- `poll_id`: *[id van poll 1]*, `text`: `optie 1`, `votes`: `1`
- `poll_id`: *[id van poll 1]*, `text`: `optie 2`, `votes`: `1`
- `poll_id`: *[id van poll 1]*, `text`: `optie 3`, `votes`: `1`
- `poll_id`: *[id van poll 1]*, `text`: `optie 4`, `votes`: `1`
2. Doe hetzelfde voor poll 2
> **Check:** Je hebt nu 2 rijen in `polls` en 8 rijen in `options`.
---
## Stap 2.7 — SQL Editor verkennen
> **Vertel:** "Supabase heeft ook een SQL Editor. Hier kun je direct SQL queries uitvoeren. Laten we eens kijken wat Supabase onder water doet."
### In de SQL Editor, typ en run:
```sql
SELECT * FROM polls;
```
> **Vertel:** "Dit haalt alle polls op. Simpel toch? SQL is de taal waarmee je met databases praat."
```sql
SELECT * FROM options WHERE poll_id = '[plak hier een poll id]';
```
> **Vertel:** "Dit haalt alleen de opties op van één specifieke poll. De WHERE clausule filtert."
```sql
SELECT polls.question, options.text, options.votes
FROM polls
JOIN options ON options.poll_id = polls.id;
```
> **Vertel:** "Dit is een JOIN — we combineren data uit twee tabellen. Je ziet de vraag samen met elke optie en het aantal stemmen. Dit is de kracht van een relationele database."
---
## ☕ PAUZE (10:15 10:30)
> **Vertel:** "Na de pauze gaan we Supabase koppelen aan ons Next.js project. Dan halen we de data niet meer uit een array, maar uit de echte database."
---
# DEEL 3: Supabase koppelen aan Next.js (10:30 11:30)
---
## Stap 3.1 — Supabase client library installeren
> **Vertel:** "We gaan nu onze Next.js app koppelen aan Supabase. Eerst installeren we de Supabase JavaScript library."
In de terminal:
```bash
npm install @supabase/supabase-js
```
---
## Stap 3.2 — Environment variables instellen
> **Vertel:** "We hebben twee dingen nodig van Supabase: de project URL en de anon key. Die vind je in je dashboard onder Settings → API."
### Stappen op het scherm:
1. Ga naar **Settings****API** in het Supabase dashboard
2. Kopieer de **Project URL** en de **anon public key**
Maak een nieuw bestand `.env.local` in de root van je project:
```bash
NEXT_PUBLIC_SUPABASE_URL=https://jouw-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
> **Vertel:** "We gebruiken `NEXT_PUBLIC_` als prefix zodat deze variabelen ook in de browser beschikbaar zijn. Normaal gesproken wil je secrets NIET public maken, maar de anon key is ontworpen om veilig in de browser te gebruiken — de beveiliging zit in de RLS policies die we net hebben ingesteld."
>
> **Belangrijk:** "Voeg `.env.local` toe aan je `.gitignore` als die er nog niet in staat! Dit bestand mag NOOIT naar GitHub."
---
## Stap 3.3 — Supabase client aanmaken
> **Vertel:** "We maken een utility bestand aan dat de Supabase client configureert. Dit gebruiken we overal in onze app."
Maak een nieuw bestand `lib/supabase.ts`:
```typescript
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
```
> **Vertel:** "Drie regels code. We importeren `createClient` van Supabase, lezen de URL en key uit de environment variables, en exporteren een client instance. Het uitroepteken (`!`) zegt tegen TypeScript: 'ik weet zeker dat deze waarde bestaat'. We gebruiken dit ene `supabase` object overal in onze app."
---
## Stap 3.4 — Types updaten
> **Vertel:** "Onze database structuur is anders dan wat we in de code hadden. In de database hebben we aparte tabellen voor polls en options. Laten we onze types updaten."
Open `types/index.ts` en vervang de inhoud:
```typescript
export interface Poll {
id: string;
question: string;
created_at: string;
options: Option[];
}
export interface Option {
id: string;
poll_id: string;
text: string;
votes: number;
}
```
> **Vertel:** "We hebben nu twee types: Poll en Option. Een Poll heeft een array van Options. Dit matcht precies met onze twee database tabellen. Let op: `id` is nu een `string` (uuid) in plaats van een simpel nummer."
---
## Stap 3.5 — `data.ts` aanpassen voor Supabase
> **Vertel:** "Nu het belangrijkste: we vervangen onze hardcoded array door echte database queries. We herschrijven `lib/data.ts` volledig."
Open `lib/data.ts` en vervang de **hele** inhoud:
```typescript
import { supabase } from "./supabase";
import { Poll, Option } from "@/types";
export async function getPolls(): Promise<Poll[]> {
const { data: polls, error } = await supabase
.from("polls")
.select("*, options(*)")
.order("created_at", { ascending: false });
if (error) {
console.error("Error fetching polls:", error);
return [];
}
return polls || [];
}
export async function getPollById(id: string): Promise<Poll | null> {
const { data: poll, error } = await supabase
.from("polls")
.select("*, options(*)")
.eq("id", id)
.single();
if (error) {
console.error("Error fetching poll:", error);
return null;
}
return poll;
}
export async function votePoll(
pollId: string,
optionId: string
): Promise<Option | null> {
// Eerst de huidige votes ophalen
const { data: option, error: fetchError } = await supabase
.from("options")
.select("votes")
.eq("id", optionId)
.single();
if (fetchError || !option) {
console.error("Error fetching option:", fetchError);
return null;
}
// Dan de votes met 1 verhogen
const { data: updated, error: updateError } = await supabase
.from("options")
.update({ votes: option.votes + 1 })
.eq("id", optionId)
.select()
.single();
if (updateError) {
console.error("Error updating votes:", updateError);
return null;
}
return updated;
}
```
> **Vertel:** "Laten we dit stap voor stap doorlopen:"
>
> - **`getPolls()`**: We doen `supabase.from("polls").select("*, options(*)")`. Die `options(*)` is de magie — Supabase haalt automatisch de gerelateerde opties op via de foreign key. Alsof je een JOIN doet, maar dan makkelijker.
> - **`getPollById()`**: Zelfde, maar met `.eq("id", id)` filteren we op één poll. `.single()` zegt: ik verwacht precies 1 resultaat.
> - **`votePoll()`**: We werken nu met een `optionId` (uuid) in plaats van een index. We halen eerst de huidige votes op, verhogen met 1, en slaan op.
---
## Stap 3.6 — Homepage aanpassen
> **Vertel:** "Onze `getPolls()` is nu async — die returned een Promise. We moeten de homepage aanpassen."
Open `app/page.tsx` en vervang de inhoud:
```typescript
import { getPolls } from "@/lib/data";
import { PollItem } from "@/components/PollItem";
import Link from "next/link";
export default async function Home() {
const polls = await getPolls();
return (
<div className="w-full p-4">
<h2 className="text-2xl font-bold mb-4">Onze polls</h2>
{polls.map((poll) => (
<Link key={poll.id} href={`/poll/${poll.id}`}>
<PollItem poll={poll} />
</Link>
))}
</div>
);
}
```
> **Vertel:** "Let op: de functie is nu `async` en we doen `await getPolls()`. Dit kan omdat dit een Server Component is — die draait op de server en mag async zijn. We wrappen elke poll ook in een Link zodat je erop kunt klikken om naar de detail pagina te gaan."
---
## Stap 3.7 — PollItem aanpassen voor nieuwe types
> **Vertel:** "Onze PollItem moet nu werken met de Option type in plaats van een platte array. We passen het component aan."
Open `components/PollItem.tsx` en vervang de inhoud:
```typescript
'use client'
import { Poll, Option } from "@/types"
type PollItemProps = {
poll: Poll
onOptionClick?: (option: Option) => void
}
type PollItemOptionProps = {
option: Option
percentage: number
onClick?: (option: Option) => void
}
export const PollItemOption = ({ option, percentage, onClick }: PollItemOptionProps) => {
return (
<div
onClick={() => onClick?.(option)}
className="relative my-2 p-3 border rounded cursor-pointer hover:bg-gray-50 overflow-hidden"
>
<div
className="absolute top-0 left-0 h-full bg-blue-100 transition-all duration-300"
style={{ width: `${percentage}%` }}
/>
<div className="relative flex justify-between">
<span>{option.text}</span>
<span className="text-gray-500">{option.votes} ({percentage}%)</span>
</div>
</div>
)
}
export const PollItem = ({ poll, onOptionClick }: PollItemProps) => {
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes, 0);
return (
<section className="w-full my-6">
<h2 className="text-xl font-bold mb-2">{poll.question}</h2>
<p className="text-sm text-gray-500 mb-3">{totalVotes} stemmen</p>
{poll.options.map((option) => {
const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0;
return (
<PollItemOption
key={option.id}
option={option}
percentage={percentage}
onClick={onOptionClick}
/>
)
})}
</section>
)
}
```
> **Vertel:** "Het verschil: we gebruiken nu `option.text` en `option.votes` in plaats van de platte arrays. En de key is nu `option.id` — dat is altijd uniek."
---
## Stap 3.8 — VoteForm + detail pagina aanpassen
> **Vertel:** "We hebben in Deel 1 het Server Component + VoteForm patroon opgezet. De pagina is een Server Component die data ophaalt, de VoteForm is een Client Component voor interactie. Dat patroon hoeven we niet te veranderen — we passen alleen de VoteForm aan om met option ID's te werken."
Open `components/VoteForm.tsx` en vervang de inhoud:
```typescript
'use client'
import { Poll, Option } from "@/types";
import { PollItem } from "./PollItem";
import { useState } from "react";
export function VoteForm({ poll: initialPoll }: { poll: Poll }) {
const [poll, setPoll] = useState(initialPoll);
const onVote = async (option: Option) => {
const response = await fetch(`/api/polls/${poll.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ optionId: option.id }),
});
if (response.ok) {
const updatedPoll = await response.json();
setPoll(updatedPoll);
}
};
return <PollItem poll={poll} onOptionClick={onVote} />;
}
```
> **Vertel:** "Bijna identiek als voorheen, maar nu sturen we `option.id` (de uuid) mee in plaats van een index. En de callback ontvangt een `Option` object in plaats van een string."
Open `app/poll/[id]/page.tsx` en update:
```typescript
import { getPollById } from "@/lib/data";
import { VoteForm } from "@/components/VoteForm";
import { notFound } from "next/navigation";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function PollPage({ params }: PageProps) {
const { id } = await params;
const poll = await getPollById(id);
if (!poll) notFound();
return (
<div className="w-full p-4">
<h2 className="text-2xl font-bold mb-4">{poll.question}</h2>
<VoteForm poll={poll} />
</div>
);
}
```
> **Vertel:** "Het enige verschil met Deel 1: `await` voor `getPollById()` — want die is nu async vanwege Supabase. De rest is exact hetzelfde. Dát is de kracht van dit patroon: de pagina structuur verandert niet als je van data source switcht."
---
## Stap 3.9 — API routes aanpassen voor Supabase
> **Vertel:** "Als laatste moeten we de API routes updaten om met Supabase te werken."
Open `app/api/polls/[id]/route.ts` en vervang de inhoud:
```typescript
import { NextResponse } from "next/server";
import { getPollById, votePoll } from "@/lib/data";
interface RouteParams {
params: Promise<{ id: string }>;
}
export async function GET(request: Request, { params }: RouteParams) {
const { id } = await params;
const poll = await getPollById(id);
if (!poll) {
return NextResponse.json({ error: "Poll niet gevonden" }, { status: 404 });
}
return NextResponse.json(poll);
}
export async function POST(request: Request, { params }: RouteParams) {
const { id } = await params;
const body = await request.json();
const { optionId } = body;
if (!optionId) {
return NextResponse.json({ error: "optionId is verplicht" }, { status: 400 });
}
const updatedOption = await votePoll(id, optionId);
if (!updatedOption) {
return NextResponse.json(
{ error: "Kon stem niet verwerken" },
{ status: 400 }
);
}
// Haal de volledige poll op om terug te sturen
const poll = await getPollById(id);
return NextResponse.json(poll);
}
```
> **Vertel:** "De GET route is bijna hetzelfde, maar nu met `await` omdat `getPollById` async is. De POST route leest nu `optionId` uit de body, roept `votePoll` aan, en stuurt de hele updated poll terug."
---
## ✅ Check: Alles testen
> **Check:** Herstart de dev server (stop en `npm run dev`).
>
> 1. Ga naar `http://localhost:3000` — je ziet de polls uit Supabase
> 2. Klik op een poll — je ziet de detail pagina met opties
> 3. Stem op een optie — de votes updaten
> 4. **Refresh de pagina** — de stem is bewaard!
> 5. **Open Supabase Table Editor** — je ziet de updated votes in de `options` tabel
>
> **Vertel:** "Dat is het grote verschil met onze in-memory data: als je nu de server herstart, of de pagina refresht, zijn je stemmen er nog steeds. Ze staan in een echte database. En als iemand anders de URL opent, ziet die persoon dezelfde data. Dat is de kracht van een database."
---
## Stap 3.10 — Bonus: Realtime (als er tijd over is)
> **Vertel:** "Als je wil dat stemmen LIVE updaten bij andere gebruikers, kan Supabase dat ook. Dit is een bonus — we laten het even zien."
Open `components/VoteForm.tsx` en voeg een `useEffect` toe voor realtime:
```typescript
'use client'
import { Poll, Option } from "@/types";
import { PollItem } from "./PollItem";
import { useState, useEffect } from "react";
import { supabase } from "@/lib/supabase";
export function VoteForm({ poll: initialPoll }: { poll: Poll }) {
const [poll, setPoll] = useState(initialPoll);
// Realtime subscription — luister naar vote updates
useEffect(() => {
const channel = supabase
.channel('votes')
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'options' },
async () => {
const res = await fetch(`/api/polls/${poll.id}`);
if (res.ok) {
setPoll(await res.json());
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [poll.id]);
const onVote = async (option: Option) => {
const response = await fetch(`/api/polls/${poll.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ optionId: option.id }),
});
if (response.ok) {
setPoll(await response.json());
}
};
return <PollItem poll={poll} onOptionClick={onVote} />;
}
```
> **Vertel:** "Nu luistert de VoteForm naar veranderingen in de options tabel. Als iemand anders stemt, update jouw scherm automatisch. Dit is realtime — geen polling, geen refresh nodig. En het zit netjes in het Client Component waar het hoort."
---
# AFSLUITING (11:30 12:00)
## Samenvatting
> **Vertel:** "Wat hebben we vandaag gedaan?"
>
> 1. **Poll afgemaakt** — stemmen werkt nu echt
> 2. **Supabase leren kennen** — tabellen, foreign keys, RLS policies, SQL
> 3. **Supabase gekoppeld** — van in-memory array naar echte database
> 4. **Data is nu persistent** — overleeft server restarts
>
> **Vertel:** "Volgende week gaan we authenticatie toevoegen met Supabase Auth, zodat je kunt zien wie er gestemd heeft."
---
## Huiswerk
> **Vertel:** "Voor volgende week:"
>
> 1. **Maak een `/create` pagina** waar je een nieuwe poll kunt aanmaken
> - Formulier met een vraag en minimaal 2 opties
> - Sla op in Supabase (INSERT in polls + options tabellen)
> - Redirect naar de homepage na het aanmaken
> 2. **Voeg een "Nieuwe Poll" link toe** in de navbar
> 3. **Extra:** probeer de SQL Editor in Supabase — schrijf queries om:
> - De poll met de meeste stemmen te vinden
> - Alle opties op te halen gesorteerd op votes (hoog naar laag)