Files
novi-lessons/Les10-Supabase-Auth/Les10-Docenttekst.md
2026-05-19 18:50:11 +02:00

56 KiB
Raw Blame History

Les 10 — Supabase Authenticatie & RLS: Docenttekst

Docent: Tim Duur: 3 uur (180 min) — 09:00 tot 12:00 Cursus: AI Developer — NOVI Hogeschool Utrecht Datum: Lesweek 10


Overzicht & Tijdsindeling

Tijd Duur Onderdeel Slide(s)
09:00 09:10 10 min Welkom + Terugblik Les 8-9 1, 2, 3
09:10 09:25 15 min Eindexamenopdracht Introductie 4
09:25 09:45 20 min Theorie: Authenticatie & Supabase Auth 5, 6, 7
09:45 10:00 15 min Theorie: Auth in Next.js + RLS 8, 9
10:00 10:15 15 min Pauze 10
10:15 10:30 15 min Hands-on: Auth Opzetten in Supabase 11
10:30 10:55 25 min Hands-on: Login & Registratie Bouwen 12
10:55 11:10 15 min Hands-on: Sessie & Beschermde Routes 13
11:10 11:25 15 min Hands-on: Basis RLS 14
11:25 11:45 20 min Doorwerken + Tim loopt rond 11-14
11:45 12:00 15 min Samenvatting + Huiswerk 15

Benodigdheden

  • Slides Les 10 (15 slides)
  • Werkende Poll App uit Les 8-9 (Next.js 16 + TypeScript + Tailwind CSS + Supabase)
  • Supabase dashboard open in browser (LIVE demo)
  • VS Code met project geopend
  • Terminal klaar voor npm commando's

BLOK 1 — Klassikaal (09:00 10:00)


09:00 09:10 | Welkom + Terugblik Les 8-9 (10 min)


📊 Slide 1 — Les 10: Supabase Auth & Row Level Security

Tim zegt: "Goedemorgen allemaal! Welkom bij Les 10. Vandaag gaan we iets heel belangrijks toevoegen aan onze Poll App: gebruikers. Op dit moment kan iedereen alles doen in onze app — polls aanmaken, stemmen, noem maar op. Dat gaat vandaag veranderen."


📊 Slide 2 — Planning Vandaag

Tim zegt: "Laat me even laten zien wat we vandaag gaan doen. We beginnen met een korte terugblik op wat we in Les 8 en 9 hebben gebouwd. Dan heb ik een belangrijke aankondiging over de eindexamenopdracht. Daarna duiken we in de theorie van authenticatie en hoe Supabase dat regelt. Na de pauze gaan we hands-on aan de slag: we bouwen login en registratie, beschermen onze routes, en zetten Row Level Security op. Best een volle les, maar we doen het stap voor stap."


📊 Slide 3 — Terugblik Les 8-9: Supabase Setup & Database

Tim zegt: "Even een snelle terugblik. In Les 8 en 9 hebben we Supabase opgezet als onze backend. We hebben een project aangemaakt, twee tabellen gebouwd — polls en options — en onze Next.js app verbonden met Supabase. Jullie kunnen polls aanmaken en erop stemmen."

Tim zegt: "Maar er mist iets heel belangrijks. Wie kan me vertellen wat er mist?"

Wacht op antwoorden. Stuur richting: er is geen login, iedereen kan alles doen, er is geen beveiliging.

Tim zegt: "Precies. Er is geen authenticatie. Iedereen die de URL kent kan alles doen met onze database. In de echte wereld wil je weten WIE iets doet, en wil je bepalen WAT die persoon mag doen. Dat is precies wat we vandaag gaan bouwen."


09:10 09:25 | Eindexamenopdracht Introductie (15 min)


📊 Slide 4 — Eindexamenopdracht: Vrije Keuze App

Tim zegt: "Voordat we met de theorie beginnen, wil ik jullie alvast vertellen over de eindexamenopdracht. Dit is belangrijk, dus luister goed."

Tim zegt: "De eindexamenopdracht is een vrije keuze app. Dat betekent: jullie mogen zelf kiezen wat voor applicatie je gaat bouwen. Het kan een to-do app zijn, een recepten-app, een fitness tracker, een blog platform — het maakt niet uit, zolang je de technieken gebruikt die we in deze cursus leren."

Tim zegt: "Wat zijn die technieken? Laat me de requirements even langslopen:"

Benoem de volgende requirements:

  • Next.js als framework (met TypeScript)
  • Tailwind CSS voor styling
  • Supabase als backend (database + authenticatie)
  • AI-assisted development — je gebruikt tools zoals ChatGPT, Copilot, of Claude om je te helpen bij het bouwen
  • Authenticatie — gebruikers moeten kunnen inloggen (wat we vandaag leren!)
  • CRUD operaties — je app moet data kunnen aanmaken, lezen, updaten en verwijderen
  • Row Level Security — je data moet beveiligd zijn (ook vandaag!)

Tim zegt: "Jullie zien: alles wat we tot nu toe geleerd hebben komt samen in die eindopdracht. En vandaag leren we de laatste twee grote onderdelen: authenticatie en beveiliging. Na vandaag hebben jullie alle bouwstenen om je eigen app te gaan bouwen."

Tim zegt: "Begin alvast na te denken over wat je wilt bouwen. Volgende les gaan we er meer over praten en kunnen jullie vragen stellen. Maar ik wilde het alvast noemen zodat jullie weten waar we naartoe werken."

Geef ruimte voor korte vragen. Houd het kort — max 2-3 vragen.


09:25 09:45 | Theorie: Authenticatie & Supabase Auth (20 min)


📊 Slide 5 — Wat is Authenticatie?

Tim zegt: "Oké, laten we beginnen met de basis. Wat is authenticatie eigenlijk? En wat is het verschil met autorisatie? Dit zijn twee termen die vaak door elkaar worden gehaald, maar ze betekenen iets heel anders."

Tim zegt: "Authenticatie is: WIE ben je? Het is het proces van bewijzen dat je bent wie je zegt dat je bent. Denk aan inloggen met je email en wachtwoord. Je bewijst: ik ben Tim, want ik ken het wachtwoord van Tim's account."

Tim zegt: "Autorisatie is: WAT mag je doen? Nadat we weten wie je bent, bepalen we wat je mag. Mag je alleen je eigen polls zien? Mag je polls van anderen verwijderen? Dat is autorisatie."

