EduAnalytics Part 2: Teacher Score Management

Add role-based auth and a real Postgres database to your analytics dashboard.

Introduction

30 Second Summary

Think about how a school app needs different views for different people. A teacher needs to see all student scores, while a student should only see their own results.

In this project, you will build a role-based authentication system on top of your existing EduAnalytics dashboard using Supabase Auth and a real Postgres database.

What You'll Build

A full-stack authentication layer with email/password signup, role selection, proxy-based session management, and a teacher dashboard connected to a live Supabase database.

By the end of this project, you'll have:

  • A Supabase project with a profiles table, a students table, Row Level Security policies, and a trigger that auto-creates profiles on signup.
  • Auth pages for sign-up with role selection (teacher or student), sign-in, and an auth callback route for email verification.
  • A proxy-based session manager that refreshes tokens on every request and routes users to the correct dashboard based on their stored role.
  • A teacher dashboard connected to Supabase that fetches real student data and lets teachers add new scores via Server Actions.
  • Secret Mission: Add inline editing and deletion of student scores from the teacher dashboard.

Are there any prerequisites?

You need to have completed Part 1 (Student Performance Analytics Dashboard) with your student-dashboard project ready to build on. You also need a Supabase account and Node.js v20.17.0 or later installed.

Before We Start

Before We Start...

Set Up Supabase and Install Packages

In Part 1, your dashboard used mock data hardcoded in a local file. To turn this into a real app, you need a database and authentication system that lives in the cloud.

Supabase gives you both in one platform. It provides a hosted Postgres database plus built-in auth. In this step, you will create a Supabase project, install the client packages, and create the utility files your app needs to talk to Supabase from both the browser and the server.

In this step, get ready to:
  • Create a Supabase project and copy your credentials.
  • Install the Supabase client packages and set up environment variables.
  • Create browser and server client utility files.
Create a Supabase project

Every Supabase project comes with its own Postgres database and a unique API URL. You need to create one so your app has somewhere to store users and student data.

  • Open your browser and go to database.new to create a new Supabase project.
  • Enter a project name (e.g. eduanalytics).
  • Set a secure database password and save it somewhere safe.
  • Choose a region close to you.
  • Click Create new project and wait for the database to finish launching. This takes about 1-2 minutes.

