576 lines
18 KiB
Markdown
576 lines
18 KiB
Markdown
# 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:00–09: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:10–10: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:15–10:30 | PAUZE (15 min)
|
||
📌 Slide 7
|
||
|
||
---
|
||
|
||
### 10:30–11: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:30–11: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:45–12: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!
|