Tim zegt: "Een voorbeeld uit het dagelijks leven: als je naar een festival gaat, dan is je ID-bewijs de authenticatie — je bewijst wie je bent. Je ticket is de autorisatie — het bepaalt of je naar binnen mag en welke gebieden je in mag."

Tim zegt: "Vandaag doen we allebei. Authenticatie met Supabase Auth, en autorisatie met Row Level Security."


📊 Slide 6 — Supabase Auth: 3 Methodes

Open nu het Supabase dashboard in de browser. Navigeer naar Authentication > Providers.

Tim zegt: "Supabase heeft een ingebouwd authenticatiesysteem. Je hoeft geen eigen login-systeem te bouwen — Supabase regelt alles voor je: wachtwoorden hashen, sessies beheren, tokens genereren. Laat me dit even laten zien in het dashboard."

LIVE DEMO in Supabase Dashboard:

  1. Open het Supabase project in de browser
  2. Klik op Authentication in de linkerzijbalk
  3. Laat de Users tab zien (nu nog leeg)
  4. Klik op Providers onder Configuration

Tim zegt: "Kijk, hier zie je alle providers die Supabase ondersteunt. Er zijn er heel veel, maar wij focussen op drie methodes:"

Tim zegt: "Methode 1: Email + Wachtwoord. De klassieke manier. Gebruiker vult email en wachtwoord in, Supabase slaat het veilig op. Het wachtwoord wordt gehasht — dat betekent dat zelfs Supabase je wachtwoord niet kan zien. Dit is standaard ingeschakeld."

Wijs in het dashboard naar de Email provider — laat zien dat deze standaard aan staat.

Tim zegt: "Methode 2: Magic Link. Dit is een coole methode. De gebruiker vult alleen zijn email in, en krijgt een link per mail. Als je op die link klikt, ben je ingelogd. Geen wachtwoord nodig. Heel veilig, want alleen de eigenaar van dat emailadres kan inloggen. Dit werkt ook via de Email provider."

Tim zegt: "Methode 3: Social Login (OAuth). Inloggen via Google, GitHub, Discord, enzovoort. De gebruiker klikt op 'Login met Google', wordt doorgestuurd naar Google, logt daar in, en komt terug in jouw app. Heel handig, maar iets complexer om op te zetten. Dit doen we vandaag niet, maar het is goed om te weten dat het bestaat."

Scroll even door de lijst met providers zodat studenten zien hoeveel opties er zijn (Google, GitHub, Apple, Discord, etc.).

Tim zegt: "Wij gaan vandaag methode 1 en 2 gebruiken: email met wachtwoord, en magic link. Dat is voor 90% van de apps meer dan genoeg."


📊 Slide 7 — Hoe Werkt een Sessie? (JWT & Cookies)

Tim zegt: "Oké, maar hoe werkt dat technisch? Als je inlogt, wat gebeurt er dan achter de schermen? Hier komen twee belangrijke termen: JWT en cookies."

Tim zegt: "Als je inlogt bij Supabase, krijg je een JWT terug — een JSON Web Token. Dat is eigenlijk een lange string, een soort pasje, dat bewijst dat jij ingelogd bent. In die token staat wie je bent, wanneer je bent ingelogd, en wanneer het verloopt."

Tim zegt: "Die token moet ergens bewaard worden zodat je niet bij elke pagina opnieuw hoeft in te loggen. Dat doen we met cookies. Een cookie is een klein stukje data dat je browser automatisch meestuurt bij elk verzoek naar de server."

Tim zegt: "Dus het werkt zo:"

Leg het volgende stappenplan uit:

  1. Gebruiker logt in met email + wachtwoord
  2. Supabase controleert de gegevens
  3. Supabase stuurt een JWT token terug
  4. De token wordt opgeslagen als cookie in de browser
  5. Bij elk volgend verzoek stuurt de browser de cookie mee
  6. De server leest de cookie, checkt de token, en weet wie je bent

Tim zegt: "Het mooie van Supabase is dat dit allemaal automatisch gaat. Wij hoeven alleen de juiste packages te installeren en een paar bestanden aan te maken. Supabase regelt het hashen van wachtwoorden, het aanmaken van tokens, en het vernieuwen van verlopen tokens."

LIVE DEMO in Supabase Dashboard:

  1. Ga naar Authentication > Users
  2. Klik op Add user > Create new user
  3. Vul een test email en wachtwoord in (bijv. test@voorbeeld.nl / test1234)
  4. Klik op Create user

Tim zegt: "Kijk, ik heb nu handmatig een gebruiker aangemaakt in het dashboard. Dit is handig voor testen. Straks gaan we dit vanuit de app doen, maar het is goed om te weten dat je ook handmatig gebruikers kunt beheren."

Laat de aangemaakte user zien in de lijst. Wijs op de kolommen: email, created at, last sign in, etc.


09:45 10:00 | Theorie: Auth in Next.js + RLS (15 min)


📊 Slide 8 — Auth in Next.js (@supabase/ssr)

Tim zegt: "Nu we weten hoe authenticatie werkt, moeten we het koppelen aan onze Next.js app. Daarvoor gebruiken we een package die @supabase/ssr heet. SSR staat voor Server-Side Rendering."

Tim zegt: "Waarom een speciale package? Omdat Next.js zowel op de server als in de browser draait. In de browser heb je gewoon toegang tot cookies. Maar op de server — als een pagina wordt gerenderd op de server — moet je cookies op een andere manier lezen en schrijven. @supabase/ssr regelt dat voor je."

Tim zegt: "We gaan straks drie belangrijke bestanden aanmaken:"

Benoem de drie bestanden:

  1. src/lib/supabase/client.ts — De browser client. Wordt gebruikt in componenten die in de browser draaien (Client Components).
  2. src/lib/supabase/server.ts — De server client. Wordt gebruikt in Server Components en Server Actions.
  3. src/middleware.ts — Middleware die bij ELK verzoek draait. Controleert of de gebruiker is ingelogd en vernieuwt de sessie.

Tim zegt: "De middleware is het belangrijkste stuk. Die draait bij elk verzoek — elke keer als je een pagina opent. De middleware checkt: is er een geldige sessie? Zo niet, stuur de gebruiker naar de login pagina. Zo ja, laat het verzoek door."

Tim zegt: "Denk aan de middleware als een beveiliger bij de deur. Elke bezoeker wordt gecheckt voordat ze naar binnen mogen."

