Career Fair Calendar with Admin Panel
Build a full-stack event calendar with a protected admin panel and conflict detection.
Introduction
30 Second Summary
Imagine you run 50+ career fairs a year across ten states. You need a calendar everyone can browse, a team that can add and move events without touching code, and a system that flags when two fairs collide in the same city on the same day.
In this project, you will build a full-stack event calendar for career fairs. Visitors browse and filter events on an interactive calendar, while a password-protected admin panel lets the events team create, edit, and cancel fairs through simple forms.
What You'll Build
Open the finished app and you see a colour-coded calendar spanning the full year. Click any event dot and a detail panel slides out with venue, date, and a registration form. Switch to the admin panel, try scheduling two events in the same state on the same day, and watch the system flag the conflict before you save.
By the end of this project, you'll have:
- A full-year interactive calendar where visitors filter career fairs by category, audience, and Malaysian state, then click any event to see its details and register interest.
- A protected admin dashboard where a non-technical team member creates, edits, moves, and cancels events through point-and-click forms.
- Built-in conflict detection that warns the admin when two events overlap in the same region on the same date, preventing scheduling chaos across 50+ fairs per year.
- Secret Mission: Add capacity tracking with "Filling Up" and "Full" status badges that auto-close registration when an event hits its limit.
Are there any prerequisites?
You should be familiar with JavaScript/TypeScript fundamentals and basic React concepts. No prior Next.js or Supabase experience is required.
If you have development experience with PHP, Python Flask, MySQL, or DynamoDB, you will find the patterns here familiar. This project translates those skills into the modern full-stack JavaScript ecosystem.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
You're building a full-stack career fair calendar that a real events team could use to manage 50+ fairs per year. Visitors browse and register interest, while an admin panel handles creation, editing, conflict detection, and authentication.
Set Up Your Project and Database
You have a clear picture of what you're building. Now you need a foundation to build on: a running app connected to a database with the right tables.
In this step, you'll scaffold a Next.js project, spin up a free Supabase database with tables for events and registrations, and wire them together so your app can talk to your data.
In this step, get ready to:
- Install Node.js and scaffold a Next.js project with Supabase and FullCalendar dependencies.
- Create a Supabase project and set up the events and registrations database tables.
- Configure environment variables and create Supabase client utility files.
Install Node.js and scaffold your Next.js project
Next.js 16.3 requires Node.js version 20.9 or higher. Let's check whether you have it installed.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Check your Node.js version by running this command:
node --version
✔️ I see version 20.9 or higher
Your Node.js version is compatible. Move on to the next instruction below the tabs.
ⓧ I see an older version
Your installed Node.js is below 20.9. You need to upgrade.
- Visit https://nodejs.org and download the latest LTS installer for your operating system.
- Run the installer and follow the prompts to replace your existing version.
- Close and reopen your terminal, then run node --version again to confirm the version is 20.9 or higher.
ⓧ Command not found
Node.js is not installed on your machine yet.
- Visit https://nodejs.org and download the LTS installer for your operating system.
- Run the installer and accept the defaults.
- Close and reopen your terminal, then run node --version to confirm installation. You should see a version number of 20.9 or higher.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold a new Next.js project by running this command:
npx create-next-app@latest talentbank-calendar --yes
What does this command do?
The --yes flag accepts all the default options: TypeScript, Tailwind CSS, ESLint, App Router, and Turbopack. This gives you a production-ready starting structure without needing to answer prompts one by one.
- Move into your new project folder by running:
cd talentbank-calendar
- Install the Supabase client libraries by running:
npm install @supabase/supabase-js @supabase/ssr
Why two Supabase packages?
The @supabase/supabase-js package is the main client for querying your database. The @supabase/ssr package adds cookie-based session handling so authentication works correctly with server-side rendering.
- Install the FullCalendar packages by running:
npm install @fullcalendar/react @fullcalendar/core @fullcalendar/daygrid @fullcalendar/interaction
What is FullCalendar?
FullCalendar is a JavaScript library that renders interactive calendar views (month grid, week, day) out of the box. Building calendar rendering from scratch would take days. FullCalendar v7 provides month views, click handlers, and colour-coding with a React-native integration.
- Verify everything installed correctly by starting the development server:
npm run dev
You should see output indicating the server is running, with a URL pointing to http://localhost:3000.
- Open your browser and visit http://localhost:3000. You should see the default Next.js starter page with the Next.js logo and links.
Page not loading?
- Make sure you ran npm run dev from inside the talentbank-calendar folder, not your Desktop.
- Check your terminal for error messages. If you see a port conflict, another process is using port 3000. Stop it or use a different port.
- Help me troubleshoot my Next.js dev server
- Leave the dev server running. Open a new terminal tab or window by pressing Cmd+T (macOS) or Ctrl+Shift+T (Windows).
Create your Supabase project and database tables
Your app needs somewhere to store event data and registrations. Supabase gives you a full PostgreSQL database with a visual dashboard and built-in authentication, all on a free tier.
- Open https://supabase.com in your browser.
- Click Start your project and sign up with your GitHub account (or email).
- Once signed in, click New Project.
- Enter talentbank-calendar as the project name.
- Set a database password. Save this somewhere safe.
- Choose the region closest to you, then click Create new project.
- Wait for the project to finish provisioning. This takes about 1-2 minutes.
Once your project is ready, you'll create the two tables your app needs using SQL.
- In the left sidebar of your Supabase dashboard, click SQL Editor.
- Click New query to open a blank editor.
- Create the events table by pasting and running this SQL:
-- Create the events table to store career fair details
create table events (
id uuid primary key default gen_random_uuid(),
title text not null,
date_start date not null,
date_end date not null,
venue text,
state text,
category text,
audience text,
field text,
status text default 'active',
capacity integer,
description text,
created_at timestamptz default now()
);
- Click Run (or press Cmd+Enter on macOS, Ctrl+Enter on Windows). You should see a success message at the bottom.
What does this table store?
Each row in events represents one career fair. The date_start and date_end columns let fairs span multiple days. The state column holds the Malaysian state (e.g. Johor, Penang) so you can filter and detect scheduling conflicts by region later.
The status column defaults to active and will later support a cancelled state for soft-deleting events without losing their data.
- Clear the SQL editor, then create the registrations table by pasting and running this SQL:
-- Create the registrations table to capture interest
create table registrations (
id uuid primary key default gen_random_uuid(),
event_id uuid references events(id),
name text not null,
email text not null,
created_at timestamptz default now()
);
- Click Run. You should see the same success message.
What does the registrations table do?
When a visitor clicks "Register Interest" on a career fair, their name and email get stored here along with the event_id linking back to the specific event. The foreign key reference means the database enforces that every registration points to a real event.
- Verify your tables exist by clicking Table Editor in the left sidebar. You should see both events and registrations listed.
Tables not showing up?
- Make sure you selected the public schema in the Table Editor dropdown. Supabase defaults to this schema, but double-check.
- If you see an error in the SQL Editor, check for typos in column names or missing commas between column definitions.
- Help me debug my Supabase tables
Connect Supabase to your Next.js app
Your database exists but your app doesn't know how to reach it yet. You need two things: environment variables that store your project's URL and API key, and utility files that create properly configured Supabase clients.
- In your Supabase dashboard, click Connect in the top navigation bar.
- Select the Frameworks tab, then choose Next.js.
- You'll see your NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY values displayed. Keep this page open.
- In your code editor, create a new file called .env.local in the root of your talentbank-calendar folder. Paste the following, replacing the placeholder values with your actual values from the Supabase dashboard:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-anon-key
Why these specific variable names?
The NEXT_PUBLIC_ prefix tells Next.js to make these values available in both server and browser code. Without it, the values would only be accessible on the server.
Supabase's publishable key is safe to expose in the browser because Row Level Security controls what data each user can access.
- Switch to your second terminal tab and navigate back to the project folder, then restart the dev server to pick up the new environment variables:
cd ~/Desktop/talentbank-calendar
npm run dev
- Refresh http://localhost:3000 in your browser. The starter page should still load without errors, confirming your environment variables are valid.
Seeing an error after adding .env.local?
- Make sure the file is named exactly .env.local (with the leading dot) and sits in the project root, not inside src/ or any subfolder.
- Confirm there are no quotes around the values. The format should be KEY=value with no spaces around the = sign.
- Help me fix my .env.local configuration
Now create the utility files that your app will use to talk to Supabase.
- In your code editor, create a new folder called utils in the project root, then create a supabase folder inside it.
- Inside utils/supabase/, create a file called client.ts and paste the following code:
import { createBrowserClient } from '@supabase/ssr'
// Creates a Supabase client for use in browser (Client Components)
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
}
What does this file do?
This creates a Supabase client configured for browser-side code (React Client Components). The createBrowserClient function handles session management automatically, so calling createClient() multiple times across your app always returns a properly configured instance.
- Inside utils/supabase/, create a second file called server.ts and paste the following code:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
// Creates a Supabase client for use in server code (Server Components, Route Handlers)
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 {}
}
}
}
)
}
What does the server client do differently?
Server Components can't access browser cookies directly. This utility reads cookies from the incoming request so the Supabase client can detect the user's session.
The setAll function is wrapped in a try/catch because Server Components can't write cookies. The actual cookie writing happens through a proxy file you'll add in a later step.
- Save both files.
- Check your browser at http://localhost:3000. The starter page should still load without any errors. The dev server auto-detects new files.
Seeing a compile error?
- Check that your file paths are exactly utils/supabase/client.ts and utils/supabase/server.ts in the project root.
- Make sure you installed both @supabase/ssr and @supabase/supabase-js earlier. Run npm list @supabase/ssr to confirm it's installed.
- Help me debug my Supabase client files
✔️ Awesome, I've got everything!
Great. Double check you've saved both utility files and your .env.local.
ⓧ I'd like to double check the full code
Here is utils/supabase/client.ts:
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
}
Here is utils/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_PUBLISHABLE_KEY!,
{
cookies: {
getAll() { return cookieStore.getAll() },
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {}
}
}
}
)
}
And .env.local (with your own values):
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-anon-key
Confirm everything is wired up
Before you run the final check, what do you expect to see when you visit http://localhost:3000? Think about whether anything visible has changed from the starter page.
- Visit http://localhost:3000 in your browser. The default Next.js starter page loads without errors.
- In the Supabase dashboard, click Table Editor in the left sidebar. You should see both events and registrations tables listed with their columns.
Why does the page look the same?
The starter page still looks identical because you haven't changed any visible UI yet. The important part is that no errors appear, which means your app, database, and connection are all in place and ready to use.
Your foundation is solid. You have a running Next.js app, a Supabase database with the right tables, and client utilities ready to connect the two. Next up, you'll bring that data to life with an interactive calendar that visitors can actually see and use.
Display the Public Calendar
Your database has the right tables, but right now that data is invisible. Visitors have no way to see what career fairs are happening or when they take place.
In this step, you will build the core public-facing experience. You will render an interactive month-view calendar powered by FullCalendar, pull live event data from Supabase, and let visitors filter and explore events.
In this step, get ready to:
- Render a FullCalendar month-view calendar with colour-coded events fetched from Supabase.
- Add filter dropdowns for category, audience, and state that narrow the visible events.
- Wire up event click to show a detail panel with a Register Interest form that writes to the database.
Seed sample events and render the calendar
Before you can see anything on the calendar, you need data in the events table. You will add a few sample career fairs, then build the components that display them.
- In your Supabase dashboard, open the SQL Editor and run this query to insert seed events:
INSERT INTO events (title, date_start, date_end, venue, state, category, audience, field, status, capacity, description)
VALUES
('KL Tech Career Fair 2026', '2026-09-15', '2026-09-15', 'KLCC Convention Centre', 'Kuala Lumpur', 'Meet Employers', 'For Candidates', 'Technology', 'active', 200, 'Meet top tech employers in KL.'),
('Penang Networking Night', '2026-09-22', '2026-09-22', 'Penang Digital Library', 'Penang', 'Networking', 'Open to All', 'Open to all fields', 'active', 100, 'Network with professionals across industries.'),
('Johor Mentorship Day', '2026-10-05', '2026-10-05', 'Johor Innovation Hub', 'Johor', 'Get Mentored', 'For Candidates', 'Engineering', 'active', 50, 'One-on-one mentorship sessions with industry leaders.'),
('Selangor Skills Workshop', '2026-10-12', '2026-10-12', 'Shah Alam Convention Centre', 'Selangor', 'Learn Skills', 'Open to All', 'Technology', 'active', 80, 'Hands-on workshops for in-demand tech skills.');
What does this SQL do?
Each row represents one career fair with a category from Talentbank's real taxonomy (Meet Employers, Networking, Get Mentored, Learn Skills) and a Malaysian state. The calendar will use these categories for colour-coding.
- Click Run in the SQL Editor.
- Switch to the Table Editor and open the events table. You should see four rows with the seed data you just inserted.
Don't see any rows?
Make sure you selected the correct project in the top-left dropdown of the Supabase dashboard. Also check for red error text in the SQL Editor output panel. A common issue is a typo in a column name.
help me debug why my seed SQL did not insert rows into the events table
Now you have data to display. Next, build the components that render the calendar.
- In your code editor, create a new folder called components inside the app directory.
- Create a new file app/components/CalendarView.tsx and paste the following code:
'use client'
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/react/daygrid'
import '@fullcalendar/react/skeleton.css'
// Colour map for event categories
const categoryColors: Record<string, string> = {
'Meet Employers': '#3b82f6',
'Networking': '#22c55e',
'Get Mentored': '#a855f7',
'Learn Skills': '#f97316',
}
export default function CalendarView({ events }: { events: any[] }) {
// Transform events into FullCalendar's expected format
const calendarEvents = events.map((event) => ({
id: event.id,
title: event.title,
start: event.date_start,
end: event.date_end,
backgroundColor: categoryColors[event.category] || '#6b7280',
borderColor: categoryColors[event.category] || '#6b7280',
}))
return (
<FullCalendar
plugins={[dayGridPlugin]}
initialView="dayGridMonth"
events={calendarEvents}
height="auto"
/>
)
}
What does this component do?
The 'use client' directive marks this as a client component because FullCalendar needs browser APIs. The colour map assigns a distinct colour to each of Talentbank's four event categories.
The calendarEvents transformation converts your Supabase row shape into the start/end format that FullCalendar expects. Each event dot on the calendar gets the background colour matching its category.
- Now replace the contents of app/page.tsx with this server component that fetches events and renders the calendar:
import { createClient } from '@/utils/supabase/server'
import CalendarView from './components/CalendarView'
export default async function Home() {
const supabase = await createClient()
// Fetch all active events from the database
const { data: events } = await supabase
.from('events')
.select('*')
.eq('status', 'active')
return (
<main className="max-w-6xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">
Talentbank Career Fairs
</h1>
<CalendarView events={events || []} />
</main>
)
}
Why a server component for the page?
The page fetches data on the server using the Supabase server client you set up in Step 1. This means the database query runs before the HTML reaches the browser. The fetched events array is passed as a prop to the client-side CalendarView component that handles the interactive rendering.
- Save both files.
- Switch to your browser and refresh http://localhost:3000.
You should see a month-view calendar with coloured event dots on the dates you seeded. The "KL Tech Career Fair 2026" appears in blue, "Penang Networking Night" in green, "Johor Mentorship Day" in purple, and "Selangor Skills Workshop" in orange.
Seeing a blank page or an error?
If you see a module-not-found error mentioning @fullcalendar/react/daygrid, confirm your package.json includes the FullCalendar packages and that you ran npm install after adding them in Step 1.
If the calendar renders but shows no events, check that your .env.local has the correct Supabase URL and key, and that the seed data exists in your events table.
help me debug why my FullCalendar is not rendering events from Supabase
Add filter controls for category, audience, and state
Talentbank runs 50+ fairs a year across Malaysia. Without filters, the calendar becomes a wall of dots. You will add dropdown filters matching Talentbank's actual taxonomy so visitors can narrow events by what matters to them.
- Open app/components/CalendarView.tsx and replace its entire contents with the updated version below. First, paste the imports and constants section:
'use client'
import { useState } from 'react'
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/react/daygrid'
import '@fullcalendar/react/skeleton.css'
// Colour map for event categories
const categoryColors: Record<string, string> = {
'Meet Employers': '#3b82f6',
'Networking': '#22c55e',
'Get Mentored': '#a855f7',
'Learn Skills': '#f97316',
}
// Filter options matching Talentbank's taxonomy
const categories = ['Meet Employers', 'Networking', 'Get Mentored', 'Learn Skills']
const audiences = ['For Candidates', 'For Employers', 'Open to All']
const states = ['Johor', 'Klang Valley', 'Kuala Lumpur', 'Nationwide',
'Negeri Sembilan', 'Penang', 'Perak', 'Sarawak', 'Selangor', 'Terengganu']
What are these filter arrays?
These arrays match the real categories, audiences, and Malaysian states that Talentbank uses on their career fairs page. Each array populates one dropdown filter on the calendar.
- Directly below the constants, paste the component function with filter logic:
export default function CalendarView({ events }: { events: any[] }) {
const [selectedCategory, setSelectedCategory] = useState('')
const [selectedAudience, setSelectedAudience] = useState('')
const [selectedState, setSelectedState] = useState('')
// Filter events based on active selections
const filteredEvents = events.filter((event) => {
if (selectedCategory && event.category !== selectedCategory) return false
if (selectedAudience && event.audience !== selectedAudience) return false
if (selectedState && event.state !== selectedState) return false
return true
})
// Transform filtered events for FullCalendar
const calendarEvents = filteredEvents.map((event) => ({
id: event.id,
title: event.title,
start: event.date_start,
end: event.date_end,
backgroundColor: categoryColors[event.category] || '#6b7280',
borderColor: categoryColors[event.category] || '#6b7280',
}))
- Finally, paste the return statement that renders the filter dropdowns and calendar:
return (
<div>
<div className="flex gap-4 mb-6 flex-wrap">
<select value={selectedCategory} onChange={(e) => setSelectedCategory(e.target.value)} className="border rounded px-3 py-2">
<option value="">All Categories</option>
{categories.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<select value={selectedAudience} onChange={(e) => setSelectedAudience(e.target.value)} className="border rounded px-3 py-2">
<option value="">All Audiences</option>
{audiences.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<select value={selectedState} onChange={(e) => setSelectedState(e.target.value)} className="border rounded px-3 py-2">
<option value="">All States</option>
{states.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<FullCalendar
plugins={[dayGridPlugin]}
initialView="dayGridMonth"
events={calendarEvents}
height="auto"
/>
</div>
)
}
How does the filtering work?
Each dropdown is a controlled React component tied to a useState variable. When a visitor selects "Networking" from the category dropdown, filteredEvents recalculates and only events with category === 'Networking' pass through. The calendar re-renders instantly with just the matching events.
- Save CalendarView.tsx and refresh your browser.
- You should see three dropdown filters above the calendar. Select "Networking" from the first dropdown.
Only the green "Penang Networking Night" event remains visible. Select "All Categories" to bring all four events back.
Filters appear but don't hide events?
Check that the category values in your seed data exactly match the strings in the categories array (case-sensitive). For example, "Meet Employers" must match precisely.
help me debug why my calendar filters are not hiding events when I select a category
Wire up event click and the Register Interest form
Visitors can now see and filter events, but they cannot interact with them. Clicking an event should open a detail panel where they can read about the fair and register their interest.
- Open app/components/CalendarView.tsx and add the following import at the top, below the existing imports:
import EventDetailPanel from './EventDetailPanel'
- Inside the component function, add a state variable for the selected event. Find the line const [selectedState, setSelectedState] = useState('') and add this line below it:
const [selectedEvent, setSelectedEvent] = useState<any>(null)
- In the <FullCalendar> component, add the eventClick prop below events={calendarEvents}:
eventClick={(info) => {
const event = events.find((e) => e.id === info.event.id)
if (event) setSelectedEvent(event)
}}
- After the closing </div> of the FullCalendar wrapper but before the final closing </div> and ), add the detail panel render:
{selectedEvent && (
<EventDetailPanel
event={selectedEvent}
onClose={() => setSelectedEvent(null)}
/>
)}
How does eventClick work?
FullCalendar fires the eventClick callback with an info object containing info.event.id. You look up the full event data from your original array (which has all the Supabase columns) and store it in state. When selectedEvent is not null, the detail panel renders.
- Save CalendarView.tsx. Next, create the detail panel. Create a new file app/components/EventDetailPanel.tsx and paste the following:
'use client'
import RegisterInterestForm from './RegisterInterestForm'
export default function EventDetailPanel(
{ event, onClose }: { event: any; onClose: () => void }
) {
return (
<div className="fixed inset-0 bg-black/50 flex justify-end z-50"
onClick={onClose}>
<div className="bg-white w-96 p-6 overflow-y-auto"
onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} className="text-gray-500 float-right">
Close
</button>
<h2 className="text-xl font-bold mb-2">{event.title}</h2>
<p className="text-sm text-gray-600 mb-1">
{event.date_start} to {event.date_end}
</p>
<p className="text-sm text-gray-600 mb-1">{event.venue}</p>
<p className="text-sm text-gray-600 mb-3">{event.state}</p>
<p className="mb-4">{event.description}</p>
<h3 className="font-semibold mb-2">Register Interest</h3>
<RegisterInterestForm eventId={event.id} />
</div>
</div>
)
}
What does EventDetailPanel do?
The panel slides in from the right as a fixed overlay. Clicking the dark backdrop or the Close button calls onClose to clear the selected event. The e.stopPropagation() on the white panel prevents clicks inside it from triggering the backdrop's close handler.
- Create one more file: app/components/RegisterInterestForm.tsx with the following code:
'use client'
import { useState } from 'react'
import { createClient } from '@/utils/supabase/client'
export default function RegisterInterestForm(
{ eventId }: { eventId: string }
) {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const supabase = createClient()
// Insert registration into the database
await supabase
.from('registrations')
.insert({ event_id: eventId, name, email })
setSubmitted(true)
}
if (submitted) {
return <p className="text-green-600">Thanks for registering!</p>
}
return (
<form onSubmit={handleSubmit} className="space-y-3">
<input type="text" placeholder="Your name" value={name}
onChange={(e) => setName(e.target.value)} required
className="border rounded px-3 py-2 w-full" />
<input type="email" placeholder="Your email" value={email}
onChange={(e) => setEmail(e.target.value)} required
className="border rounded px-3 py-2 w-full" />
<button type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded">
Register Interest
</button>
</form>
)
}
What does RegisterInterestForm do?
This form uses the browser Supabase client (not the server one) because it runs in the browser after user interaction. On submit, it inserts a row into the registrations table with the event_id, the visitor's name, and their email. After a successful insert, it swaps to a confirmation message.
- Save all three files.
- Refresh your browser at http://localhost:3000.
Before you click an event, what do you think will happen? Will you see the detail panel slide in from the right, or will it appear as a centered modal?
- Click any coloured event on the calendar.
A panel slides in from the right showing the event title, date, venue, state, and description. Below the details you see the Register Interest form.
- Type a test name and email into the form and click Register Interest.
- You should see a green "Thanks for registering!" confirmation message.
- Switch to the Supabase dashboard and open the Table Editor for the registrations table. You should see a new row with your test name, email, and the matching event_id.
Form submits but no row appears in registrations?
Open your browser's DevTools console (F12) and check for errors. A common cause is that the registrations table does not have the anon role allowed to insert. In the Supabase dashboard, go to Authentication > Policies and check that a policy allows inserts on registrations for the anon role. Alternatively, ensure RLS is disabled on this table for now (you will lock things down in Step 5).
help me debug why my Supabase insert into registrations table is silently failing
✔️ Awesome, I've got everything!
Great. Make sure you have saved all four files: app/page.tsx, app/components/CalendarView.tsx, app/components/EventDetailPanel.tsx, and app/components/RegisterInterestForm.tsx.
ⓧ I'd like to double check the full code
Your project now has four key files built in this step. Scroll up and confirm each of these sections is present in order:
- app/page.tsx - server component with Supabase fetch and CalendarView render.
- app/components/CalendarView.tsx - imports (including useState, FullCalendar, dayGridPlugin, EventDetailPanel), colour map, filter arrays, component with three filter states plus selectedEvent, filter logic, calendarEvents map, return with dropdowns, FullCalendar with eventClick, and the conditional EventDetailPanel.
- app/components/EventDetailPanel.tsx - slide-in panel showing event details and containing RegisterInterestForm.
- app/components/RegisterInterestForm.tsx - form with name and email inputs that inserts into the registrations table.
Your public calendar is live. Visitors can browse career fairs by month, filter by category, audience, or state, click into event details, and register their interest. Next up, you will build the admin panel so the events team can manage the calendar without touching code.
Build the Admin Panel with CRUD Forms
Your public calendar is live and visitors can browse events. But right now, the only way to add or change events is by writing SQL directly in the Supabase dashboard.
In this step, you'll build a dedicated admin panel where the events team can create new career fairs, edit existing ones, and cancel events through simple forms. No SQL or code required on their end.
In this step, get ready to:
- Create an event form with fields for title, dates, venue, state, category, audience, field, capacity, and description.
- Build an event list table in the admin panel with edit and cancel actions.
- Implement a cancel action that soft-deletes events and marks them with strikethrough on the public calendar.
Create the event form component
The form needs to handle both creating new events and editing existing ones. You'll build a single reusable component that switches behaviour based on a mode prop.
- Create the folder structure for the admin panel by running this command:
mkdir -p app/admin/components app/admin/actions app/admin/new "app/admin/edit/[id]"
- Confirm the folders exist by running:
ls app/admin
You should see actions, components, edit, and new listed.
- Create the reusable event form by adding this code to app/admin/components/EventForm.tsx. Start with the imports, dropdown options, and component type:
'use client'
import { useState } from 'react'
import { createClient } from '@/utils/supabase/client'
import { useRouter } from 'next/navigation'
// Talentbank's event taxonomy for dropdown menus
const CATEGORIES = ['Meet Employers', 'Networking', 'Get Mentored', 'Learn Skills', 'Other']
const AUDIENCES = ['Everyone', 'For Candidates', 'For Employers', 'Open to All']
const STATES = ['Johor', 'Klang Valley', 'Kuala Lumpur', 'Nationwide',
'Negeri Sembilan', 'Penang', 'Perak', 'Sarawak', 'Selangor', 'Terengganu']
const FIELDS = ['Open to all fields', 'Technology', 'Engineering']
type EventFormProps = {
mode: 'create' | 'edit'
initialData?: any
}
What do these arrays represent?
Each array matches Talentbank's real event taxonomy. When the form renders, these arrays populate the dropdown menus so the admin picks from consistent options instead of typing free text.
- Continue building the EventForm component by adding the state management and handlers below the code you just wrote:
export default function EventForm({ mode, initialData }: EventFormProps) {
const router = useRouter()
const supabase = createClient()
// Pre-fill with existing data in edit mode, empty values in create mode
const [formData, setFormData] = useState({
title: initialData?.title || '',
date_start: initialData?.date_start || '',
date_end: initialData?.date_end || '',
venue: initialData?.venue || '',
state: initialData?.state || '',
category: initialData?.category || '',
audience: initialData?.audience || '',
field: initialData?.field || '',
capacity: initialData?.capacity || 0,
description: initialData?.description || '',
})
- Add the submit handler and change handler directly below the useState block:
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (mode === 'create') {
// Insert a new event row into Supabase
await supabase.from('events').insert(formData)
} else {
// Update the existing event by its id
await supabase.from('events').update(formData).eq('id', initialData?.id)
}
router.push('/admin')
router.refresh()
}
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: name === 'capacity' ? parseInt(value) || 0 : value
}))
}
What does this code do?
The handleSubmit function checks the mode prop. In create mode it inserts a new row. In edit mode it updates the row matching the event's id.
After saving, router.push('/admin') navigates back to the admin list and router.refresh() re-fetches fresh data from Supabase.
- Add the form's opening JSX with the title input, date pickers, and venue field below handleChange:
return (
<form onSubmit={handleSubmit} className="max-w-2xl space-y-4 p-6">
<h1 className="text-2xl font-bold">
{mode === 'create' ? 'Create New Event' : 'Edit Event'}
</h1>
<input name="title" value={formData.title} onChange={handleChange}
placeholder="Event title" required className="w-full border p-2 rounded" />
<div className="grid grid-cols-2 gap-4">
<input name="date_start" type="date" value={formData.date_start}
onChange={handleChange} required className="border p-2 rounded" />
<input name="date_end" type="date" value={formData.date_end}
onChange={handleChange} required className="border p-2 rounded" />
</div>
<input name="venue" value={formData.venue} onChange={handleChange}
placeholder="Venue" className="w-full border p-2 rounded" />
- Add the dropdown selects for state, category, and audience below the venue input, still inside the <form> tag:
<select name="state" value={formData.state} onChange={handleChange}
className="w-full border p-2 rounded">
<option value="">Select state</option>
{STATES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
<select name="category" value={formData.category} onChange={handleChange}
className="w-full border p-2 rounded">
<option value="">Select category</option>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<select name="audience" value={formData.audience} onChange={handleChange}
className="w-full border p-2 rounded">
<option value="">Select audience</option>
{AUDIENCES.map(a => <option key={a} value={a}>{a}</option>)}
</select>
- Add the final fields and submit button to close the form and component:
<select name="field" value={formData.field} onChange={handleChange}
className="w-full border p-2 rounded">
<option value="">Select field</option>
{FIELDS.map(f => <option key={f} value={f}>{f}</option>)}
</select>
<input name="capacity" type="number" value={formData.capacity}
onChange={handleChange} placeholder="Capacity"
className="w-full border p-2 rounded" />
<textarea name="description" value={formData.description}
onChange={handleChange} placeholder="Event description"
className="w-full border p-2 rounded h-24" />
<button type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
{mode === 'create' ? 'Create Event' : 'Save Changes'}
</button>
</form>
)
}
- Create the page that renders this form in create mode. Add this code to app/admin/new/page.tsx:
import EventForm from '../components/EventForm'
// This page renders the create-event form at /admin/new
export default function NewEventPage() {
return <EventForm mode="create" />
}
- Save both files.
- In your browser, navigate to http://localhost:3000/admin/new.
You should see a form with text inputs for title and venue, date pickers for start and end dates, dropdown menus for state, category, audience, and field, a capacity number input, and a description textarea.
- Fill in the form with a test event. Use "Test Career Fair" as the title, pick any dates, select "Johor" as the state, and "Meet Employers" as the category.
- Click Create Event.
- Switch to the Supabase dashboard and open the events table. Confirm the new row appeared with the values you entered.
Form submits but no row appears?
- Check that your .env.local contains the correct NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY values.
- Verify that Row Level Security is disabled on the events table. If RLS is on, inserts from the browser will be silently blocked.
- Open DevTools and check the Network tab for any failed requests to Supabase.
Still stuck? Why is my event form not saving to Supabase?
Build the admin event list with edit functionality
The events team needs to see all their events in one place. You'll build a table showing every event with buttons to edit or cancel each one.
- Create the admin event list component by adding this code to app/admin/components/AdminEventList.tsx. Start with imports and types:
'use client'
import Link from 'next/link'
import { createClient } from '@/utils/supabase/client'
import { useRouter } from 'next/navigation'
type Event = {
id: string
title: string
date_start: string
date_end: string
state: string
category: string
status: string
}
type AdminEventListProps = {
events: Event[]
}
- Add the component function with the cancel handler and header below the type definitions:
export default function AdminEventList({ events }: AdminEventListProps) {
const router = useRouter()
const supabase = createClient()
// Set event status to cancelled (soft delete)
async function handleCancel(id: string) {
await supabase.from('events').update({ status: 'cancelled' }).eq('id', id)
router.refresh()
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">All Events</h1>
<Link href="/admin/new"
className="bg-blue-600 text-white px-4 py-2 rounded">
+ New Event
</Link>
</div>
- Add the table structure with headers and row mapping below the header <div>:
<table className="w-full border-collapse border">
<thead>
<tr className="bg-gray-100">
<th className="border p-2 text-left">Title</th>
<th className="border p-2">Start</th>
<th className="border p-2">End</th>
<th className="border p-2">State</th>
<th className="border p-2">Category</th>
<th className="border p-2">Status</th>
<th className="border p-2">Actions</th>
</tr>
</thead>
<tbody>
{events.map(event => (
<tr key={event.id} className={event.status === 'cancelled'
? 'line-through text-gray-400' : ''}>
- Add the table cell data and action buttons, then close all tags to complete the component:
<td className="border p-2">{event.title}</td>
<td className="border p-2">{event.date_start}</td>
<td className="border p-2">{event.date_end}</td>
<td className="border p-2">{event.state}</td>
<td className="border p-2">{event.category}</td>
<td className="border p-2">{event.status}</td>
<td className="border p-2 space-x-2">
<Link href={`/admin/edit/${event.id}`}
className="text-blue-600 hover:underline">Edit</Link>
{event.status !== 'cancelled' && (
<button onClick={() => handleCancel(event.id)}
className="text-red-600 hover:underline">Cancel</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
What does this code do?
The AdminEventList component renders a table of all events. Each row shows the event's key details plus two action buttons.
The Edit link navigates to /admin/edit/[id] where the form pre-fills with that event's data. The Cancel button calls handleCancel which sets the event's status to cancelled in Supabase.
- Create the admin landing page that fetches events and passes them to the list. Add this code to app/admin/page.tsx:
import { createClient } from '@/utils/supabase/server'
import AdminEventList from './components/AdminEventList'
// Server component: fetches all events for the admin table
export default async function AdminPage() {
const supabase = await createClient()
const { data: events } = await supabase
.from('events')
.select('id, title, date_start, date_end, state, category, status')
.order('date_start', { ascending: true })
return <AdminEventList events={events || []} />
}
- Save both files.
- Navigate to http://localhost:3000/admin in your browser.
You should see a table listing all your events (including the test event you created earlier) with columns for title, start date, end date, state, category, status, and action links.
Seeing an empty table?
- Make sure you have at least one event in the events table. Check the Supabase dashboard to confirm your seed data or test event is there.
- Confirm that app/admin/page.tsx imports from @/utils/supabase/server and not the browser client.
Need help? Why is my admin event list showing an empty table?
- Now create the edit page that pre-fills the form with existing event data. Add this code to app/admin/edit/[id]/page.tsx:
import { createClient } from '@/utils/supabase/server'
import EventForm from '../../components/EventForm'
// Server component: fetches a single event and renders the edit form
export default async function EditEventPage(
{ params }: { params: { id: string } }
) {
const supabase = await createClient()
const { data: event } = await supabase
.from('events')
.select('*')
.eq('id', params.id)
.single()
return <EventForm mode="edit" initialData={event} />
}
- Save the file.
- Return to http://localhost:3000/admin and click Edit on any event.
The edit form loads with all fields pre-filled with that event's current values. Change the venue to something new and click Save Changes. You'll be redirected back to the admin list where the updated venue shows in the table.
Add cancel action with strikethrough on the public calendar
Cancelling an event should not delete it from the database. Instead, it sets the status to cancelled so the record is preserved for reporting.
On the public calendar, cancelled events appear with a strikethrough style so visitors know they are no longer happening.
- The cancel logic is already wired into AdminEventList.tsx via the handleCancel function. Now you need to update the public calendar to display cancelled events with strikethrough styling.
- In app/page.tsx, find the Supabase query that fetches events. It currently filters for active events only. Find this line:
.eq('status', 'active')
- Remove the .eq('status', 'active') filter so the query fetches all events including cancelled ones. The query should end after the .select() or .order() call with no status filter.
- Next, update app/components/CalendarView.tsx to apply strikethrough styling to cancelled events. Find the section where you map events to FullCalendar event objects and add a CSS class for cancelled events:
// When mapping events to FullCalendar format, add className for cancelled
className: event.status === 'cancelled' ? 'line-through opacity-50' : ''
What does this change accomplish?
By removing the active-only filter, cancelled events now appear on the public calendar. The line-through opacity-50 classes make them visually distinct so visitors can see the event existed but is no longer running.
This is a soft-delete pattern. The data stays in the database for record-keeping, but the UI signals that the event is cancelled.
- Save both files.
- Navigate to http://localhost:3000/admin.
- Click Cancel on your test event.
The event's status in the table changes to cancelled and the row text appears with a strikethrough style.
- Before you check the public calendar, what do you think the cancelled event will look like? Will it disappear entirely, or will it still show with a visual difference?
- Navigate to http://localhost:3000 to view the public calendar.
The cancelled event still appears on the calendar but with a strikethrough style and reduced opacity. Active events remain unchanged.
Cancelled event not showing strikethrough?
- Confirm you removed the .eq('status', 'active') filter from app/page.tsx. If it is still there, cancelled events will not be fetched at all.
- Check that your CalendarView.tsx passes the className property when mapping events to the FullCalendar event format.
- FullCalendar uses classNames (plural) in some configurations. If className does not work, try classNames: ['line-through', 'opacity-50'] as an array instead.
Still stuck? Help me debug my cancelled event strikethrough styling on FullCalendar
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files in the app/admin/ folder.
ⓧ I'd like to double check the full code
Here are the key files you created in this step. Scroll up and confirm each one is present and matches:
- app/admin/components/EventForm.tsx (form with all 10 fields, handles create and edit modes).
- app/admin/components/AdminEventList.tsx (table with Edit links and Cancel buttons).
- app/admin/page.tsx (server component fetching all events).
- app/admin/new/page.tsx (renders EventForm in create mode).
- app/admin/edit/[id]/page.tsx (fetches event by id, renders EventForm in edit mode).
- app/components/CalendarView.tsx (updated with strikethrough class for cancelled events).
- app/page.tsx (updated to fetch all events including cancelled).
Your admin panel is fully functional. The events team can create, edit, and cancel career fairs through simple forms, and every change shows up immediately on the public calendar. Next up, you'll add conflict detection so the team gets a warning when they accidentally schedule two events in the same state on the same day.
Add Conflict Detection
Your admin panel lets the events team create, edit, and cancel career fairs. With Talentbank running 50+ fairs per year across Malaysia, the team will inevitably try to schedule two events in the same state on the same date.
Without a warning, attendees get split between competing fairs and everyone loses. In this step, you will build conflict detection that catches overlapping events before they go live.
In this step, get ready to:
- Test the current form to see that overlapping events save silently.
- Write a conflict detection utility that queries Supabase for overlapping active events in the same state.
- Wire up conflict warnings in the admin form with an override option.
Test the overlap gap
Right now, the admin form silently accepts overlapping events. Let's see this in action.
- Switch back to your browser and navigate to /admin/new.
- Create a test event with the state set to Johor and any date range (e.g., tomorrow's date for both start and end).
- After it saves, navigate to /admin/new again and create a second event also in Johor on the same date.
Before you submit the second event, do you expect the form to warn you about the overlap?
It saves without any complaint. Both events now sit on the same date in the same state, and nobody was warned. That silent acceptance is the gap you will close next.
Create the conflict detection utility
Date-range overlap detection uses a classic interval comparison: two events overlap when one starts before the other ends AND ends after the other starts. You will wrap this logic in a reusable utility function.
- Create a new file called utils/checkConflicts.ts and add the following code:
import { createClient } from '@/utils/supabase/client'
// Check for active events in the same state with overlapping dates
export async function checkConflicts({ state, date_start, date_end, excludeId }: {
state: string
date_start: string
date_end: string
excludeId?: string
}) {
const supabase = createClient()
const params = { state, date_start, date_end, excludeId }
let query = supabase
.from('events')
.select('id, title')
.eq('state', params.state)
.neq('status', 'cancelled')
.lte('date_start', params.date_end)
.gte('date_end', params.date_start)
// Exclude the current event when editing
if (params.excludeId) {
query = query.neq('id', params.excludeId)
}
const { data, error } = await query
if (error) {
console.error('Error checking conflicts:', error)
return []
}
return data || []
}
What does this code do?
The checkConflicts function queries the events table for rows where the state matches, the status is not cancelled, and the date ranges overlap. The overlap logic is: existing.date_start <= new.date_end AND existing.date_end >= new.date_start.
When editing an existing event, excludeId removes that event from the results so it does not conflict with itself. The function returns an array of conflicting event objects containing id and title.
- Save utils/checkConflicts.ts.
Seeing a TypeScript error on the import?
Make sure you have a tsconfig.json path alias mapping @/* to your project root. The default Next.js scaffold includes this. If it is missing, check that your tsconfig.json contains "paths": { "@/*": ["./*"] } under compilerOptions.
help me fix this TypeScript path alias error
Wire conflict warnings into the event form
The utility exists, but nothing calls it yet. You will update EventForm.tsx to check for conflicts when the admin selects a state and date range, then display a warning banner with an override option.
- In app/admin/components/EventForm.tsx, add the import for checkConflicts at the top of the file alongside your existing imports:
import { checkConflicts } from '@/utils/checkConflicts'
- Inside the component function, add these state variables below your existing state declarations:
// Track conflict warnings and override status
const [conflictWarnings, setConflictWarnings] = useState<
{ id: string; title: string }[]
>([])
const [overrideConflicts, setOverrideConflicts] = useState(false)
- Below the state variables, add the conflict check handler function:
// Run conflict check when state or dates change
async function handleConflictCheck() {
if (!formData.state || !formData.date_start || !formData.date_end) return
const conflicts = await checkConflicts({
state: formData.state,
date_start: formData.date_start,
date_end: formData.date_end,
excludeId: eventId,
})
setConflictWarnings(conflicts)
setOverrideConflicts(false)
}
What does this handler do?
The handleConflictCheck function only runs when all three required fields (state, start date, end date) have values. It passes eventId as excludeId so that in edit mode, the event does not conflict with itself. Whenever new conflicts are found, the override flag resets to false so the admin must explicitly choose to override again.
- Find the state, date_start, and date_end input fields in the form JSX. Add an onBlur handler to each one that calls handleConflictCheck:
// Add onBlur={handleConflictCheck} to the state, date_start, and date_end inputs
<select
name="state"
value={formData.state}
onChange={handleChange}
onBlur={handleConflictCheck}
>
{/* ...state options... */}
</select>
<input
type="date"
name="date_start"
value={formData.date_start}
onChange={handleChange}
onBlur={handleConflictCheck}
/>
<input
type="date"
name="date_end"
value={formData.date_end}
onChange={handleChange}
onBlur={handleConflictCheck}
/>
Why onBlur instead of onChange?
Using onBlur triggers the conflict check when the admin finishes editing a field and moves on. This avoids firing a database query on every keystroke while still catching conflicts before the form is submitted.
- Now add the warning banner JSX. Place this above the submit button inside the form:
{conflictWarnings.length > 0 && (
<div className="bg-yellow-50 border border-yellow-300 rounded p-4">
<p className="font-semibold text-yellow-800">
Scheduling conflict detected:
</p>
<ul className="list-disc ml-4 text-yellow-700">
{conflictWarnings.map((event) => (
<li key={event.id}>{event.title}</li>
))}
</ul>
<button
type="button"
onClick={() => setOverrideConflicts(true)}
className="mt-2 text-sm underline text-yellow-800"
>
Override and save anyway
</button>
</div>
)}
What does the warning banner show?
The yellow banner appears only when conflictWarnings contains at least one event. It lists the titles of all conflicting events. The override button sets overrideConflicts to true, which unlocks the submit. Some overlaps are intentional (e.g., events for different audiences), so the admin always has the final say.
- Finally, add a guard at the top of your existing handleSubmit function to block saving when conflicts exist and the override has not been clicked:
// Block save if conflicts exist and admin hasn't overridden
if (conflictWarnings.length > 0 && !overrideConflicts) {
return
}
- Save app/admin/components/EventForm.tsx.
The create form and the edit form both use the same EventForm component. Because you added conflict detection here, it works automatically in both modes. In edit mode, excludeId prevents the event from flagging itself.
Warning banner not appearing?
Make sure the onBlur={handleConflictCheck} attribute is on all three fields: state, date_start, and date_end. The check only fires when all three have values, so fill in all three fields and then click outside the last one.
Also confirm your Supabase events table has at least one active event in the same state you are testing. Open the Supabase dashboard to verify the seed data is still there.
help me debug why my conflict warning banner is not showing
✔️ Awesome, I've got everything!
Great. Double check that you have saved both utils/checkConflicts.ts and app/admin/components/EventForm.tsx.
ⓧ I'd like to double check the full code
Scroll up and confirm each addition is present in the correct file, in order:
- utils/checkConflicts.ts contains the full checkConflicts function with the Supabase query.
- EventForm.tsx has the import, the two state variables, the handleConflictCheck function, onBlur on the three inputs, the yellow warning banner JSX, and the submit guard.
Time to test. Before you run this, do you think the form will warn you when you create another event in Johor on the same date as your earlier test event?
- Return to your browser and navigate to /admin/new.
- Fill in the form with state set to Johor and the same date range you used for your earlier test event.
- Click outside the date field to trigger the blur event.
A yellow warning banner appears listing the title of your earlier Johor event. The form blocks saving until you click Override and save anyway.
- Click Override and save anyway and then submit the form.
- The event saves successfully despite the overlap.
- Now navigate to /admin/edit/ for one of your Johor events and change its date to a week with no other events in Johor.
- Click outside the date field.
The warning banner disappears. The conflict detection works in both create and edit modes.
Your calendar now catches scheduling mistakes before they cause problems on event day. Next up, you will lock the admin panel behind authentication so only the events team can make changes.
Protect the Admin with Authentication
Your admin panel works, but there is a problem. Anyone who discovers the /admin URL can create, edit, or cancel events.
In this step, you will lock down the admin panel with Supabase Auth email/password login. You will also add Row Level Security policies so the database itself enforces who can read and who can write.
In this step, get ready to:
- Set up Supabase Auth and create an admin user account.
- Build a login page and protect all admin routes with middleware.
- Enable Row Level Security so only authenticated users can modify events.
Enable email/password auth and create an admin user
Supabase Auth handles user sign-up, sign-in, and session tokens out of the box. You just need to enable the email provider and create a user for your events team.
- Open the Supabase dashboard for your project.
- In the left sidebar, click Authentication.
- Click Providers in the sub-navigation.
- Confirm that Email is enabled (it is by default on new projects).
Now create the admin user account that will be used to log in.
- Click Users in the Authentication sub-navigation.
- Click Add user, then Create new user.
- Enter an email address and a strong password you will remember.
- Toggle Auto Confirm User on so the account is immediately active.
- Click Create user.
You should see the new user appear in the Users list with a confirmed status.
Why create the user in the dashboard?
For this project, you only need one admin account. Creating it directly in the Supabase dashboard avoids building a sign-up flow that would let anyone register as an admin.
Build the login page
The login page is a client component that collects an email and password, then calls Supabase's signInWithPassword method. On success it redirects to the admin panel.
- Create a new file at app/admin/login/page.tsx and add the component logic:
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/utils/supabase/client'
export default function AdminLoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const router = useRouter()
const supabase = createClient()
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setError('')
// Attempt to sign in with email and password
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
return
}
// Redirect to admin dashboard on success
router.push('/admin')
}
What does this code do?
- It uses the browser Supabase client from utils/supabase/client.ts (already set up in Step 1).
- signInWithPassword sends the credentials to Supabase Auth and receives a session token stored in cookies.
- On success, router.push('/admin') sends the user to the protected admin dashboard.
- On failure, the error message from Supabase is displayed below the form.
- Still in the same file, add the return statement with the form JSX below the handleLogin function:
return (
<main style={{ maxWidth: 400, margin: '100px auto' }}>
<h1>Admin Login</h1>
<form onSubmit={handleLogin}>
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit">Sign In</button>
</form>
</main>
)
}
What does this JSX render?
- A centered card with an email input, a password input, and a Sign In button.
- If error state is set (from a failed login attempt), a red error message appears above the button.
- Save app/admin/login/page.tsx.
- Visit http://localhost:3000/admin/login in your browser. You should see a simple form with Email and Password fields and a Sign In button.
Don't see the login page?
- Make sure the file path is exactly app/admin/login/page.tsx (the login folder sits inside app/admin/).
- Confirm your dev server is still running with npm run dev.
Help me debug my admin login page not loading.
Protect admin routes with middleware
The login page exists, but nothing stops someone from navigating straight to /admin without signing in. Next.js middleware runs before every matched request, so it is the right place to check for a valid session and redirect unauthenticated visitors.
- Create a new file called middleware.ts in the root of your project (the same level as package.json). Add the imports and Supabase client setup:
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
// Create a response we can modify
let response = NextResponse.next({
request: { headers: request.headers },
})
// Create a Supabase client using cookies from the 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) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
response = NextResponse.next({
request: { headers: request.headers },
})
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
)
},
},
}
)
What does this setup do?
- It creates a server-side Supabase client that reads and writes session cookies from the incoming request.
- The setAll callback writes cookies to both the request (for downstream Server Components) and the response (for the browser).
- Still in middleware.ts, add the session validation and route matcher below the Supabase client:
// Validate the session using getClaims
const { data: { claims } } = await supabase.auth.getClaims()
// If no valid session, redirect to login
if (!claims) {
const url = request.nextUrl.clone()
url.pathname = '/admin/login'
return NextResponse.redirect(url)
}
return response
}
// Only run on /admin routes, excluding the login page itself
export const config = {
matcher: ['/admin/((?!login).*)'],
}
What does the session check do?
- getClaims() validates the JWT signature against your project's public keys. It cannot be spoofed.
- If there are no valid claims (no session), the middleware redirects to /admin/login.
- The matcher config ensures the middleware only runs on /admin/* paths but excludes /admin/login so unauthenticated users can still reach the login form.
- Save middleware.ts.
Before you test, what do you think will happen if you visit /admin without being signed in?
- Open an incognito/private browser window and navigate to http://localhost:3000/admin.
You are redirected to /admin/login. The middleware detected no valid session and blocked access.
Still seeing the admin panel without logging in?
- Make sure middleware.ts is at the project root, not inside app/.
- Restart your dev server after creating the middleware file. Next.js does not always hot-reload middleware changes.
- Confirm you are using a fresh incognito window with no existing Supabase session cookies.
Help me debug why my middleware is not redirecting unauthenticated users.
Now add a layout for the admin panel that includes a Logout button and a second session check.
- Create app/admin/layout.tsx with the following code:
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
import AdminNav from './components/AdminNav'
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
// Double-check session on the server side
const { data: { claims } } = await supabase.auth.getClaims()
if (!claims) {
redirect('/admin/login')
}
return (
<div>
<AdminNav />
{children}
</div>
)
}
Why check the session in the layout too?
Middleware is the first line of defence. The layout adds a second check directly in the server component rendering tree. This is a defence-in-depth pattern recommended by Supabase. If middleware is bypassed or misconfigured, the layout still prevents rendering.
The layout references an AdminNav component that holds the Logout button.
- Create app/admin/components/AdminNav.tsx with the following code:
'use client'
import { useRouter } from 'next/navigation'
import { createClient } from '@/utils/supabase/client'
export default function AdminNav() {
const router = useRouter()
const supabase = createClient()
async function handleLogout() {
// Sign out and redirect to the login page
await supabase.auth.signOut()
router.push('/admin/login')
}
return (
<nav style={{ padding: '1rem', borderBottom: '1px solid #ccc' }}>
<strong>Talentbank Admin</strong>
<button onClick={handleLogout} style={{ marginLeft: '1rem' }}>
Logout
</button>
</nav>
)
}
What does AdminNav do?
- It renders a navigation bar with the project name and a Logout button.
- Clicking Logout calls supabase.auth.signOut() to clear the session, then redirects to the login page.
- Save both app/admin/layout.tsx and app/admin/components/AdminNav.tsx.
- Return to your incognito window at /admin/login. Sign in with the email and password you created in the Supabase dashboard.
- After signing in, you should land on /admin and see the admin event list with a Logout button in the navigation bar.
Sign-in redirects back to the login page?
- This usually means the session cookie is not being written properly. Restart your dev server to pick up any middleware changes.
- Make sure you toggled Auto Confirm User when creating the user. Unconfirmed users cannot sign in.
Help me debug why signing in redirects me back to the login page.
Enable Row Level Security on the events table
Authentication controls who can reach the admin UI, but the database itself has no access rules yet. Anyone with your project's publishable key could call the Supabase API directly and modify data. Row Level Security lets you define who can read and write at the database level.
- In the Supabase dashboard, click SQL Editor in the left sidebar.
- Click New query.
- Paste the following SQL and click Run:
-- Enable Row Level Security on the events table
ALTER TABLE events ENABLE ROW LEVEL SECURITY;
-- Allow anyone (including unauthenticated visitors) to read events
CREATE POLICY "Public can view events"
ON events
FOR SELECT
TO anon, authenticated
USING (true);
-- Only authenticated users can insert new events
CREATE POLICY "Authenticated users can insert events"
ON events
FOR INSERT
TO authenticated
WITH CHECK (true);
-- Only authenticated users can update events
CREATE POLICY "Authenticated users can update events"
ON events
FOR UPDATE
TO authenticated
USING (true)
WITH CHECK (true);
-- Only authenticated users can delete events
CREATE POLICY "Authenticated users can delete events"
ON events
FOR DELETE
TO authenticated
USING (true);
What do these policies do?
- The SELECT policy grants read access to both the anon role (unauthenticated visitors browsing the public calendar) and the authenticated role.
- The INSERT, UPDATE, and DELETE policies restrict write operations to the authenticated role only. Even if someone uses the API key directly, they cannot modify data without a valid login session.
Middleware is the locked door. RLS is the safe inside the house.
- After running the query, you should see Success. No rows returned in the SQL Editor output.
Before you verify, what do you think will happen on the public calendar now that RLS is enabled?
- Open a separate incognito tab (not signed in) and navigate to http://localhost:3000.
- The public calendar still loads and displays all events. The SELECT policy grants read access to the anon role, so unauthenticated visitors can still browse.
Public calendar shows no events after enabling RLS?
- Check that your SELECT policy includes TO anon, authenticated. If you only specified TO authenticated, unauthenticated visitors are blocked from reading.
- In the Supabase dashboard, go to Table Editor, select the events table, and check the Policies tab to confirm the SELECT policy is listed.
Help me fix my RLS policies so the public calendar can read events.
Now run the full verification to confirm everything works end to end.
- Open /admin in an incognito window without logging in. You are redirected to /admin/login.
- Log in with your admin credentials. You can now access the admin panel and create events.
- Open the public calendar in a separate incognito tab (no login) and confirm events still load.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all your files.
ⓧ I'd like to double check the full code
Here are the key files you created in this step. Scroll up and confirm each one is present and saved.
- middleware.ts at the project root with the matcher for /admin/((?!login).*).
- app/admin/login/page.tsx with the login form calling signInWithPassword.
- app/admin/layout.tsx checking getClaims() and rendering AdminNav.
- app/admin/components/AdminNav.tsx with the Logout button calling supabase.auth.signOut().
- RLS policies applied via the SQL Editor.
Your admin panel is now locked down. Only authenticated staff can create, edit, or cancel events, while the public calendar remains open for everyone to browse. Next up, you will tackle the Secret Mission and add capacity tracking to your events.
Secret mission
Add Capacity Tracking with Status Badges
Career fairs have physical limits. When an event fills up, continuing to accept registrations creates a mess on event day. In this stretch challenge, you'll add real-world capacity management with dynamic status badges and auto-close registration.
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 runs on the Supabase free tier, so there are no ongoing costs. Your Supabase project will auto-pause after 1 week of inactivity.
Resources you used:
- Supabase project (free tier, 500 MB database, includes the events and registrations tables, auth configuration, and RLS policies).
- Local project folder (talentbank-calendar on your Desktop).
Keep everything running
No action needed. Choose this if you are still actively building or plan to deploy to a hosting service soon.
- Your Supabase project stays active on the free tier. It auto-pauses after 1 week of inactivity and resumes when you access it again.
- Your local talentbank-calendar folder remains on your Desktop.
- To resume development, open a terminal, navigate to ~/Desktop/talentbank-calendar, and run npm run dev.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and database stay intact for when you return.
- Stop the dev server by pressing Ctrl+C in the terminal where npm run dev is running.
- Your Supabase project will auto-pause after 1 week of inactivity. No manual action needed.
- When you return, run npm run dev from the talentbank-calendar folder. Your Supabase project resumes automatically when you access it.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your database, events data, and registration records.
- Delete your Supabase project:
- Open the Supabase dashboard and select your project.
- Navigate to Settings > General > Delete project.
- Click Delete Project, enter your project name to confirm, then click Delete.
- Delete your local project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf talentbank-calendar
On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force talentbank-calendar
- Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir ~\Desktop (Windows). You should no longer see talentbank-calendar in the output.
Nice Work!
Nice Work!
You just built a full-stack career fair calendar that a real events team could use to manage 50+ fairs per year. Visitors browse and register interest, admins manage everything through forms, and the system catches scheduling conflicts before they cause chaos.
Here's what you learned:
- Built a full-year interactive calendar with FullCalendar v7 and Next.js, pulling live data from a Supabase PostgreSQL database and filtering events by category, audience, and state.
- Created a password-protected admin panel with full CRUD forms, secured by Supabase Auth and Row Level Security so only authenticated staff can modify events.
- Implemented conflict detection that checks for overlapping events in the same state and warns the admin before saving, with an override option for intentional overlaps.
- Secret Mission: Added capacity tracking with dynamic status badges (Open, Filling Up, Full) and auto-close registration when events hit their limit.
Ready to quiz yourself?