Once the project is ready, you need to grab two values: the project URL and the publishable key.

  • Open the project Connect dialog from your project dashboard.
  • Copy the Project URL (it looks like https://<project-ref>.supabase.co).
  • Copy the Publishable key (it starts with sb_publishable_). If you do not see one, navigate to Settings > API Keys, open the API Keys tab, and click Create new API Keys.
  • Keep both values handy. You will paste them into your project in the next substep.

What is a publishable key?

Supabase uses publishable keys for client-side operations. They are safe to expose in your frontend code because Row Level Security protects your data at the database level.

The older anon key still works but is deprecated. Publishable keys (sb_publishable_) are the recommended replacement.

Don't see a publishable key?

Older Supabase projects may not have publishable keys yet. Navigate to Settings > API Keys and click Create new API Keys. This adds the new key type alongside your existing legacy keys.

help me find my Supabase publishable key

Install packages and create environment variables

Your Next.js app needs two packages to communicate with Supabase. The @supabase/supabase-js package is the core client library. The @supabase/ssr package adds cookie-based session handling for server-rendered apps.

  • Switch back to VS Code with your student-dashboard project open.
  • Open the integrated terminal by pressing Ctrl+` (macOS and Windows).
  • Install both Supabase packages by running this command:
npm install @supabase/supabase-js @supabase/ssr

What do these packages do?

@supabase/supabase-js is the main client that handles queries, auth, and real-time subscriptions. @supabase/ssr wraps it with cookie-based session management so your Next.js server components can securely read the user's session.

You should see the packages added to your package.json under dependencies.

Install failed?

Make sure your terminal is in the student-dashboard project root (where package.json lives). If you see permission errors on macOS, try prefixing with sudo.

help me fix my npm install error for Supabase packages

Now store your Supabase credentials as environment variables so your app can access them without hardcoding secrets.

  • Create a new file called .env.local in the root of your student-dashboard project.
  • Paste the following into .env.local, replacing the placeholder values with the URL and key you copied earlier:
NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key

Why are these prefixed with NEXT_PUBLIC?

Next.js only exposes environment variables to the browser if they start with NEXT_PUBLIC_. Both the URL and publishable key are safe to expose client-side because Row Level Security protects your data at the database layer.

  • Save .env.local.
  • Confirm the file exists by checking the VS Code Explorer sidebar. You should see .env.local at the project root alongside package.json.
Create the Supabase client utilities

Your app talks to Supabase from two places: the browser (client components) and the server (server components and actions). Each needs its own client instance configured differently for cookie handling.

  • In the VS Code Explorer, create a new folder path: src/lib/supabase.
  • Create a new file at src/lib/supabase/client.ts and paste the following code:
import { createBrowserClient } from "@supabase/ssr";

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

What does this code do?

createBrowserClient from @supabase/ssr creates a Supabase client that automatically handles auth cookies in the browser. Every time you call createClient() from a client component, it returns a ready-to-use Supabase instance with the user's session attached.

  • Save src/lib/supabase/client.ts.
  • Now create src/lib/supabase/server.ts and paste the following code:
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_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // The `setAll` method is called from a Server Component.
            // This can be ignored if you have proxy refreshing sessions.
          }
        },
      },
    }
  );
}

What does this code do?

createServerClient creates a Supabase client for server-side use. It reads the user's auth cookies via Next.js's cookies() helper so server components can identify who is making the request.

The setAll method updates cookies when auth tokens refresh. The try/catch block handles the case where setAll is called from a read-only Server Component. The proxy you will build in Step 4 handles these refreshes instead.

  • Save src/lib/supabase/server.ts.
  • Confirm both files exist by expanding src/lib/supabase in the Explorer sidebar. You should see client.ts and server.ts.

Seeing a TypeScript error in server.ts?

If you see a red underline on cookies, make sure your next package is up to date. The cookies() function returns a Promise in Next.js 15+, which is why we use await.

help me fix a TypeScript error in my Supabase server client file

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: .env.local, src/lib/supabase/client.ts, and src/lib/supabase/server.ts.

ⓧ I'd like to double check the full code

Here is each file in full for comparison.

NEXT_PUBLIC_SUPABASE_URL=<your-supabase-url>
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-supabase-publishable-key>
import { createBrowserClient } from "@supabase/ssr";

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );
}
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_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // The `setAll` method is called from a Server Component.
            // This can be ignored if you have proxy refreshing sessions.
          }
        },
      },
    }
  );
}

Your Supabase connection is ready. Next up, you will create the database tables that store user profiles and student scores.

Create the Database Schema

Your Supabase client libraries are wired up and ready to talk to your project. Now you need to give them something to talk to.

In this step, you will build the database tables that power the authentication and score-tracking features. You will also set up Row Level Security so that each teacher can only see and manage their own data.

In this step, get ready to:
  • Create the profiles table and an automatic signup trigger.
  • Create the students table to store score records.
  • Add Row Level Security policies to both tables.
Create the profiles table and signup trigger

Every user who signs up gets a row in Supabase's internal auth.users table. Your app also needs to know each user's role (teacher or student). A separate profiles table stores that role, and a database trigger automatically creates a profile row whenever someone signs up.

  • In the Supabase dashboard, click SQL Editor in the left sidebar.
  • Click New query to open a blank editor.
  • Paste the following SQL and click Run:
-- Create the profiles table to store user roles
create table public.profiles (
  id uuid primary key references auth.users on delete cascade,
  email text,
  role text check (role in ('teacher', 'student')),
  created_at timestamptz default now()
);

-- Create a trigger function to auto-create a profile on signup
create or replace function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, email)
  values (new.id, new.email);
  return new;
end;
$$ language plpgsql security definer;

-- Attach the trigger to the auth.users table
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

What does this SQL do?

The profiles table has a foreign key to auth.users, linking each profile to a real auth account. The role column is constrained to only allow 'teacher' or 'student'.

The handle_new_user function fires automatically after every new row in auth.users. It copies the user's id and email into a new profiles row. The role column starts as null and gets set when the user picks their role on the sign-up page.

  • Confirm that the result panel at the bottom shows Success. No rows returned.
  • Click Table Editor in the left sidebar. You should see profiles listed under your tables.

Seeing a permission error?

Make sure you are running the SQL in the correct Supabase project. Check the project name in the top-left corner of the dashboard matches the project you created in Step 1.

If you see a "relation already exists" error, the table was already created. You can safely move on to the next query.

Help me debug my profiles table SQL.

Create the students table

In Part 1, your dashboard used mock data from a local file. Now you will replace that with a real students table that stores score records linked to the teacher who entered them.

  • Switch back to the SQL Editor and open a new query.
  • Paste the following SQL and click Run:
-- Create the students table to store score records
create table public.students (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  subject text not null,
  score integer not null,
  grade text not null,
  status text not null,
  teacher_id uuid references public.profiles(id) on delete cascade,
  created_at timestamptz default now()
);

What does this table do?

Each row represents one student score entry. The teacher_id column links each record to the teacher who created it. This is the key that Row Level Security will use to restrict access.

The id column uses gen_random_uuid() to automatically generate a unique identifier for every new row.

  • Confirm that the result panel shows Success. No rows returned.
  • Click Table Editor in the left sidebar. You should now see both profiles and students listed.

Table not appearing in the Table Editor?

Try refreshing the page. The sidebar sometimes needs a reload to show newly created tables.

If you see a "relation profiles does not exist" error, make sure you ran the profiles SQL from the previous substep first. The students table references profiles.

Help me debug my students table SQL.

Add Row Level Security policies

Right now, anyone with your project URL and publishable key could read or write to these tables. Row Level Security locks that down by adding rules that the database enforces on every query.

  • Open a new query in the SQL Editor.
  • Paste the following SQL and click Run:
-- Enable Row Level Security on both tables
alter table public.profiles enable row level security;
alter table public.students enable row level security;

-- Profiles: owners can read their own profile
create policy "Users can read own profile"
  on public.profiles for select
  using (auth.uid() = id);

-- Students: teachers can read their own students
create policy "Teachers can read own students"
  on public.students for select
  using (auth.uid() = teacher_id);

-- Students: teachers can insert their own students
create policy "Teachers can insert own students"
  on public.students for insert
  with check (auth.uid() = teacher_id);

-- Students: teachers can update their own students
create policy "Teachers can update own students"
  on public.students for update
  using (auth.uid() = teacher_id);

-- Students: teachers can delete their own students
create policy "Teachers can delete own students"
  on public.students for delete
  using (auth.uid() = teacher_id);

What is Row Level Security?

RLS makes the database itself responsible for access control. Even if someone calls your API directly, Postgres checks auth.uid() (the currently authenticated user) against the policy condition before returning or modifying any rows.

The profiles policy lets users read only their own profile. The students policies restrict all operations (select, insert, update, delete) to rows where teacher_id matches the logged-in teacher. This means one teacher can never see another teacher's data.

  • Confirm that the result panel shows Success. No rows returned.
  • In the Table Editor, click profiles. You should see a yellow RLS enabled badge next to the table name, confirming security is active.

RLS badge not showing?

Refresh the Table Editor page. If you still do not see the badge, re-run just the two alter table ... enable row level security lines by themselves.

Help me enable Row Level Security on my Supabase tables.

Your database schema is locked and loaded. Next up, you will build the auth pages that let users sign up, pick a role, and sign in.

Build the Auth Pages

Your database is ready to store users and their roles. Now you need a way for teachers and students to actually create accounts and sign in.

In this step, you'll build three auth pages: a sign-up page with role selection, a sign-in page, and a callback route that handles email verification. You'll also redirect the app's home page to the sign-in form so unauthenticated visitors land in the right place.

In this step, get ready to:
  • Create a sign-up page with email, password, and role selection.
  • Create a sign-in page with email and password authentication.
  • Create an auth callback route and redirect the home page to sign-in.
Create the sign-up page

The sign-up page lets new users create an account with their email and password, then pick whether they're a teacher or a student. After signup, the page updates the user's profile role in the profiles table you created earlier.

  • In the VS Code Explorer sidebar, create a new folder path: src/app/auth/sign-up.
  • Inside sign-up, create a new file called page.tsx.
  • Paste the following code into src/app/auth/sign-up/page.tsx:
"use client";

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

export default function SignUpPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [role, setRole] = useState<"teacher" | "student">("teacher");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  async function handleSignUp(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    setError("");

    const supabase = createClient();

    // Sign up the user with Supabase Auth
    const { data, error: signUpError } = await supabase.auth.signUp({
      email,
      password,
    });

    if (signUpError) {
      setError(signUpError.message);
      setLoading(false);
      return;
    }

    // Update the auto-created profile with the selected role
    if (data.user) {
      const { error: profileError } = await supabase
        .from("profiles")
        .update({ role })
        .eq("id", data.user.id);

      if (profileError) {
        setError(profileError.message);
        setLoading(false);
        return;
      }
    }

    // Redirect to the role-specific dashboard
    router.push(`/dashboard/${role}`);
  }

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-100 dark:bg-gray-900">
      <div className="w-full max-w-md rounded-xl bg-white dark:bg-gray-800 p-8 shadow-sm border border-gray-200 dark:border-gray-700">
        <h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
          Create Account
        </h1>
        {error && (
          <p className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p>
        )}
        <form onSubmit={handleSignUp} className="space-y-4">
          <div>
            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
              Email
            </label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
              Password
            </label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              minLength={6}
              className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
              I am a
            </label>
            <div className="flex gap-4">
              <button
                type="button"
                onClick={() => setRole("teacher")}
                className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium border transition-colors ${
                  role === "teacher"
                    ? "bg-indigo-600 text-white border-indigo-600"
                    : "bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600"
                }`}
              >
                Teacher
              </button>
              <button
                type="button"
                onClick={() => setRole("student")}
                className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium border transition-colors ${
                  role === "student"
                    ? "bg-indigo-600 text-white border-indigo-600"
                    : "bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600"
                }`}
              >
                Student
              </button>
            </div>
          </div>
          <button
            type="submit"
            disabled={loading}
            className="w-full rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors disabled:opacity-50"
          >
            {loading ? "Creating account..." : "Sign Up"}
          </button>
        </form>
        <p className="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
          Already have an account?{" "}
          <a href="/auth/sign-in" className="text-indigo-600 dark:text-indigo-400 hover:underline">
            Sign in
          </a>
        </p>
      </div>
    </div>
  );
}

What does this code do?

This is a client component (indicated by "use client") that manages the signup flow in three stages.

  • handleSignUp calls supabase.auth.signUp() to create the user in Supabase Auth. This also triggers the handle_new_user database trigger you set up earlier, which auto-creates a row in profiles.
  • After signup, it updates that auto-created profile row with the role the user selected (teacher or student).
  • The role selector uses two toggle-style buttons. Whichever is active gets the indigo highlight.
  • Save src/app/auth/sign-up/page.tsx.

You won't see a change in the browser yet because there's no route linking to this page. The next file will give us a sign-in page that links back to it.

Seeing a TypeScript error in your editor?

  • Make sure the folder path is exactly src/app/auth/sign-up/page.tsx. A typo in the folder name means Next.js won't recognise it as a route.
  • Check that src/lib/supabase/client.ts exists from the previous step. The import @/lib/supabase/client resolves to that file.

Help me debug a TypeScript error in my Supabase sign-up page.

Create the sign-in page

The sign-in page is simpler. It collects email and password, authenticates with Supabase, and redirects the user to the dashboard.

  • Inside your existing src/app/auth folder, create a new folder called sign-in.
  • Create a file called page.tsx inside src/app/auth/sign-in.
  • Paste the following code into src/app/auth/sign-in/page.tsx:
"use client";

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

export default function SignInPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  async function handleSignIn(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    setError("");

    const supabase = createClient();

    // Authenticate with email and password
    const { error: signInError } = await supabase.auth.signInWithPassword({
      email,
      password,
    });

    if (signInError) {
      setError(signInError.message);
      setLoading(false);
      return;
    }

    // Redirect to the dashboard and refresh server components
    router.push("/dashboard");
    router.refresh();
  }

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-100 dark:bg-gray-900">
      <div className="w-full max-w-md rounded-xl bg-white dark:bg-gray-800 p-8 shadow-sm border border-gray-200 dark:border-gray-700">
        <h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
          Sign In
        </h1>
        {error && (
          <p className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p>
        )}
        <form onSubmit={handleSignIn} className="space-y-4">
          <div>
            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
              Email
            </label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
              Password
            </label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
            />
          </div>
          <button
            type="submit"
            disabled={loading}
            className="w-full rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors disabled:opacity-50"
          >
            {loading ? "Signing in..." : "Sign In"}
          </button>
        </form>
        <p className="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
          Don't have an account?{" "}
          <a href="/auth/sign-up" className="text-indigo-600 dark:text-indigo-400 hover:underline">
            Sign up
          </a>
        </p>
      </div>
    </div>
  );
}

What does this code do?

  • handleSignIn calls supabase.auth.signInWithPassword() which validates the credentials against Supabase Auth and sets a session cookie.
  • router.refresh() forces server components to re-render with the new session. Without it, cached pages would still show the unauthenticated state.
  • The sign-in page pushes to /dashboard (not a role-specific route). In a later step, the proxy will inspect the user's role and redirect to the correct dashboard automatically.
  • Save src/app/auth/sign-in/page.tsx.
  • Start your dev server if it isn't running already, then open http://localhost:3000/auth/sign-in in your browser.

You should see a centred card with Email and Password fields plus a Sign In button. The "Don't have an account?" link at the bottom should take you to the sign-up page with its role selector buttons.

Page shows a 404?

  • Confirm the folder structure is src/app/auth/sign-in/page.tsx. Next.js uses the folder path to generate routes.
  • Make sure you saved the file. Unsaved files won't be picked up by the dev server.

Help me debug why my sign-in page isn't loading.

Create the callback route and update the home page

When Supabase sends a confirmation email (for email verification flows), the link points to a callback URL in your app. This route handler exchanges the confirmation code for a real session.

  • Inside src/app/auth, create a new folder called callback.
  • Create a file called route.ts inside src/app/auth/callback.
  • Paste the following code into src/app/auth/callback/route.ts:
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");
  const next = searchParams.get("next") ?? "/dashboard";

  // Exchange the confirmation code for a session
  if (code) {
    const supabase = await createClient();
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    if (!error) {
      return NextResponse.redirect(`${origin}${next}`);
    }
  }

  // If something went wrong, redirect to sign-in with an error
  return NextResponse.redirect(`${origin}/auth/sign-in?error=Could not authenticate`);
}

What does this code do?

  • This is a Route Handler (a route.ts file), not a page. It handles GET requests to /auth/callback.
  • exchangeCodeForSession() takes the one-time code from the email confirmation link and converts it into a valid auth session stored in cookies.
  • It uses the server client (not the browser client) because this runs on the server and needs cookie write access.
  • Save src/app/auth/callback/route.ts.

You won't see a change yet because this route only activates when Supabase redirects a user here during email verification. The next chunk wires up the home page redirect.

Right now, visitors who hit the root URL (/) still see the old home page. You want unauthenticated users to land on the sign-in form instead.

  • Open src/app/page.tsx in VS Code.
  • Replace the entire contents of the file with this code:
import { redirect } from "next/navigation";

export default function Home() {
  // Send all visitors to the sign-in page
  redirect("/auth/sign-in");
}

What does this code do?

Next.js's redirect() function performs a server-side redirect. Anyone who opens your app at / is immediately sent to the sign-in page before any page content renders.

  • Save src/app/page.tsx.
  • Open http://localhost:3000 in your browser. You should be automatically redirected to the sign-in page at /auth/sign-in.

Still seeing the old home page?

  • Make sure you replaced the entire file contents, not just appended the redirect. The old page component should no longer exist in src/app/page.tsx.
  • Try a hard refresh (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows) to clear the browser cache.

Help me figure out why my home page redirect isn't working.

✔️ Awesome, I've got everything!

Great. Double check you've saved all four files before moving on.

ⓧ I'd like to double check the full code

Here are the four files you created or updated in this step.

"use client";

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

export default function SignUpPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [role, setRole] = useState<"teacher" | "student">("teacher");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  async function handleSignUp(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    setError("");

    const supabase = createClient();

    const { data, error: signUpError } = await supabase.auth.signUp({
      email,
      password,
    });

    if (signUpError) {
      setError(signUpError.message);
      setLoading(false);
      return;
    }

    if (data.user) {
      const { error: profileError } = await supabase
        .from("profiles")
        .update({ role })
        .eq("id", data.user.id);

      if (profileError) {
        setError(profileError.message);
        setLoading(false);
        return;
      }
    }

    router.push(`/dashboard/${role}`);
  }

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-100 dark:bg-gray-900">
      <div className="w-full max-w-md rounded-xl bg-white dark:bg-gray-800 p-8 shadow-sm border border-gray-200 dark:border-gray-700">
        <h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
          Create Account
        </h1>
        {error && (
          <p className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p>
        )}
        <form onSubmit={handleSignUp} className="space-y-4">
          <div>
            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
              Email
            </label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
              Password
            </label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              minLength={6}
              className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
              I am a
            </label>
            <div className="flex gap-4">
              <button
                type="button"
                onClick={() => setRole("teacher")}
                className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium border transition-colors ${
                  role === "teacher"
                    ? "bg-indigo-600 text-white border-indigo-600"
                    : "bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600"
                }`}
              >
                Teacher
              </button>
              <button
                type="button"
                onClick={() => setRole("student")}
                className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium border transition-colors ${
                  role === "student"
                    ? "bg-indigo-600 text-white border-indigo-600"
                    : "bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600"
                }`}
              >
                Student
              </button>
            </div>
          </div>
          <button
            type="submit"
            disabled={loading}
            className="w-full rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors disabled:opacity-50"
          >
            {loading ? "Creating account..." : "Sign Up"}
          </button>
        </form>
        <p className="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
          Already have an account?{" "}
          <a href="/auth/sign-in" className="text-indigo-600 dark:text-indigo-400 hover:underline">
            Sign in
          </a>
        </p>
      </div>
    </div>
  );
}
"use client";

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

export default function SignInPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  async function handleSignIn(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    setError("");

    const supabase = createClient();

    const { error: signInError } = await supabase.auth.signInWithPassword({
      email,
      password,
    });

    if (signInError) {
      setError(signInError.message);
      setLoading(false);
      return;
    }

    router.push("/dashboard");
    router.refresh();
  }

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-100 dark:bg-gray-900">
      <div className="w-full max-w-md rounded-xl bg-white dark:bg-gray-800 p-8 shadow-sm border border-gray-200 dark:border-gray-700">
        <h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
          Sign In
        </h1>
        {error && (
          <p className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p>
        )}
        <form onSubmit={handleSignIn} className="space-y-4">
          <div>
            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
              Email
            </label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
              Password
            </label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
            />
          </div>
          <button
            type="submit"
            disabled={loading}
            className="w-full rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors disabled:opacity-50"
          >
            {loading ? "Signing in..." : "Sign In"}
          </button>
        </form>
        <p className="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
          Don't have an account?{" "}
          <a href="/auth/sign-up" className="text-indigo-600 dark:text-indigo-400 hover:underline">
            Sign up
          </a>
        </p>
      </div>
    </div>
  );
}
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");
  const next = searchParams.get("next") ?? "/dashboard";

  if (code) {
    const supabase = await createClient();
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    if (!error) {
      return NextResponse.redirect(`${origin}${next}`);
    }
  }

  return NextResponse.redirect(`${origin}/auth/sign-in?error=Could not authenticate`);
}
import { redirect } from "next/navigation";

export default function Home() {
  redirect("/auth/sign-in");
}

Your auth pages are in place. Next up, you'll build the proxy that checks every request, refreshes tokens, and routes users to the correct dashboard based on their role.

Add Proxy-Based Session Management and Role Routing

Your auth pages are ready. Users can sign up with a role and sign in with their credentials.

However, there is nothing stopping an unauthenticated user from typing /dashboard into the URL bar and accessing protected content. You also need a way to automatically route teachers and students to their correct dashboards after login.

Next.js 16 introduced a proxy.ts convention that intercepts every incoming request before it reaches a page. You will use this to refresh auth tokens, block unauthenticated access, and redirect users based on their stored role.

In this step, get ready to:
  • Create a proxy session utility that refreshes auth tokens on every request.
  • Wire up role-based routing that sends teachers and students to separate dashboards.
  • Restructure your dashboard routes into teacher and student paths.
Create the proxy session utility

The proxy needs its own Supabase client that can read cookies from the incoming request and write updated cookies back to the response. This is different from the server client you created earlier, which uses Next.js's cookies() helper.

  • Create a new file at src/lib/supabase/proxy.ts and paste the following code:
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

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

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

  // Validate the JWT and refresh the session
  const {
    data: { claims },
  } = await supabase.auth.getClaims();

  // Redirect unauthenticated users away from /dashboard
  if (
    !claims &&
    request.nextUrl.pathname.startsWith("/dashboard")
  ) {
    const url = request.nextUrl.clone();
    url.pathname = "/auth/sign-in";
    return NextResponse.redirect(url);
  }

  // Redirect authenticated users to their role-specific dashboard
  if (claims && request.nextUrl.pathname.startsWith("/dashboard")) {
    const { data: profile } = await supabase
      .from("profiles")
      .select("role")
      .eq("id", claims.sub)
      .single();

    if (profile) {
      const expectedPath = `/dashboard/${profile.role}`;
      if (!request.nextUrl.pathname.startsWith(expectedPath)) {
        const url = request.nextUrl.clone();
        url.pathname = expectedPath;
        return NextResponse.redirect(url);
      }
    }
  }

  // Redirect authenticated users away from auth pages
  if (
    claims &&
    (request.nextUrl.pathname.startsWith("/auth/sign-in") ||
      request.nextUrl.pathname.startsWith("/auth/sign-up"))
  ) {
    const { data: profile } = await supabase
      .from("profiles")
      .select("role")
      .eq("id", claims.sub)
      .single();

    if (profile) {
      const url = request.nextUrl.clone();
      url.pathname = `/dashboard/${profile.role}`;
      return NextResponse.redirect(url);
    }
  }

  return supabaseResponse;
}

What does this code do?

This file handles two major responsibilities: session management and role-based routing.

For session management, it creates a Supabase client wired directly into the request/response cookie flow. The getAll method reads cookies from the incoming request, and setAll writes refreshed tokens back to both the request and the response. The getClaims() call validates the JWT signature and refreshes expired tokens automatically.

For routing, three rules apply. Unauthenticated users visiting /dashboard get redirected to /auth/sign-in. Authenticated users visiting the wrong dashboard (e.g. a teacher hitting /dashboard/student) get redirected to their role-specific path. Authenticated users visiting auth pages get sent straight to their dashboard.

Why getClaims() instead of getSession()?

The getClaims() method validates the JWT signature against your project's public keys using WebCrypto. The older getSession() method reads cookies without verification and should never be trusted for authorization decisions on the server.

  • Save src/lib/supabase/proxy.ts.

You won't see a change yet. The next file will wire this utility into the Next.js request pipeline.

Seeing a TypeScript error?

Make sure you are importing from @supabase/ssr (not @supabase/supabase-js). The createServerClient function lives in the SSR package.

Also confirm your .env.local has both NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY set correctly.

help me debug a TypeScript error in my proxy.ts Supabase utility

Create the proxy entry point

Next.js 16 looks for a proxy.ts file at the same level as your app directory. Since your project uses a src folder, that means src/proxy.ts.

  • Create a new file at src/proxy.ts and paste the following code:
import { type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/proxy";

export async function proxy(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

What does this code do?

This is the entry point that Next.js 16 calls on every incoming request. It exports a named proxy function that delegates to your updateSession utility.

The config.matcher regex tells Next.js to skip static assets (images, fonts, favicon) so the proxy only runs on page and API routes.

  • Save src/proxy.ts.
  • If your dev server is running, stop it and restart with npm run dev. Next.js picks up new proxy files on startup.

Proxy not running?

Check that src/proxy.ts is at the same level as your app folder (inside src/), not inside app/ itself.

Make sure you exported the function as proxy (not default). Next.js 16 requires a named export.

help me debug why my Next.js proxy.ts file is not intercepting requests

Restructure the dashboard routes

Your proxy now redirects teachers to /dashboard/teacher and students to /dashboard/student. You need pages at those paths for the redirects to land somewhere.

  • Move your existing dashboard page file into a new folder at src/app/dashboard/teacher/page.tsx. Create the teacher folder if it does not exist.
  • If your Part 1 project has a layout.tsx inside src/app/dashboard/, keep it where it is. Both the teacher and student routes will share it.
  • Create a new file at src/app/dashboard/student/page.tsx and paste the following code:
export default function StudentDashboardPage() {
  return (
    <div className="flex items-center justify-center min-h-[60vh]">
      <div className="rounded-xl bg-white dark:bg-gray-800 p-8 shadow-sm border border-gray-200 dark:border-gray-700 text-center">
        <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
          Student Dashboard
        </h2>
        <p className="text-gray-500 dark:text-gray-400">
          Coming soon in Part 3! Your personal results and task submissions will appear here.
        </p>
      </div>
    </div>
  );
}

What does this code do?

This is a placeholder page for students. When a student signs up and gets redirected to /dashboard/student, they see a friendly message instead of a 404. The actual student functionality will come in a future part.

  • Save src/app/dashboard/student/page.tsx.
  • Open your browser and navigate to http://localhost:3000. You should be redirected to the sign-in page.
  • Try navigating directly to http://localhost:3000/dashboard. The proxy should redirect you back to /auth/sign-in since you are not logged in.

Not getting redirected?

Make sure your dev server was restarted after creating src/proxy.ts. Stop it with Ctrl+C and run npm run dev again.

If you see a 404 at /dashboard/teacher, confirm that you moved your original dashboard page file into src/app/dashboard/teacher/page.tsx (not just copied it).

help me debug why my proxy is not redirecting unauthenticated users away from the dashboard

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is src/lib/supabase/proxy.ts:

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

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

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

  const {
    data: { claims },
  } = await supabase.auth.getClaims();

  if (
    !claims &&
    request.nextUrl.pathname.startsWith("/dashboard")
  ) {
    const url = request.nextUrl.clone();
    url.pathname = "/auth/sign-in";
    return NextResponse.redirect(url);
  }

  if (claims && request.nextUrl.pathname.startsWith("/dashboard")) {
    const { data: profile } = await supabase
      .from("profiles")
      .select("role")
      .eq("id", claims.sub)
      .single();

    if (profile) {
      const expectedPath = `/dashboard/${profile.role}`;
      if (!request.nextUrl.pathname.startsWith(expectedPath)) {
        const url = request.nextUrl.clone();
        url.pathname = expectedPath;
        return NextResponse.redirect(url);
      }
    }
  }

  if (
    claims &&
    (request.nextUrl.pathname.startsWith("/auth/sign-in") ||
      request.nextUrl.pathname.startsWith("/auth/sign-up"))
  ) {
    const { data: profile } = await supabase
      .from("profiles")
      .select("role")
      .eq("id", claims.sub)
      .single();

    if (profile) {
      const url = request.nextUrl.clone();
      url.pathname = `/dashboard/${profile.role}`;
      return NextResponse.redirect(url);
    }
  }

  return supabaseResponse;
}

Here is src/proxy.ts:

import { type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/proxy";

export async function proxy(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

Here is src/app/dashboard/student/page.tsx:

export default function StudentDashboardPage() {
  return (
    <div className="flex items-center justify-center min-h-[60vh]">
      <div className="rounded-xl bg-white dark:bg-gray-800 p-8 shadow-sm border border-gray-200 dark:border-gray-700 text-center">
        <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
          Student Dashboard
        </h2>
        <p className="text-gray-500 dark:text-gray-400">
          Coming soon in Part 3! Your personal results and task submissions will appear here.
        </p>
      </div>
    </div>
  );
}

Your app now has a security layer that protects dashboards and routes users based on their role. Next up, you will connect the teacher dashboard to real Supabase data instead of mock students.

Connect Teacher Dashboard to Supabase

Your auth system is working. Teachers can sign up, sign in, and get routed to the correct dashboard.

The dashboard is still showing mock data from Part 1, though. In this step, you will replace that static data with live queries to your Supabase database and build a form that lets teachers add real student scores.

In this step, get ready to:
  • Replace mock data with a live Supabase query filtered by the logged-in teacher.
  • Create a Server Action that validates input and inserts student scores.
  • Build an Add Student form component wired to the Server Action.
Replace mock data with a live Supabase query

The teacher dashboard page currently imports hardcoded student data. You will rewrite it as an async Server Component that fetches real records from the students table, filtered by the logged-in teacher's ID.

  • In VS Code, open src/app/dashboard/teacher/page.tsx.
  • Select all the existing content and replace it with the following imports, interface, and helper functions:
import { createClient } from "@/lib/supabase/server";
import KpiCard from "@/components/KpiCard";
import SubjectChart from "@/components/SubjectChart";
import ResultsTable from "@/components/ResultsTable";

// Define the shape of a student record from the database
interface Student {
  id: string;
  name: string;
  subject: string;
  score: number;
  grade: string;
  status: "Pass" | "Fail";
}

// Compute KPI values from the student array
function calculateKpis(students: Student[]) {
  const totalStudents = students.length;
  if (totalStudents === 0) {
    return { totalStudents: 0, averageScore: 0, passRate: 0, topPerformer: null };
  }
  const averageScore = Math.round(
    students.reduce((sum, s) => sum + s.score, 0) / totalStudents
  );
  const passRate = Math.round(
    (students.filter((s) => s.status === "Pass").length / totalStudents) * 100
  );
  const topPerformer = students.reduce((top, s) =>
    s.score > top.score ? s : top
  );

  return { totalStudents, averageScore, passRate, topPerformer };
}

// Group students by subject and compute each subject's average score
function calculateSubjectAverages(students: Student[]) {
  const subjectMap: Record<string, { total: number; count: number }> = {};

  students.forEach((s) => {
    if (!subjectMap[s.subject]) {
      subjectMap[s.subject] = { total: 0, count: 0 };
    }
    subjectMap[s.subject].total += s.score;
    subjectMap[s.subject].count += 1;
  });

  return Object.entries(subjectMap).map(([subject, data]) => ({
    subject,
    average: Math.round(data.total / data.count),
  }));
}

What does this code do?

The imports now pull in the server-side Supabase client instead of mock data. The Student interface defines the shape of rows coming back from your database.

The two helper functions remain similar to Part 1. calculateKpis derives totals, averages, pass rates, and the top performer. calculateSubjectAverages groups students by subject and computes each subject's mean score.

  • Directly below the helper functions, paste the page component that queries Supabase:
export default async function TeacherDashboardPage() {
  // Create a server-side Supabase client
  const supabase = await createClient();

  // Get the authenticated teacher's claims (contains their user ID)
  const {
    data: { claims },
  } = await supabase.auth.getClaims();

  // Fetch only this teacher's students, newest first
  const { data: students } = await supabase
    .from("students")
    .select("*")
    .eq("teacher_id", claims!.sub)
    .order("created_at", { ascending: false });

  const studentList: Student[] = students ?? [];
  const { totalStudents, averageScore, passRate, topPerformer } =
    calculateKpis(studentList);
  const subjectAverages = calculateSubjectAverages(studentList);

  return (
    <div className="space-y-6">
      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
        <KpiCard
          title="Total Students"
          value={totalStudents.toString()}
          description="In your class"
        />
        <KpiCard
          title="Average Score"
          value={`${averageScore}%`}
          description="Across all subjects"
        />
        <KpiCard
          title="Pass Rate"
          value={`${passRate}%`}
          description="Score above 50%"
        />
        <KpiCard
          title="Top Performer"
          value={topPerformer ? topPerformer.name : "N/A"}
          description={
            topPerformer
              ? `${topPerformer.score}% in ${topPerformer.subject}`
              : "Add students to see"
          }
        />
      </div>

      {studentList.length > 0 && (
        <>
          <SubjectChart data={subjectAverages} />
          <ResultsTable students={studentList} />
        </>
      )}
    </div>
  );
}

What does this code do?

The page is now an async Server Component. It creates a Supabase client, calls getClaims() to identify the logged-in teacher, then queries the students table filtered by teacher_id.

Row Level Security on the database enforces that a teacher can only see their own students. The claims!.sub value is the teacher's user ID, which matches teacher_id in the table.

  • Save src/app/dashboard/teacher/page.tsx.
  • In your browser, sign in as a teacher at http://localhost:3000/auth/sign-in. You should land on the teacher dashboard showing all KPI cards with zeroed-out values and no chart or table (since no students exist yet).

Seeing an error about claims being null?

Make sure you are signed in before visiting the dashboard. The proxy redirects unauthenticated users to /auth/sign-in, but if your session expired you may need to sign in again.

Also confirm your .env.local values are correct and the dev server is running.

Help me debug a claims error on my teacher dashboard.

Create a Server Action for adding students

The dashboard currently shows empty data. You need a way for teachers to add student scores. Server Actions let you run server-side code directly from a form submission without creating a separate API route.

  • Create a new file at src/app/actions.ts and paste the following code:
"use server";

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

export async function addStudent(formData: FormData) {
  // Create a server-side Supabase client
  const supabase = await createClient();

  // Verify the teacher is authenticated
  const {
    data: { claims },
  } = await supabase.auth.getClaims();

  if (!claims) {
    throw new Error("Not authenticated");
  }

  // Extract form values
  const name = formData.get("name") as string;
  const subject = formData.get("subject") as string;
  const score = parseInt(formData.get("score") as string, 10);

  // Validate all inputs before inserting
  if (!name || !subject || isNaN(score) || score < 0 || score > 100) {
    throw new Error("Invalid input");
  }

  // Compute grade from score
  let grade: string;
  if (score >= 90) grade = "A";
  else if (score >= 80) grade = "B";
  else if (score >= 60) grade = "C";
  else if (score >= 50) grade = "D";
  else grade = "F";

  // Determine pass/fail status
  const status = score >= 50 ? "Pass" : "Fail";

  // Insert the new student record
  const { error } = await supabase.from("students").insert({
    name,
    subject,
    score,
    grade,
    status,
    teacher_id: claims.sub,
  });

  if (error) {
    throw new Error(error.message);
  }

  // Refresh the teacher dashboard to show the new record
  revalidatePath("/dashboard/teacher");
}

What does this code do?

The "use server" directive marks this file as containing Server Actions. The addStudent function receives FormData directly from a form's action attribute.

It validates the teacher's identity via getClaims(), computes a letter grade and pass/fail status from the numeric score, then inserts into the students table. The teacher_id column is automatically set to the current teacher's ID so RLS policies apply correctly.

Finally, revalidatePath tells Next.js to regenerate the teacher dashboard page so the new student appears immediately.

  • Save src/app/actions.ts.

Why a Server Action instead of an API route?

Server Actions co-locate your mutation logic with the form that triggers it. You do not need to manage fetch calls, CORS, or separate endpoint files. Next.js handles the network round-trip for you and integrates with cache revalidation automatically.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file.

ⓧ I'd like to double check the full code

"use server";

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

export async function addStudent(formData: FormData) {
  const supabase = await createClient();

  const {
    data: { claims },
  } = await supabase.auth.getClaims();

  if (!claims) {
    throw new Error("Not authenticated");
  }

  const name = formData.get("name") as string;
  const subject = formData.get("subject") as string;
  const score = parseInt(formData.get("score") as string, 10);

  if (!name || !subject || isNaN(score) || score < 0 || score > 100) {
    throw new Error("Invalid input");
  }

  let grade: string;
  if (score >= 90) grade = "A";
  else if (score >= 80) grade = "B";
  else if (score >= 60) grade = "C";
  else if (score >= 50) grade = "D";
  else grade = "F";

  const status = score >= 50 ? "Pass" : "Fail";

  const { error } = await supabase.from("students").insert({
    name,
    subject,
    score,
    grade,
    status,
    teacher_id: claims.sub,
  });

  if (error) {
    throw new Error(error.message);
  }

  revalidatePath("/dashboard/teacher");
}
Build the Add Student form

You need a client component that renders an input form and submits it to the addStudent Server Action. The form collects a student name, subject, and score.

  • Create a new file at src/components/AddStudentForm.tsx and paste the following code:
"use client";

import { useState } from "react";
import { addStudent } from "@/app/actions";

export default function AddStudentForm() {
  // Toggle form visibility
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="rounded-xl bg-white dark:bg-gray-800 p-6 shadow-sm border border-gray-200 dark:border-gray-700">
      <div className="flex items-center justify-between">
        <h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">
          Manage Students
        </h3>
        <button
          onClick={() => setIsOpen(!isOpen)}
          className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors"
        >
          {isOpen ? "Cancel" : "Add Student"}
        </button>
      </div>
      {isOpen && (
        <form action={addStudent} className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-4">
          <input
            name="name"
            type="text"
            placeholder="Student name"
            required
            className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
          />
          <select
            name="subject"
            required
            className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
          >
            <option value="">Select subject</option>
            <option value="Math">Math</option>
            <option value="Science">Science</option>
            <option value="English">English</option>
            <option value="History">History</option>
          </select>
          <input
            name="score"
            type="number"
            placeholder="Score (0-100)"
            min="0"
            max="100"
            required
            className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
          />
          <button
            type="submit"
            className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 transition-colors"
          >
            Save
          </button>
        </form>
      )}
    </div>
  );
}

What does this code do?

This is a "use client" component because it uses useState to toggle the form open and closed. When the form is visible, it uses the native HTML action attribute pointing at addStudent.

When the learner clicks Save, Next.js serializes the form fields as FormData and sends them to the Server Action. The page automatically refreshes after the action completes because of revalidatePath.

  • Save src/components/AddStudentForm.tsx.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file.

ⓧ I'd like to double check the full code

"use client";

import { useState } from "react";
import { addStudent } from "@/app/actions";

export default function AddStudentForm() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="rounded-xl bg-white dark:bg-gray-800 p-6 shadow-sm border border-gray-200 dark:border-gray-700">
      <div className="flex items-center justify-between">
        <h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">
          Manage Students
        </h3>
        <button
          onClick={() => setIsOpen(!isOpen)}
          className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors"
        >
          {isOpen ? "Cancel" : "Add Student"}
        </button>
      </div>
      {isOpen && (
        <form action={addStudent} className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-4">
          <input
            name="name"
            type="text"
            placeholder="Student name"
            required
            className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
          />
          <select
            name="subject"
            required
            className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
          >
            <option value="">Select subject</option>
            <option value="Math">Math</option>
            <option value="Science">Science</option>
            <option value="English">English</option>
            <option value="History">History</option>
          </select>
          <input
            name="score"
            type="number"
            placeholder="Score (0-100)"
            min="0"
            max="100"
            required
            className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
          />
          <button
            type="submit"
            className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 transition-colors"
          >
            Save
          </button>
        </form>
      )}
    </div>
  );
}

Now you need to import and render this component in the teacher dashboard page.

  • Return to src/app/dashboard/teacher/page.tsx.
  • Find the import lines at the top of the file. Below the ResultsTable import, add this line:
import AddStudentForm from "@/components/AddStudentForm";
  • Scroll down to the return statement. Find the opening <div className="space-y-6"> tag.
  • Add the AddStudentForm component on the very next line, above the grid of KPI cards:
      <AddStudentForm />
  • Save src/app/dashboard/teacher/page.tsx.
  • Refresh your teacher dashboard in the browser. You should see a white card labelled Manage Students with an Add Student button at the top of the page.
  • Click Add Student to expand the form. Enter a name, choose a subject, type a score between 0 and 100, then click Save.
  • The page refreshes automatically and you should see the KPI cards update with real values and the student appear in the results table.

Form submits but nothing appears?

Check the terminal running your dev server for errors. A common cause is a mismatch between the name attributes on the form inputs and the formData.get() keys in the Server Action.

Also verify your students table RLS policies allow inserts where teacher_id = auth.uid(). You can check by opening the Supabase dashboard, navigating to Authentication and confirming your user exists, then checking Table Editor to see if the row was inserted.

Why is my Server Action not inserting into the students table?

✔️ Awesome, I've got everything!

Great. Double check you have saved the file.

ⓧ I'd like to double check the full code

import { createClient } from "@/lib/supabase/server";
import KpiCard from "@/components/KpiCard";
import SubjectChart from "@/components/SubjectChart";
import ResultsTable from "@/components/ResultsTable";
import AddStudentForm from "@/components/AddStudentForm";

interface Student {
  id: string;
  name: string;
  subject: string;
  score: number;
  grade: string;
  status: "Pass" | "Fail";
}

function calculateKpis(students: Student[]) {
  const totalStudents = students.length;
  if (totalStudents === 0) {
    return { totalStudents: 0, averageScore: 0, passRate: 0, topPerformer: null };
  }
  const averageScore = Math.round(
    students.reduce((sum, s) => sum + s.score, 0) / totalStudents
  );
  const passRate = Math.round(
    (students.filter((s) => s.status === "Pass").length / totalStudents) * 100
  );
  const topPerformer = students.reduce((top, s) =>
    s.score > top.score ? s : top
  );

  return { totalStudents, averageScore, passRate, topPerformer };
}

function calculateSubjectAverages(students: Student[]) {
  const subjectMap: Record<string, { total: number; count: number }> = {};

  students.forEach((s) => {
    if (!subjectMap[s.subject]) {
      subjectMap[s.subject] = { total: 0, count: 0 };
    }
    subjectMap[s.subject].total += s.score;
    subjectMap[s.subject].count += 1;
  });

  return Object.entries(subjectMap).map(([subject, data]) => ({
    subject,
    average: Math.round(data.total / data.count),
  }));
}

export default async function TeacherDashboardPage() {
  const supabase = await createClient();

  const {
    data: { claims },
  } = await supabase.auth.getClaims();

  const { data: students } = await supabase
    .from("students")
    .select("*")
    .eq("teacher_id", claims!.sub)
    .order("created_at", { ascending: false });

  const studentList: Student[] = students ?? [];
  const { totalStudents, averageScore, passRate, topPerformer } =
    calculateKpis(studentList);
  const subjectAverages = calculateSubjectAverages(studentList);

  return (
    <div className="space-y-6">
      <AddStudentForm />

      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
        <KpiCard
          title="Total Students"
          value={totalStudents.toString()}
          description="In your class"
        />
        <KpiCard
          title="Average Score"
          value={`${averageScore}%`}
          description="Across all subjects"
        />
        <KpiCard
          title="Pass Rate"
          value={`${passRate}%`}
          description="Score above 50%"
        />
        <KpiCard
          title="Top Performer"
          value={topPerformer ? topPerformer.name : "N/A"}
          description={
            topPerformer
              ? `${topPerformer.score}% in ${topPerformer.subject}`
              : "Add students to see"
          }
        />
      </div>

      {studentList.length > 0 && (
        <>
          <SubjectChart data={subjectAverages} />
          <ResultsTable students={studentList} />
        </>
      )}
    </div>
  );
}

Your teacher dashboard is now powered by real data. Every score a teacher adds flows through a Server Action into Supabase and appears instantly on the dashboard. Next up, the Secret Mission challenges you to add edit and delete functionality to the results table.

Secret mission

Add Edit and Delete Score Actions

Your teacher dashboard can add students, but what about correcting mistakes? In a real classroom, teachers need to update scores after re-grading and remove duplicate entries. Add inline editing and deletion to your results table with full ownership verification.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project uses the Supabase free tier, so there are no ongoing costs unless you exceed the free tier limits.

Resources you used:

  • Supabase project (free tier, includes database and auth).
  • Database tables: profiles and students.
  • Auth users registered in the Supabase Auth system.
  • Local .env.local file with Supabase credentials.

Keep everything running

No action needed. Choose this if you plan to continue building on this project.

  • Your Supabase project stays on the free tier indefinitely.
  • Your database tables, auth users, and RLS policies remain intact.
  • Your local code and .env.local file stay as they are.

Pause - I'll come back to this later

Pause your Supabase project to free up your free tier slot. Your data is preserved but the project becomes inactive.

  • Open your Supabase dashboard.
  • Navigate to Settings, then General.
  • Click Pause Project.
  • Your project will also auto-pause after 7 days of inactivity on the free tier.

Delete - I don't want to use this again

Remove all project resources permanently. This cannot be undone.

  • Open your Supabase dashboard.
  • Navigate to Settings, then General.
  • Click Delete Project. This removes your database tables, auth users, RLS policies, and the trigger function.
  • Remove the .env.local file from your local project by running:
rm .env.local
  • Your local code files (auth pages, Server Actions, Supabase client utilities) can remain if you want to reference them later, or you can delete the entire src/lib/supabase/ folder and auth-related routes if you want a clean slate.

Well Done!

Well Done!

Nice work. You just built a full-stack role-based authentication system with a live Postgres database powering your teacher dashboard.

You learned how to:

  • Set up a Supabase project with a Postgres database, Row Level Security policies, and a trigger that auto-creates user profiles on signup.
  • Built production-ready email/password authentication with role-based signup and proxy-based session management using the Next.js 16 proxy.ts convention for automatic token refresh and role routing.
  • Connected the teacher dashboard to a real database using Server Actions for adding student scores with input validation, automatic grade computation, and cache revalidation.
  • Secret Mission: Added inline editing and deletion of student records with ownership verification to ensure teachers can only modify their own data.

Ready to quiz yourself?