Tim zegt: "En dan is er nog de auth callback route. Als een gebruiker een magic link aanklikt, of terugkomt van een OAuth provider, komt die terecht op een speciale URL in je app. Die route wisselt de code om voor een sessie. Dat is een technisch detail, maar het is een bestand dat je nodig hebt."


📊 Slide 9 — Row Level Security (RLS)

Tim zegt: "Het laatste theorieblok: Row Level Security, oftewel RLS. Dit is de autorisatie-kant van het verhaal."

Tim zegt: "RLS is een feature van de database zelf — niet van Next.js, niet van Supabase Auth, maar van PostgreSQL. Het betekent dat je regels kunt instellen op de database die bepalen wie welke rijen mag lezen, aanmaken, updaten of verwijderen."

Tim zegt: "Zonder RLS kan iedereen die de URL van je Supabase project kent alles doen met je data. Dat is een groot beveiligingsrisico. Met RLS zeg je: 'alleen ingelogde gebruikers mogen polls aanmaken' of 'alleen de eigenaar mag een poll verwijderen'."

LIVE DEMO in Supabase Dashboard:

  1. Ga naar Table Editor en klik op de tabel polls
  2. Wijs op het gele waarschuwingsicoontje naast de tabel (RLS is uitgeschakeld)
  3. Klik op RLS disabled (of ga via Authentication > Policies)

Tim zegt: "Kijk hier — Supabase waarschuwt ons: RLS staat uit op deze tabel. Dat betekent dat iedereen alles kan doen. Dat gaan we straks fixen."

Tim zegt: "Een RLS policy is een regel die je schrijft in SQL. Het ziet er zo uit:"

Schrijf op het whiteboard of laat op de slide zien:

CREATE POLICY "Iedereen kan polls lezen"
ON polls FOR SELECT
USING (true);

Tim zegt: "Dit zegt: voor de tabel polls, bij een SELECT query — dus bij het lezen — mag iedereen alles zien. USING (true) betekent: altijd waar, geen beperking."

Tim zegt: "Maar we kunnen ook zeggen: alleen ingelogde gebruikers mogen polls aanmaken:"

CREATE POLICY "Ingelogde gebruikers maken polls"
ON polls FOR INSERT
TO authenticated
WITH CHECK (true);

Tim zegt: "TO authenticated is het belangrijke deel. Dat is een speciale Supabase rol die alleen geldt voor ingelogde gebruikers. Als je niet bent ingelogd, krijg je de anon rol, en die heeft geen toestemming voor INSERT."

Tim zegt: "Er is ook een speciale functie: auth.uid(). Die geeft het ID van de ingelogde gebruiker. Daarmee kun je zeggen: je mag alleen je EIGEN data lezen of bewerken. Dat gaan we vandaag niet gebruiken in onze Poll App, maar het is goed om te weten voor je eindopdracht."

Tim zegt: "Oké, dat was veel theorie! Laten we even pauze houden en dan gaan we het allemaal bouwen."


10:00 10:15 | Pauze (15 min)


📊 Slide 10 — Pauze

Tim zegt: "We houden een kwartier pauze. Om kwart over 10 beginnen we met de hands-on. Zorg dat je laptop klaar staat met je project geopend in VS Code."


BLOK 2 — Hands-on (10:15 11:45)


10:15 10:30 | Hands-on: Auth Opzetten in Supabase (15 min)


📊 Slide 11 — Hands-on: Auth Opzetten in Supabase

Deze slide blijft zichtbaar terwijl studenten werken. Alle stappen staan erop.

Tim zegt: "Oké, we gaan beginnen! Ik doe het voor op het scherm en jullie doen mee. We beginnen met het installeren van de packages en het aanmaken van de bestanden."

Tim zegt: "Open je terminal in VS Code — je weet hoe dat moet: Ctrl+backtick of via het menu. Zorg dat je in de root van je project staat."


Stap 1: Installeer de packages

Tim zegt: "Allereerst installeren we twee packages. Type het volgende commando:"

npm install @supabase/ssr @supabase/supabase-js

Tim zegt: "Even wachten tot het klaar is... @supabase/ssr is de package die we nodig hebben voor authenticatie in Next.js. @supabase/supabase-js hebben jullie misschien al — dat is de standaard Supabase client. Als die al geinstalleerd is, wordt hij geüpdatet."

Wacht tot iedereen klaar is. Loop rond en check of er errors zijn.


Stap 2: Controleer je .env.local

Tim zegt: "Controleer even of je .env.local bestand de juiste variabelen bevat. Dit zou er al moeten staan van Les 8:"

NEXT_PUBLIC_SUPABASE_URL=https://jouw-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=jouw-anon-key

Tim zegt: "Als je deze niet hebt, ga naar je Supabase dashboard, klik op Settings, dan API, en kopieer de URL en de anon key."


Stap 3: Maak de mappenstructuur aan

Tim zegt: "Nu gaan we de bestanden aanmaken. Maak eerst de map aan als die nog niet bestaat:"

mkdir -p src/lib/supabase

Stap 4: Browser Client (src/lib/supabase/client.ts)

Tim zegt: "Het eerste bestand is de browser client. Maak een nieuw bestand aan: src/lib/supabase/client.ts. Dit is de simpelste van de drie."

import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

Tim zegt: "Dit is een simpele functie die een Supabase client aanmaakt voor de browser. De createBrowserClient functie uit @supabase/ssr regelt automatisch dat cookies goed worden gelezen en geschreven in de browser. Die uitroeptekens achter process.env zijn TypeScript — ze zeggen: ik weet zeker dat deze waarde bestaat."


Stap 5: Server Client (src/lib/supabase/server.ts)

Tim zegt: "Nu het server bestand. Maak src/lib/supabase/server.ts aan. Dit is iets complexer, maar jullie hoeven het niet uit je hoofd te kennen — je kunt het altijd kopiëren."

import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options)
          )
        },
      },
    }
  )
}

Tim zegt: "Dit bestand is voor de server-kant van Next.js. Het gebruikt cookies() van next/headers — dat is een Next.js functie die je toegang geeft tot de cookies op de server. Merk op dat de functie async is — cookies() is asynchroon in Next.js 16."

Tim zegt: "Het cookies object dat we meegeven aan createServerClient vertelt Supabase hoe het cookies moet lezen met getAll() en schrijven met setAll(). Supabase gebruikt dit om de sessie bij te houden."


Stap 6: Middleware (src/middleware.ts)

