Files
novi-lessons/Les08-Van-InMemory-naar-Supabase/Les08-Lesopdracht.md
2026-06-07 10:44:05 +02:00

5.4 KiB

Les 8 — Lesopdracht

CRUD + voting werkend met Supabase

Duur: ~120 minuten klassikaal


Doel

Aan het einde: QuickPoll volledig werkend tegen Supabase. Polls aanmaken via /create, stemmen werkt persistent, alles in database.


Stap 1 — /create pagina

app/create/page.tsx:

import { createPoll } from "./actions";

export default function CreatePage() {
  return (
    <main className="max-w-md mx-auto p-8">
      <h1 className="text-2xl font-bold mb-4">Nieuwe poll</h1>
      <form action={createPoll} className="space-y-4">
        <input
          name="title"
          required
          placeholder="Vraag"
          className="w-full border p-2 rounded"
        />
        <textarea
          name="options"
          required
          placeholder="Opties, één per regel"
          rows={4}
          className="w-full border p-2 rounded"
        />
        <button className="bg-blue-600 text-white px-4 py-2 rounded">
          Aanmaken
        </button>
      </form>
    </main>
  );
}

Stap 2 — Create action

app/create/actions.ts:

"use server";
import { supabase } from "@/lib/supabase";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";

export async function createPoll(formData: FormData) {
  const title = formData.get("title") as string;
  const optionsText = formData.get("options") as string;
  const options = optionsText.split("\n").map(t => t.trim()).filter(Boolean);

  if (!title || options.length < 2) {
    throw new Error("Vul titel + min 2 opties in");
  }

  // Insert poll
  const { data: poll, error: pollErr } = await supabase
    .from("polls")
    .insert({ title })
    .select()
    .single();
  if (pollErr) throw new Error(pollErr.message);

  // Insert opties
  const { error: optErr } = await supabase
    .from("options")
    .insert(options.map(text => ({ poll_id: poll.id, text })));
  if (optErr) throw new Error(optErr.message);

  revalidatePath("/");
  redirect(`/poll/${poll.id}`);
}

Stap 3 — RPC function voor voting

In Supabase SQL Editor:

create or replace function increment_vote(option_id_input bigint)
returns void as $$
  update options
  set votes = votes + 1
  where id = option_id_input;
$$ language sql;

Test:

select increment_vote(1);
select * from options where id = 1;
-- votes moet +1 zijn

Stap 4 — Vote action + VoteForm

app/poll/[id]/actions.ts:

"use server";
import { supabase } from "@/lib/supabase";
import { revalidatePath } from "next/cache";

export async function vote(pollId: number, optionId: number) {
  const { error } = await supabase.rpc("increment_vote", {
    option_id_input: optionId,
  });
  if (error) throw new Error(error.message);

  revalidatePath(`/poll/${pollId}`);
}

components/VoteForm.tsx:

"use client";
import { vote } from "@/app/poll/[id]/actions";
import { useFormStatus } from "react-dom";

function VoteButton({ option, pollId }) {
  const { pending } = useFormStatus();
  return (
    <form action={vote.bind(null, pollId, option.id)}>
      <button
        disabled={pending}
        className="w-full text-left p-3 border rounded hover:bg-gray-50 disabled:opacity-50 flex justify-between"
      >
        <span>{option.text}</span>
        <span className="text-gray-500">{option.votes}</span>
      </button>
    </form>
  );
}

export function VoteForm({ poll }) {
  return (
    <div className="space-y-2">
      {poll.options.map(o => (
        <VoteButton key={o.id} option={o} pollId={poll.id} />
      ))}
    </div>
  );
}

Stap 5 — Detail-pagina updaten

// app/poll/[id]/page.tsx
import { supabase } from "@/lib/supabase";
import { notFound } from "next/navigation";
import { VoteForm } from "@/components/VoteForm";

export default async function PollPage({
  params,
}: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const { data: poll } = await supabase
    .from("polls")
    .select("*, options(*)")
    .eq("id", Number(id))
    .single();

  if (!poll) notFound();

  return (
    <main className="max-w-md mx-auto p-8">
      <h1 className="text-2xl font-bold mb-4">{poll.title}</h1>
      <VoteForm poll={poll} />
    </main>
  );
}

Stap 6 — Test

  1. Open /create → maak een nieuwe poll
  2. Wordt geredirect naar /poll/[nieuw-id]
  3. Klik op een optie → vote-count gaat omhoog
  4. Refresh → vote blijft (persistent!)
  5. Open Supabase Table Editor → zie de vote in DB

Eisen

  • Polls aanmaken werkt via /create
  • Stemmen werkt en persistent
  • Server actions met Supabase
  • RPC function increment_vote bestaat
  • Geen errors in console
  • Vote-count update direct na klik (revalidatePath werkt)

Bonus

  • Optimistic UIuseOptimistic voor instant vote-feedback
  • Empty state — als geen polls: mooie CTA naar /create
  • Disable vote per session — sla in localStorage op wie heeft gestemd
  • Total votes — toon onderaan totaal aantal stemmen

Veelvoorkomende problemen

Probleem Oplossing
"function increment_vote does not exist" RPC nog niet aangemaakt — Stap 3
Vote-count update niet revalidatePath ontbreekt of verkeerde path
Form submit doet niets "use server" mist in actions.ts
Foreign key error op options Poll moet eerst inserten, dan opties

Klaar?

Volgende les: Supabase Auth — gebruikers laten inloggen.