Files
novi-lessons/Les08-Supabase+Nextjs/Les08-Docenttekst.md
2026-03-31 16:34:28 +02:00

576 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Les 8 — Docenttekst
## Van In-Memory naar Supabase
---
## Lesoverzicht
| Gegeven | Details |
|---------|---------|
| **Les** | 8 van 18 |
| **Onderwerp** | Supabase koppelen aan Next.js |
| **Duur** | 3 uur (09:00 12:00) |
| **Voorbereiding** | Werkend QuickPoll project, Supabase project met polls/options tabellen |
| **Benodigdheden** | Laptop, Cursor/VS Code, browser, Supabase account |
## Leerdoelen
Na deze les kunnen studenten:
1. De Supabase JavaScript client installeren en configureren
2. Environment variables gebruiken voor API keys
3. Data ophalen via Supabase queries (select met relaties, eq, single)
4. Het verschil uitleggen tussen sync en async data ophalen
5. Het Server Component + Client Component patroon toepassen
6. Een formulier bouwen dat data INSERT in Supabase
---
## Lesplanning
### 09:0009:10 | Welkom & Terugblik (10 min)
📌 Slide 1, 2, 3
**Doel:** Studenten op dezelfde pagina brengen over waar we zijn.
**Wat te zeggen:**
- "Vorige week hebben we een werkend polling app gebouwd met in-memory data."
- "Vandaag koppelen we Supabase: onze database-as-a-service."
- "Na vandaag kunnen jullie niet alleen stemmen, maar ook nieuwe polls aanmaken."
**Check:**
- Iedereen heeft Supabase account met polls en options tabellen
- Iedereen heeft QuickPoll project lokaal runnen op localhost:3000
- Niemand heeft Auth ingesteld (dat doen we volgende les)
---
### 09:1010:15 | DEEL 1: Live Coding — Supabase koppelen (65 min)
📌 Slide 4, 5, 6
**Doel:** Live voor hen de hele flow bouwen: installatie → queries → component aanpassingen.
**Voorbereiding jij:**
1. Open Cursor met je QuickPoll project
2. Zorg dat Supabase dashboard open staat in je browser
3. `npm install @supabase/supabase-js` al gedraaid (zeker weten!)
4. Terminal gereed, dev server draait
**Stap-voor-stap Live Coding:**
#### 1. npm install @supabase/supabase-js
```bash
npm install @supabase/supabase-js
```
**Zeg:** "Dit geeft ons de client om met Supabase te praten."
#### 2. .env.local (Settings → API)
```
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGc...
```
**Zeg:** "Dit zijn jullie API credentials. Ziet erruit in Supabase Settings → API. De `NEXT_PUBLIC_` prefix betekent dat deze in de browser beschikbaar zijn (safe)."
**Docent tip:** Na `npm install` en .env wijzigen moet de dev server **herstarten**! Zeg dit expliciet.
#### 3. lib/supabase.ts
```typescript
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
```
**Zeg:** "Dit is onze Supabase client. We maken hem eenmalig aan en exporteren hem, dan kunnen alle componenten hem gebruiken."
#### 4. types/index.ts (Database matching)
```typescript
export interface Poll {
id: number;
question: string;
created_at: string;
options: Option[];
}
export interface Option {
id: number;
poll_id: number;
text: string;
votes: number;
created_at: string;
}
```
**Zeg:** "Dit matchen onze TypeScript types met de database schema. Poll bevat options als relatie."
#### 5. lib/data.ts (Supabase queries herschrijven)
**VOOR je dit toont, laat je het oude in-memory array zien:**
```typescript
// OUD:
const polls = [
{ question: "...", options: ["...", "..."], votes: [0, 0] }
];
export function getPolls() {
return polls;
}
```
**Zeg:** "Dit was in-memory. Nu halen we het uit Supabase."
**NA - Supabase queries:**
```typescript
import { supabase } from "./supabase";
import { Poll } from "@/types";
export async function getPolls(): Promise<Poll[]> {
const { data, error } = await supabase
.from("polls")
.select("*, options(*)");
if (error) {
console.error("Error fetching polls:", error);
return [];
}
return data || [];
}
export async function getPollById(id: number): Promise<Poll | null> {
const { data, error } = await supabase
.from("polls")
.select("*, options(*)")
.eq("id", id)
.single();
if (error) {
console.error("Error fetching poll:", error);
return null;
}
return data;
}
export async function votePoll(optionId: number): Promise<boolean> {
const { error } = await supabase.rpc("vote_option", { option_id: optionId });
if (error) {
console.error("Error voting:", error);
return false;
}
return true;
}
```
**Docent tips:**
- `.select("*, options(*)")` = "Haal polls op, EN daarbij hun relatie options"
- `.eq("id", id)` = "Where id = ..."
- `.single()` = "Ik verwacht exact 1 resultaat"
- `await` = Dit is nu async! Componenten moeten `async` zijn of we gebruiken een API route
#### 6. PAUZE — Slide 6: Server vs Client: Wie doet wat?
**BELANGRIJK:** Toon deze slide VOOR je componenten aanpast. Dit patroon is cruciaal.
**Zeg:**
"We hebben nu async functies. Server Components kunnen `await` direct gebruiken. Client Components niet. Daarom splitsen we:
- Server Components: /page.tsx files (halen data op met await)
- Client Components: VoteForm (useState, onClick event handlers)"
Laat code zien:
```typescript
// Server Component
export default async function HomePage() {
const polls = await getPolls();
return <>{...}</>
}
// Client Component
'use client'
export function VoteForm() {
const [voted, setVoted] = useState(false);
return <>{...}</>
}
```
---
#### 7. app/page.tsx → Server Component
```typescript
import { getPolls } from "@/lib/data";
import Link from "next/link";
import PollItem from "@/components/PollItem";
export default async function HomePage() {
const polls = await getPolls();
return (
<div className="w-full max-w-2xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">Huidige Polls</h1>
<Link href="/create" className="text-blue-600 hover:underline mb-6 block">
+ Nieuwe Poll
</Link>
<div className="space-y-4">
{polls.map((poll) => (
<PollItem key={poll.id} poll={poll} />
))}
</div>
</div>
);
}
```
**Zeg:** "Dit is nu async! De `await getPolls()` werkt hier rechtstreeks. Link naar /create toevoegen."
#### 8. components/PollItem.tsx (Option type, percentage bars)
```typescript
'use client'
import Link from "next/link";
import { Option } from "@/types";
interface PollItemProps {
poll: {
id: number;
question: string;
options: Option[];
};
}
export default function PollItem({ poll }: PollItemProps) {
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes, 0);
return (
<div className="border rounded p-4">
<h2 className="text-xl font-semibold mb-4">{poll.question}</h2>
<div className="space-y-2">
{poll.options.map((option) => {
const percentage = totalVotes > 0 ? (option.votes / totalVotes) * 100 : 0;
return (
<Link key={option.id} href={`/poll/${poll.id}`}>
<div className="flex items-center gap-2 cursor-pointer hover:opacity-80">
<div className="flex-1 bg-gray-200 rounded h-8 overflow-hidden">
<div
className="bg-blue-600 h-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-sm font-medium w-20">
{option.text} ({option.votes})
</span>
</div>
</Link>
);
})}
</div>
</div>
);
}
```
**Zeg:** "Nu hebben we Option type beschikbaar. Percentage bars tonen stemmen visueel."
#### 9. components/VoteForm.tsx (Client Component met vote mutation)
```typescript
'use client'
import { useState } from "react";
import { votePoll } from "@/lib/data";
import { Option } from "@/types";
interface VoteFormProps {
options: Option[];
}
export default function VoteForm({ options }: VoteFormProps) {
const [loading, setLoading] = useState(false);
const [voted, setVoted] = useState(false);
const handleVote = async (optionId: number) => {
setLoading(true);
const success = await votePoll(optionId);
if (success) {
setVoted(true);
}
setLoading(false);
};
if (voted) {
return <p className="text-green-600">Dank je voor je stem!</p>;
}
return (
<div className="space-y-2">
{options.map((option) => (
<button
key={option.id}
onClick={() => handleVote(option.id)}
disabled={loading}
className="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{option.text}
</button>
))}
</div>
);
}
```
**Zeg:** "Dit is Client Component: `'use client'` bovenaan. We kunnen useState gebruiken, onClick handlers. Na stem, feedback tonen."
#### 10. app/poll/[id]/page.tsx (Server + Client combo)
```typescript
import { getPollById } from "@/lib/data";
import VoteForm from "@/components/VoteForm";
import { notFound } from "next/navigation";
export default async function PollPage({ params }: { params: { id: string } }) {
const poll = await getPollById(parseInt(params.id));
if (!poll) {
notFound();
}
return (
<div className="w-full max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">{poll.question}</h1>
<VoteForm options={poll.options} />
</div>
);
}
```
**Zeg:** "Server Component haalt data. Geeft VoteForm (Client Component) de options door. Best of both worlds!"
#### 11. app/api/polls/[id]/route.ts (GET + POST)
```typescript
import { getPollById, votePoll } from "@/lib/data";
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const poll = await getPollById(parseInt(params.id));
if (!poll) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(poll);
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const { optionId } = await request.json();
const success = await votePoll(optionId);
return NextResponse.json({ success });
}
```
#### 12. Test alles!
- Homepage laden → alle polls met opties tonen
- Click poll → detail pagina, stem kan worden gegeven
- Stem geven → votes increment in Supabase
- Controleer in Supabase dashboard → votes kolom stijgt
**Docent tips bij Live Coding:**
1. **TypeScript errors:** "Soms zien we rode squigglies. Dat is TypeScript die zegt 'ik snap dit type niet'. Hover je eroverheen, meestal is het een `!` die je moet toevoegen of een import."
2. **RLS blocking:** "Nog krijgen we misschien 'RLS policy violation'. Dat fix je volgende les met Auth. Nu gebruiken we publieke SELECT."
3. **Env restart:** Na .env wijzigen ECHT herstarten. Hardnekkig bug!
4. **Queries testen:** Open Supabase dashboard → SQL Editor → test je select statements daar eerst.
---
### 10:1510:30 | PAUZE (15 min)
📌 Slide 7
---
### 10:3011:30 | DEEL 2: Zelf Doen — /create pagina (60 min)
📌 Slide 8
**Doel:** Studenten bouwen zelf een formulier om nieuwe polls aan te maken.
#### Stap 1: Theorie op beamer (15 min)
**Zeg:**
"Nu bouwen jullie zelf de /create pagina. Daarmee kunnen gebruikers nieuwe polls aanmaken. Eerst leg ik het uit, dan doen jullie het zelf."
**INSERT queries uitleggen:**
Laat dit zien:
```typescript
// 1. Insert poll
const { data: poll } = await supabase
.from("polls")
.insert({ question: "Wat is je favoriete taal?" })
.select()
.single();
// poll is nu { id: 42, question: "Wat is je favoriete taal?", ... }
// 2. Insert options (meerdere tegelijk)
await supabase.from("options").insert([
{ poll_id: 42, text: "JavaScript", votes: 0 },
{ poll_id: 42, text: "Python", votes: 0 },
{ poll_id: 42, text: "Rust", votes: 0 }
]);
```
**Zeg:**
- ".insert() = INSERT statement"
- ".select().single() = geef me terug wat je net inserted, als 1 rij"
- "poll.id gebruiken we dan voor de options"
- "Daarna .insert([...]) meerdere opties in één keer"
- "Dan router.push('/') terug naar homepage"
**RLS policy toevoegen:**
Laat dit SQL blokje zien (ze moeten dit in Supabase doen):
```sql
-- INSERT policy voor polls
CREATE POLICY "Allow public insert on polls"
ON polls FOR INSERT
TO anon
WITH CHECK (true);
-- INSERT policy voor options
CREATE POLICY "Allow public insert on options"
ON options FOR INSERT
TO anon
WITH CHECK (true);
```
**Zeg:**
"Dit zegt tegen Supabase: 'Iedereen mag INSERT-en op polls en options.' Zonder dit krijgen jullie 'RLS policy violation'. Dit is tijdelijk — volgende week beperken we dit met Auth."
**Form outline:**
```
1. Text input voor vraag
2. Meerdere text inputs voor opties (minimum 2)
3. "+ Optie toevoegen" knop
4. "Poll aanmaken" submit knop
5. Bij submit: INSERT in polls, dan INSERT in options, dan redirect("/")
```
#### Stap 2: Zelf doen (45 min)
**Wat studenten moeten doen:**
1. **RLS policy** in Supabase dashboard toevoegen (SQL Editor)
2. **app/create/page.tsx** aanmaken met:
- `'use client'` bovenaan
- useState voor question en options array
- Input voor question
- Loop over options, input per optie
- "+ Optie toevoegen" knop (addOption)
- "Poll aanmaken" button (handleSubmit)
3. **handleSubmit logica:**
- Insert poll → krijg poll.id terug
- Insert opties met die poll_id
- Error handling
- router.push("/") na succes
4. **Homepage (page.tsx) updaten:**
- Link naar /create bovenaan
**Docent loop ronde:**
- **Min 0-5:** Iedereen aan het werk?
- **Min 15:** Check of iedereen RLS policy heeft ingesteld. Help als iemand vast zit.
- **Min 25:** Toon code snippet van useState setup als mensen vragen hebben.
- **Min 30:** Check of eerste iemand INSERT werkend heeft. Toon in Supabase dashboard hoe je ziet dat poll aangemaakt is.
- **Min 45:** Ruim 5 min voor finalisatie, vragen, troubleshoot.
**Veelvoorkomende problemen:**
| Probleem | Oplossing |
|----------|-----------|
| "RLS policy violation" | Zeg: RLS policy toegevoegd in dashboard? Zien we in error message "RLS"? |
| "poll is undefined na insert" | `.select().single()` weg? Dat moet je toevoegen! |
| "Opties werken niet" | poll.id goed doorgegeven aan insert? Controleer in Supabase options tabel. |
| "Form submit refresh de pagina" | `e.preventDefault()` in handleSubmit? |
| "Redirect werkt niet" | `import { useRouter }` bovenaan? `const router = useRouter()` in component? |
| "Opties array gaat fout" | Laat code zien: `const newOptions = [...options]; newOptions[index] = value; setOptions(newOptions);` |
---
### 11:3011:45 | Vragen & Reflectie (15 min)
**Mogelijke vragen + antwoorden:**
**V: Wat happens na redirect?**
A: De homepage laadt opnieuw. `app/page.tsx` roept getPolls() aan, die hit Supabase en toont je nieuwe poll.
**V: Waarom `async`/`await`?**
A: Supabase is over het network. We wachten tot het antwoord komt. `async` zegt "dit kan tijd kosten".
**V: Kan ik realtime zien als iemand anders stemt?**
A: Volgende week! Supabase heeft realtime subscriptions. Daar leren we.
**V: Wat is `/api/` folder?**
A: Dat zijn backend endpoints. Volgende week gebruiken we die meer.
**V: Waarom `'use client'` in create en vote, maar niet in page?**
A: Client = interactief (forms, buttons, state). Server = data fetching. Next.js split dit automatisch.
---
### 11:4512:00 | Huiswerk & Afsluiting (15 min)
📌 Slide 9, 10
**Huiswerk:**
1. **/create pagina afmaken** (als nog niet klaar in klas)
2. **Validatie toevoegen:**
- Vraag mag niet leeg
- Opties moeten uniek zijn
- Minimaal 2 opties
- Error messages tonen
3. **Delete functionaliteit:**
- Delete knop op PollItem
- Verwijder poll + opties uit Supabase
4. **Extra (voor snelle studenten):**
- SQL queries schrijven (direct in Supabase SQL Editor)
- Realtime subscriptions uittesten
- Styling verbeteren
**Zeg:**
"Volgende week: Supabase Auth. Jullie gaan inloggen en registreren bouwen. En bepalen wie welke polls mag aanmaken. Tot dan!"
**Slide 10: Afsluiting**
- "Tot volgende week!"
- "Volgende les: Supabase Auth — inloggen, registreren, en bepalen wie wat mag"
---
## Veelvoorkomende problemen & Troubleshoot
| Symptoom | Oorzaak | Oplossing |
|----------|---------|-----------|
| "Cannot find module @supabase/supabase-js" | npm install niet gedraaid | `npm install @supabase/supabase-js` |
| Env vars undefined in browser console | NEXT_PUBLIC_ prefix vergeten OF dev server niet restarted | Restart dev server (`npm run dev`). Check prefix: NEXT_PUBLIC_SUPABASE_URL |
| "RLS policy violation" on SELECT | RLS enabled, geen SELECT policy | Voor nu: disable RLS in Supabase (Security → RLS → toggle OFF). Volgende les met Auth |
| "RLS policy violation" on INSERT | Geen INSERT policy of RLS restrictief | Voeg INSERT policies toe (zie Deel 2 stap 1) |
| getPolls() returns empty array | Query failed maar geen error | Check: .select() syntax correct? options(*) geindent? Controleer in Supabase SQL Editor |
| TypeScript "Cannot find name 'Poll'" | Import weg | `import { Poll } from "@/types"` bovenaan |
| "notFound() is not defined" | Import weg | `import { notFound } from "next/navigation"` |
| Percentage bars werken niet | totalVotes = 0 dus percentage = 0 | Check: votes kolom in Supabase ≠ 0? Stem eenmalig via UI |
| Client form not submitting | e.preventDefault() weg OF loading state blocked | Check handleSubmit: eerst `e.preventDefault()`, geen return-statements die vorig breken |
| Redirect naar / werkt niet na poll maken | router niet geïmporteerd OF router.push() fout | `import { useRouter }` from "next/navigation" (niet "next/router"!) |
| Supabase queries slow | Network latency / veel data | Normal! Later: replication, caching, realtime |
---
## Tips voor docenten
1. **Code live typen, niet copy-paste.** Laat typos zien, laat debugging zien. Authentiek!
2. **Veel pauzes voor vragen.** Live Coding voelt snel. Check regelmatig: "Allemaal met me mee?"
3. **Zelf Doen starten met duidelijke steps:** (1) RLS policy, (2) page.tsx, (3) form, (4) submit. Niet: "Bouw de pagina."
4. **Loop ronde, spot problemen vroeg:** Min 15-25 zijn goud voor troubleshoot.
5. **Toon Supabase dashboard often:** "Zie je? De data staat echt in de database!"
6. **Authenticatie is volgende les:** Zeg het af te toe: "Dit beperken we volgende week met Auth."
7. **Celebrate wins:** Eerste student met werkende /create? Toon het aan iedereen!