Tim zegt: "Nu het belangrijkste bestand: de middleware. Maak src/middleware.ts aan — let op, dit bestand staat in de src map, NIET in src/lib/supabase. Het is een speciaal Next.js bestand."

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          )
          supabaseResponse = NextResponse.next({
            request,
          })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  // Belangrijk: haal de user op om de sessie te vernieuwen
  const {
    data: { user },
  } = await supabase.auth.getUser()

  // Als er geen user is en we zijn niet op de login pagina, redirect naar login
  if (
    !user &&
    !request.nextUrl.pathname.startsWith('/login') &&
    !request.nextUrl.pathname.startsWith('/auth')
  ) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

  return supabaseResponse
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|login|auth).*)',
  ],
}

Tim zegt: "Dit is het langste bestand, maar het is heel logisch als je het stap voor stap bekijkt."

Tim zegt: "Bovenaan maken we een Supabase client aan, net als bij de server client, maar dan met de cookies van het request object. Dan halen we de user op met getUser(). Dit doet twee dingen: het controleert of er een geldige sessie is, en het vernieuwt de sessie als die bijna verlopen is."

Tim zegt: "Dan de if-statement: als er geen user is EN we zijn niet al op de login pagina, dan redirecten we naar /login. Simpel."

Tim zegt: "Onderaan staat de matcher config. Die bepaalt voor welke URLs de middleware draait. We sluiten statische bestanden uit (_next/static, _next/image, favicon.ico) en de login en auth pagina's zelf — anders zou je nooit op de login pagina kunnen komen!"

Loop rond en help studenten die vastlopen. Veel voorkomende problemen:

  • Bestand op de verkeerde plek (middleware.ts moet in src/, niet in src/app/)
  • Typfouten in de imports
  • Missende .env.local variabelen

Tim zegt: "Is iedereen zover? Vier bestanden aangemaakt? Als je ergens vastloopt, steek je hand op. We gaan zo door met de login pagina."


10:30 10:55 | Hands-on: Login & Registratie Bouwen (25 min)


📊 Slide 12 — Hands-on: Login & Registratie Bouwen

Deze slide blijft zichtbaar met alle stappen.

Tim zegt: "Nu gaan we de login pagina bouwen. We maken een pagina waar gebruikers kunnen inloggen met email en wachtwoord, of zich kunnen registreren."


Stap 1: Auth Callback Route aanmaken

Tim zegt: "Eerst hebben we de auth callback route nodig. Die is nodig voor magic links. Maak de volgende map en bestand aan:"

mkdir -p src/app/auth/callback

Maak src/app/auth/callback/route.ts aan:

import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')

  if (code) {
    const supabase = await createClient()
    await supabase.auth.exchangeCodeForSession(code)
  }

  return NextResponse.redirect(origin)
}

Tim zegt: "Dit is een API route — een route die geen pagina toont, maar een verzoek afhandelt. Als een gebruiker op een magic link klikt, stuurt Supabase ze naar /auth/callback?code=abc123. Deze route pakt die code, wisselt het om voor een sessie, en redirect de gebruiker naar de homepage."


Stap 2: Login pagina aanmaken

Tim zegt: "Nu de login pagina zelf. Maak de map en het bestand aan:"

mkdir -p src/app/login

Maak src/app/login/page.tsx aan:

'use client'

import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'

export default function LoginPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [loading, setLoading] = useState(false)
  const [message, setMessage] = useState('')
  const [isSignUp, setIsSignUp] = useState(false)
  const router = useRouter()
  const supabase = createClient()

  const handleEmailLogin = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)
    setMessage('')

    if (isSignUp) {
      const { error } = await supabase.auth.signUp({
        email,
        password,
      })
      if (error) {
        setMessage(error.message)
      } else {
        setMessage('Check je email voor een bevestigingslink!')
      }
    } else {
      const { error } = await supabase.auth.signInWithPassword({
        email,
        password,
      })
      if (error) {
        setMessage(error.message)
      } else {
        router.push('/')
        router.refresh()
      }
    }

    setLoading(false)
  }

  const handleMagicLink = async () => {
    if (!email) {
      setMessage('Vul eerst je email in')
      return
    }
    setLoading(true)
    setMessage('')

    const { error } = await supabase.auth.signInWithOtp({
      email,
    })

    if (error) {
      setMessage(error.message)
    } else {
      setMessage('Check je email voor een magic link!')
    }

    setLoading(false)
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
        <div>
          <h2 className="text-center text-3xl font-bold text-gray-900">
            {isSignUp ? 'Account aanmaken' : 'Inloggen'}
          </h2>
          <p className="mt-2 text-center text-sm text-gray-600">
            Poll App
          </p>
        </div>

        <form onSubmit={handleEmailLogin} className="mt-8 space-y-6">
          <div className="space-y-4">
            <div>
              <label htmlFor="email" className="block text-sm font-medium text-gray-700">
                Email
              </label>
              <input
                id="email"
                type="email"
                required
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
                placeholder="jouw@email.nl"
              />
            </div>

            <div>
              <label htmlFor="password" className="block text-sm font-medium text-gray-700">
                Wachtwoord
              </label>
              <input
                id="password"
                type="password"
                required
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
                placeholder="Minimaal 6 tekens"
              />
            </div>
          </div>

          {message && (
            <div className="text-sm text-center text-red-600">
              {message}
            </div>
          )}

          <div className="space-y-3">
            <button
              type="submit"
              disabled={loading}
              className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
            >
              {loading
                ? 'Laden...'
                : isSignUp
                ? 'Registreren'
                : 'Inloggen'}
            </button>

            <button
              type="button"
              onClick={handleMagicLink}
              disabled={loading}
              className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
            >
              Stuur Magic Link
            </button>
          </div>
        </form>

        <div className="text-center">
          <button
            onClick={() => setIsSignUp(!isSignUp)}
            className="text-sm text-blue-600 hover:text-blue-500"
          >
            {isSignUp
              ? 'Heb je al een account? Inloggen'
              : 'Nog geen account? Registreren'}
          </button>
        </div>
      </div>
    </div>
  )
}

Tim zegt: "Dit is een best groot bestand, maar het is eigenlijk vrij simpel. Laten we het doorlopen:"

Tim zegt: "Bovenaan staan onze state variabelen: email, password, loading, message, en of we in sign-up of login modus zitten."

Tim zegt: "De handleEmailLogin functie doet twee dingen: als isSignUp true is, roepen we supabase.auth.signUp() aan. Anders roepen we supabase.auth.signInWithPassword() aan. Bij succes redirecten we naar de homepage."

Tim zegt: "De handleMagicLink functie roept supabase.auth.signInWithOtp() aan — die stuurt een magic link naar het email adres."

Tim zegt: "De rest is gewoon een formulier met Tailwind styling. Twee input velden, twee buttons, en een toggle om te wisselen tussen inloggen en registreren."

Loop rond terwijl studenten het overtikken of kopiëren.


Stap 3: Email bevestiging uitschakelen (voor ontwikkeling)

Tim zegt: "Een belangrijk ding! Standaard stuurt Supabase een bevestigingsmail als je je registreert. Dat is goed voor productie, maar vervelend voor development. We gaan dat even uitschakelen."

LIVE DEMO in Supabase Dashboard:

  1. Ga naar Authentication > Providers > Email
  2. Schakel Confirm email uit (toggle)
  3. Klik op Save

Tim zegt: "Nu kunnen jullie je registreren zonder een email te hoeven bevestigen. In een echte app laat je dit aan staan!"


Stap 4: Testen

Tim zegt: "Start je dev server als die nog niet draait:"

npm run dev

Tim zegt: "Ga naar http://localhost:3000. Je zou nu automatisch naar /login gestuurd moeten worden — dat is de middleware die je redirect! Registreer een account met je eigen email en een wachtwoord. Gebruik minimaal 6 tekens voor het wachtwoord."

Doe de registratie zelf LIVE voor op het scherm.

Tim zegt: "Als het goed is, ben je nu ingelogd en zie je je Poll App. Ga terug naar het Supabase dashboard, naar Authentication > Users, en je ziet je nieuwe gebruiker staan!"

Laat in het dashboard de nieuwe user zien.

Voorkomende problemen:

  • "Invalid login credentials" — wachtwoord te kort (min 6 tekens)
  • "User already registered" — gebruik een ander emailadres
  • Redirect loop — middleware config klopt niet, check de matcher
  • 404 op /login — bestand op verkeerde plek

10:55 11:10 | Hands-on: Sessie & Beschermde Routes (15 min)


📊 Slide 13 — Hands-on: Sessie & Beschermde Routes

Slide blijft zichtbaar met alle stappen.

Tim zegt: "We zijn ingelogd! Maar de gebruiker weet dat niet — er is niets in de UI dat laat zien dat je bent ingelogd. Laten we dat toevoegen: een welkomstbericht met het emailadres en een uitlog-knop."


Stap 1: Navbar component aanmaken

Tim zegt: "We gaan een simpele navbar maken die de gebruikersinformatie laat zien. Maak een nieuw bestand aan:"

mkdir -p src/components

Maak src/components/Navbar.tsx aan:

'use client'

import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import type { User } from '@supabase/supabase-js'

export default function Navbar() {
  const [user, setUser] = useState<User | null>(null)
  const router = useRouter()
  const supabase = createClient()

  useEffect(() => {
    const getUser = async () => {
      const { data: { user } } = await supabase.auth.getUser()
      setUser(user)
    }
    getUser()
  }, [])

  const handleSignOut = async () => {
    await supabase.auth.signOut()
    router.push('/login')
    router.refresh()
  }

  return (
    <nav className="bg-white shadow-sm border-b">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div className="flex justify-between h-16 items-center">
          <div className="flex-shrink-0">
            <h1 className="text-xl font-bold text-gray-900">
              Poll App
            </h1>
          </div>
          {user && (
            <div className="flex items-center gap-4">
              <span className="text-sm text-gray-600">
                {user.email}
              </span>
              <button
                onClick={handleSignOut}
                className="text-sm text-red-600 hover:text-red-800 font-medium"
              >
                Uitloggen
              </button>
            </div>
          )}
        </div>
      </div>
    </nav>
  )
}

Tim zegt: "Dit is een Client Component — het draait in de browser. We gebruiken useEffect om de ingelogde user op te halen als de component laadt. Dan tonen we het emailadres en een uitlog-knop."

Tim zegt: "De handleSignOut functie roept supabase.auth.signOut() aan en stuurt de gebruiker terug naar de login pagina."


Stap 2: Navbar toevoegen aan de layout

Tim zegt: "Nu moeten we de Navbar toevoegen aan onze app. Open src/app/layout.tsx en importeer de Navbar component."

Pas src/app/layout.tsx aan:

import Navbar from '@/components/Navbar'

En voeg de Navbar toe aan de body, boven {children}:

<body>
  <Navbar />
  <main>{children}</main>
</body>

Tim zegt: "Let op: als je al een layout hebt met wat Tailwind classes, pas dan alleen de Navbar import en het component toe. Verwijder niet je bestaande styling."

Tim zegt: "We willen de Navbar niet tonen op de login pagina. Er zijn meerdere manieren om dat op te lossen, maar de simpelste is: de Navbar checkt zelf of er een user is. Als er geen user is, toont hij niets. En dat doen we al — we tonen de user info alleen als user niet null is."


Stap 3: Testen

Tim zegt: "Ga naar je browser en refresh de pagina. Je zou nu bovenaan je emailadres moeten zien en een uitlog-knop."

Doe het LIVE voor:

  1. Laat de pagina zien met Navbar
  2. Klik op "Uitloggen"
  3. Laat zien dat je naar de login pagina wordt gestuurd
  4. Probeer naar http://localhost:3000 te gaan — je wordt terug gestuurd naar login
  5. Log opnieuw in

Tim zegt: "Kijk, de middleware beschermt alles! Als je niet bent ingelogd, kun je niet bij de app. Dat is beschermde routes. Probeer het zelf: log uit en probeer direct naar localhost:3000 te navigeren. Je wordt automatisch naar de login gestuurd."

Loop rond en help studenten. Voorkomende problemen:

  • Navbar verschijnt niet — check de import en de layout.tsx
  • Witte pagina — check de browser console voor errors
  • User email toont niet — getUser() kan even duren, daardoor kort null

11:10 11:25 | Hands-on: Basis RLS (15 min)


📊 Slide 14 — Hands-on: Basis RLS

Slide blijft zichtbaar met alle stappen.

Tim zegt: "De laatste stap vandaag: Row Level Security. We gaan ervoor zorgen dat onze database beveiligd is. Op dit moment kan iedereen met de anon key alles doen met onze data. Dat gaat nu veranderen."


Stap 1: RLS inschakelen

Tim zegt: "We gaan dit doen in het Supabase dashboard. Ga naar de SQL Editor — dat is het icoon met het database-symbool in de linkerzijbalk."

LIVE DEMO in Supabase Dashboard:

  1. Ga naar SQL Editor
  2. Klik op New query

Tim zegt: "We gaan eerst RLS inschakelen op beide tabellen. Type het volgende SQL:"

-- Stap 1: RLS inschakelen op polls tabel
ALTER TABLE polls ENABLE ROW LEVEL SECURITY;

-- Stap 2: RLS inschakelen op options tabel
ALTER TABLE options ENABLE ROW LEVEL SECURITY;

Tim zegt: "Klik op Run. Als het goed is, zie je 'Success' zonder errors."

Voer de query LIVE uit.

Tim zegt: "Nu is er iets belangrijks: als je nu naar je app gaat en probeert polls te laden, zul je NIETS zien. Dat komt omdat RLS standaard alles blokkeert. Als er geen policies zijn die iets toestaan, mag niemand iets. Probeer het maar even — refresh je app."

Laat zien dat de polls verdwenen zijn.

Tim zegt: "Zie je? Geen polls meer! Dat is RLS in actie. We moeten nu policies aanmaken die bepalen wie wat mag."


Stap 2: Policies aanmaken voor polls

Tim zegt: "Maak een nieuwe query aan en type het volgende:"

-- Iedereen kan polls lezen (ook niet-ingelogde gebruikers)
CREATE POLICY "Polls are viewable by everyone"
ON polls FOR SELECT
USING (true);

-- Alleen ingelogde gebruikers kunnen polls aanmaken
CREATE POLICY "Authenticated users can create polls"
ON polls FOR INSERT
TO authenticated
WITH CHECK (true);

Tim zegt: "De eerste policy zegt: voor SELECT queries op de polls tabel, mag iedereen alles zien. USING (true) betekent: geen beperking."

Tim zegt: "De tweede policy zegt: voor INSERT queries, alleen de authenticated rol mag dat. TO authenticated is de sleutel — dat is de rol die je krijgt als je bent ingelogd via Supabase Auth. Als je niet bent ingelogd, ben je anon, en dan mag je geen polls aanmaken."

Voer de query LIVE uit.


Stap 3: Policies aanmaken voor options

Tim zegt: "Nu hetzelfde voor de options tabel:"

-- Iedereen kan options lezen
CREATE POLICY "Options are viewable by everyone"
ON options FOR SELECT
USING (true);

-- Ingelogde gebruikers kunnen stemmen (update)
CREATE POLICY "Authenticated users can vote"
ON options FOR UPDATE
TO authenticated
USING (true);

Voer de query LIVE uit.

Tim zegt: "Nu kan iedereen polls en opties zien, maar alleen ingelogde gebruikers kunnen nieuwe polls aanmaken en stemmen."


Stap 4: Optioneel — INSERT policy voor options

Tim zegt: "We hebben ook een INSERT policy nodig voor options, anders kunnen we geen nieuwe opties aanmaken bij een poll:"

-- Ingelogde gebruikers kunnen opties aanmaken
CREATE POLICY "Authenticated users can create options"
ON options FOR INSERT
TO authenticated
WITH CHECK (true);

Voer de query LIVE uit.


Stap 5: Testen

Tim zegt: "Ga terug naar je app en refresh. Je polls zouden er nu weer moeten zijn!"

Doe het LIVE voor:

  1. Refresh de app — polls verschijnen weer
  2. Maak een nieuwe poll aan — werkt!
  3. Stem op een optie — werkt!

Tim zegt: "Mooi! Alles werkt weer, maar nu met beveiliging. Laten we even testen wat er gebeurt als je niet bent ingelogd."

LIVE DEMO — RLS testen:

  1. Ga naar het Supabase dashboard
  2. Ga naar Table Editor > polls
  3. Wijs op het groene vinkje bij de tabel (RLS is nu ingeschakeld)
  4. Ga naar Authentication > Policies — laat alle policies zien

Tim zegt: "Hier in het dashboard kun je al je policies zien en beheren. Je kunt ze hier ook bewerken of verwijderen als dat nodig is."

Tim zegt: "Een belangrijk punt: de policies die we nu hebben zijn vrij simpel. In je eindopdracht kun je veel specifiekere policies schrijven. Bijvoorbeeld: een gebruiker mag alleen zijn eigen data bewerken. Dan gebruik je auth.uid() in je policy. Dat ziet er dan zo uit:"

-- Voorbeeld voor de eindopdracht (niet nodig voor poll app):
CREATE POLICY "Users can only edit own posts"
ON posts FOR UPDATE
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);

Tim zegt: "Hier zegt auth.uid() = user_id dat de ingelogde gebruiker alleen rijen mag updaten waar het user_id veld gelijk is aan zijn eigen gebruikers-ID. Super krachtig!"


Stap 6: Policies bekijken in het dashboard

Tim zegt: "Laat me even laten zien hoe je alle policies kunt bekijken."

LIVE DEMO:

  1. Ga naar Authentication > Policies
  2. Laat de lijst van policies zien per tabel
  3. Klik op een policy om de details te zien

Tim zegt: "Hier zie je per tabel welke policies er zijn. Je kunt ze ook via dit dashboard aanmaken door op 'New Policy' te klikken. Er zijn templates beschikbaar die het makkelijker maken. Maar voor nu is de SQL Editor prima."


11:25 11:45 | Doorwerken (20 min)


Tim zegt: "Oké, de rest van de tijd is om alles af te maken en te testen. Veel van jullie zullen nog bezig zijn met het overnemen van de code. Neem de tijd en zorg dat alles werkt. Ik loop rond om te helpen."

Laat slides 11-14 op het scherm staan zodat studenten kunnen terugkijken.

Checklist voor studenten (noem op):

  1. Zijn alle bestanden aangemaakt?
    • src/lib/supabase/client.ts
    • src/lib/supabase/server.ts
    • src/middleware.ts
    • src/app/auth/callback/route.ts
    • src/app/login/page.tsx
    • src/components/Navbar.tsx
  2. Kun je registreren met email + wachtwoord?
  3. Kun je inloggen?
  4. Zie je je emailadres in de Navbar?
  5. Werkt uitloggen?
  6. Word je geredirect naar login als je niet bent ingelogd?
  7. Kun je nog steeds polls zien en aanmaken?
  8. Is RLS ingeschakeld op beide tabellen?

Tim zegt: "Als je alles werkend hebt en nog tijd over hebt, probeer dan het volgende: log uit en open een incognito venster. Ga naar je app. Kun je polls ZIEN? Kun je polls AANMAKEN? Wat verwacht je dat er gebeurt?"

Het verwachte resultaat: je wordt geredirect naar de login pagina door de middleware, dus je kunt niets doen. Als je de middleware even uitzet en direct de API probeert, zou SELECT werken maar INSERT niet dankzij RLS.

Loop rond en help actief. Veel voorkomende problemen:

  • Studenten die de middleware op de verkeerde plek hebben gezet
  • Typfouten in de import paden (bijv. @/lib/supabase/client vs @/lib/supabase/server)
  • Vergeten om email bevestiging uit te schakelen in het dashboard
  • Wachtwoord te kort bij registratie
  • RLS ingeschakeld maar geen policies — dan werkt niets

BLOK 3 — Afsluiting (11:45 12:00)


11:45 12:00 | Samenvatting + Huiswerk (15 min)


📊 Slide 15 — Samenvatting + Huiswerk

Tim zegt: "Laten we even samenvatten wat we vandaag geleerd hebben."

Tim zegt: "We hebben het gehad over het verschil tussen authenticatie — wie ben je — en autorisatie — wat mag je. We hebben geleerd dat Supabase een ingebouwd authenticatiesysteem heeft met drie methodes: email + wachtwoord, magic links, en social login."

Tim zegt: "We hebben @supabase/ssr geinstalleerd en drie belangrijke bestanden aangemaakt: de browser client, de server client, en de middleware. De middleware is de bewaker die bij elk verzoek checkt of je bent ingelogd."

Tim zegt: "We hebben een login pagina gebouwd met registratie, inloggen, en magic link functionaliteit. We hebben een Navbar toegevoegd die het emailadres toont en een uitlog-knop."

Tim zegt: "En als laatste hebben we Row Level Security ingeschakeld. We hebben policies geschreven die bepalen: iedereen mag lezen, maar alleen ingelogde gebruikers mogen aanmaken en bewerken."

Tim zegt: "Dit zijn de bouwstenen die je nodig hebt voor je eindopdracht. Elke echte app heeft gebruikers en beveiliging nodig."


Huiswerk

Tim zegt: "Voor het huiswerk:"

Benoem de volgende punten:

  1. Zorg dat je Poll App volledig werkt met authenticatie en RLS. Alles wat we vandaag gedaan hebben moet werken: registreren, inloggen, uitloggen, beschermde routes, en RLS.

  2. Voeg een user_id kolom toe aan de polls tabel. Ga naar de Table Editor in Supabase, voeg een kolom toe van type uuid, en koppel die aan auth.users(id). Als je dan een poll aanmaakt, sla je op wie de poll heeft gemaakt. Pas daarna je INSERT code aan om user_id mee te sturen.

  3. Begin na te denken over je eindopdracht. Wat voor app wil je bouwen? Welke tabellen heb je nodig? Welke pagina's? Schrijf een korte beschrijving van je idee — maximaal een half A4.

Tim zegt: "Punt 2 is een uitdaging, maar het is een goede oefening. Je moet de tabel aanpassen in Supabase, en je code aanpassen in Next.js. Tip: gebruik auth.uid() in je RLS policy om ervoor te zorgen dat gebruikers alleen hun eigen polls kunnen bewerken."

Tim zegt: "En punt 3: begin echt na te denken over je eindopdracht. Volgende les gaan we het er uitgebreider over hebben en kun je je idee pitchen."


Vragen

Tim zegt: "Zijn er nog vragen over vandaag?"

Neem de tijd voor vragen. Veel voorkomende vragen:

V: Moet ik magic link ook werkend hebben voor de eindopdracht?

Tim zegt: "Nee, email + wachtwoord is voldoende. Magic link is een extra optie die handig is, maar niet verplicht."

V: Hoe werkt auth.uid() precies?

Tim zegt: "auth.uid() is een functie in PostgreSQL die Supabase toevoegt. Het geeft het UUID terug van de ingelogde gebruiker. Als niemand is ingelogd, geeft het NULL terug. Je kunt het gebruiken in RLS policies om te checken of een rij van de ingelogde gebruiker is."

V: Kan ik Google login gebruiken voor mijn eindopdracht?

Tim zegt: "Ja, dat kan! Maar het is iets complexer om op te zetten. Je moet een project aanmaken in de Google Cloud Console en de credentials toevoegen in Supabase. Als je dat wilt doen, help ik je er graag mee buiten de les."

V: Wat als ik vergeet RLS in te schakelen?

Tim zegt: "Dan is je data niet beveiligd. Iedereen met je Supabase URL en anon key kan al je data lezen, aanmaken, verwijderen... alles. In je eindopdracht is RLS verplicht."


Tim zegt: "Goed werk vandaag allemaal! Jullie hebben in drie uur een compleet authenticatiesysteem gebouwd. Dat is niet niks. Volgende les gaan we verder met de eindopdracht-planning. Zorg dat je huiswerk af is en neem je eindopdracht-idee mee. Fijne dag!"


Referentie: Alle Code in Overzicht

Onderstaande code is een compleet overzicht van alle bestanden die in deze les worden aangemaakt. Gebruik dit als naslagwerk of als studenten achterop raken.


Bestand 1: src/lib/supabase/client.ts

import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

Bestand 2: src/lib/supabase/server.ts

import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options)
          )
        },
      },
    }
  )
}

Bestand 3: src/middleware.ts

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          )
          supabaseResponse = NextResponse.next({
            request,
          })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  const {
    data: { user },
  } = await supabase.auth.getUser()

  if (
    !user &&
    !request.nextUrl.pathname.startsWith('/login') &&
    !request.nextUrl.pathname.startsWith('/auth')
  ) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

  return supabaseResponse
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|login|auth).*)',
  ],
}

Bestand 4: src/app/auth/callback/route.ts

import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')

  if (code) {
    const supabase = await createClient()
    await supabase.auth.exchangeCodeForSession(code)
  }

  return NextResponse.redirect(origin)
}

Bestand 5: src/app/login/page.tsx

'use client'

import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'

export default function LoginPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [loading, setLoading] = useState(false)
  const [message, setMessage] = useState('')
  const [isSignUp, setIsSignUp] = useState(false)
  const router = useRouter()
  const supabase = createClient()

  const handleEmailLogin = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)
    setMessage('')

    if (isSignUp) {
      const { error } = await supabase.auth.signUp({
        email,
        password,
      })
      if (error) {
        setMessage(error.message)
      } else {
        setMessage('Check je email voor een bevestigingslink!')
      }
    } else {
      const { error } = await supabase.auth.signInWithPassword({
        email,
        password,
      })
      if (error) {
        setMessage(error.message)
      } else {
        router.push('/')
        router.refresh()
      }
    }

    setLoading(false)
  }

  const handleMagicLink = async () => {
    if (!email) {
      setMessage('Vul eerst je email in')
      return
    }
    setLoading(true)
    setMessage('')

    const { error } = await supabase.auth.signInWithOtp({
      email,
    })

    if (error) {
      setMessage(error.message)
    } else {
      setMessage('Check je email voor een magic link!')
    }

    setLoading(false)
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
        <div>
          <h2 className="text-center text-3xl font-bold text-gray-900">
            {isSignUp ? 'Account aanmaken' : 'Inloggen'}
          </h2>
          <p className="mt-2 text-center text-sm text-gray-600">
            Poll App
          </p>
        </div>

        <form onSubmit={handleEmailLogin} className="mt-8 space-y-6">
          <div className="space-y-4">
            <div>
              <label htmlFor="email" className="block text-sm font-medium text-gray-700">
                Email
              </label>
              <input
                id="email"
                type="email"
                required
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
                placeholder="jouw@email.nl"
              />
            </div>

            <div>
              <label htmlFor="password" className="block text-sm font-medium text-gray-700">
                Wachtwoord
              </label>
              <input
                id="password"
                type="password"
                required
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
                placeholder="Minimaal 6 tekens"
              />
            </div>
          </div>

          {message && (
            <div className="text-sm text-center text-red-600">
              {message}
            </div>
          )}

          <div className="space-y-3">
            <button
              type="submit"
              disabled={loading}
              className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
            >
              {loading
                ? 'Laden...'
                : isSignUp
                ? 'Registreren'
                : 'Inloggen'}
            </button>

            <button
              type="button"
              onClick={handleMagicLink}
              disabled={loading}
              className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
            >
              Stuur Magic Link
            </button>
          </div>
        </form>

        <div className="text-center">
          <button
            onClick={() => setIsSignUp(!isSignUp)}
            className="text-sm text-blue-600 hover:text-blue-500"
          >
            {isSignUp
              ? 'Heb je al een account? Inloggen'
              : 'Nog geen account? Registreren'}
          </button>
        </div>
      </div>
    </div>
  )
}

Bestand 6: src/components/Navbar.tsx

'use client'

import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import type { User } from '@supabase/supabase-js'

export default function Navbar() {
  const [user, setUser] = useState<User | null>(null)
  const router = useRouter()
  const supabase = createClient()

  useEffect(() => {
    const getUser = async () => {
      const { data: { user } } = await supabase.auth.getUser()
      setUser(user)
    }
    getUser()
  }, [])

  const handleSignOut = async () => {
    await supabase.auth.signOut()
    router.push('/login')
    router.refresh()
  }

  return (
    <nav className="bg-white shadow-sm border-b">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div className="flex justify-between h-16 items-center">
          <div className="flex-shrink-0">
            <h1 className="text-xl font-bold text-gray-900">
              Poll App
            </h1>
          </div>
          {user && (
            <div className="flex items-center gap-4">
              <span className="text-sm text-gray-600">
                {user.email}
              </span>
              <button
                onClick={handleSignOut}
                className="text-sm text-red-600 hover:text-red-800 font-medium"
              >
                Uitloggen
              </button>
            </div>
          )}
        </div>
      </div>
    </nav>
  )
}

SQL: RLS Policies

-- RLS inschakelen
ALTER TABLE polls ENABLE ROW LEVEL SECURITY;
ALTER TABLE options ENABLE ROW LEVEL SECURITY;

-- Polls policies
CREATE POLICY "Polls are viewable by everyone"
ON polls FOR SELECT
USING (true);

CREATE POLICY "Authenticated users can create polls"
ON polls FOR INSERT
TO authenticated
WITH CHECK (true);

-- Options policies
CREATE POLICY "Options are viewable by everyone"
ON options FOR SELECT
USING (true);

CREATE POLICY "Authenticated users can create options"
ON options FOR INSERT
TO authenticated
WITH CHECK (true);

CREATE POLICY "Authenticated users can vote"
ON options FOR UPDATE
TO authenticated
USING (true);

Notities voor de Docent

Voorbereiding voor de les

  • Test of het Supabase project bereikbaar is
  • Zorg dat je eigen Poll App werkend is met auth (als demo)
  • Zet een fallback account klaar in het dashboard voor als live registratie niet lukt
  • Check of je de Email provider aan hebt staan in het dashboard
  • Schakel email bevestiging alvast uit voor de demo

Veel voorkomende problemen

Probleem Oorzaak Oplossing
Redirect loop op /login Middleware matched ook de login route Check de matcher config in middleware.ts
"Invalid login credentials" Wachtwoord te kort of verkeerd Minimaal 6 tekens, probeer opnieuw
"User already registered" Email al in gebruik Gebruik een ander emailadres of log in
Polls verdwenen na RLS RLS aan, maar geen policies Voeg de SELECT policies toe
404 op /login Bestand op verkeerde plek Moet in src/app/login/page.tsx
Middleware werkt niet Bestand op verkeerde plek Moet in src/middleware.ts (niet in src/app/)
Cookie errors in terminal cookies() niet ge-awaited Zorg dat createClient() in server.ts async is
Magic link komt niet aan Supabase rate limit of email config Gebruik email+wachtwoord als fallback

Tips voor het rondlopen

  • Begin bij studenten die er stil bij zitten — zij zitten vaak vast
  • Check altijd eerst of de bestanden op de juiste plek staan
  • Veel errors komen door typfouten in import paden
  • Als een student helemaal vast zit: laat ze de code uit de referentie-sectie kopiëren
  • Moedig het gebruik van AI-tools aan (ChatGPT, Copilot) om errors op te lossen

Verbinding met de eindopdracht

Benoem regelmatig de verbinding met de eindopdracht:

  • "Dit heb je ook nodig in je eindopdracht"
  • "In je eigen app kun je dit uitbreiden met..."
  • "RLS is verplicht voor je eindopdracht"
  • "Denk alvast na over welke tabellen JIJ nodig hebt en welke policies daarbij horen"