Build a Full-Stack PWA with Supabase
Build a role-based attendance app that installs on phones via PWA.
Introduction
30 Second Summary
Weekend schools run multiple classes with different teachers, and tracking which students show up each week often falls to paper sign-in sheets. Those records are hard to organize, easy to lose, and impossible to search.
In this project, you will build a school attendance system that runs as a Progressive Web App on any phone. Admins create classes and manage students, teachers mark attendance with a few taps, and all records are stored in a cloud database.
What You'll Build
A teacher opens the app on their phone on Saturday morning, taps into their assigned class, and marks each student present or absent in seconds.
How the pieces fit together
- Frontend — Vite scaffolds a vanilla JS Progressive Web App that installs on any phone.
- Backend — Supabase provides email/password auth, a PostgreSQL database, and Row Level Security to control who sees what.
- Hosting — Cloudflare Pages serves the static build on a global CDN for free.
By the end of this project, you'll have:
- An admin dashboard where you create weekend classes, assign a teacher to each one, and manage student rosters.
- A teacher attendance view with a tap-based present/absent toggle for each student, usable on any phone without an app store download.
- A production-ready app deployed on Cloudflare Pages with a Supabase backend handling authentication, a PostgreSQL database, and Row Level Security, all running at $0/month for 50 users.
- Secret Mission: Add date-range filtering and per-class attendance percentage summaries to the records view.
Are there any prerequisites?
You should be comfortable with basic JavaScript and HTML/CSS. You will also need a code editor and Node.js installed on your computer.
Before We Start
Before we dive in, take a moment to lock in what you're about to build and why it matters for your weekend school.
Set Up the Project and Database
Before you can build any features, you need two foundations in place: a running development environment and a database ready to store your app's data.
In this step, you will scaffold a Vite project, set up a Supabase database with the tables your attendance system depends on, and create the app shell that everything else builds on.
In this step, get ready to:
- Scaffold a Vite project and install dependencies.
- Create a Supabase project with the database schema.
- Configure the PWA plugin and create the app shell files.
Scaffold the Vite project and install dependencies
Vite is a fast build tool that serves your app during development and bundles it for production. You will use its vanilla JavaScript template to create the project skeleton.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold the project by running this command:
npm create vite@latest school-attendance -- --template vanilla
What will this command ask?
The scaffold will ask Install with npm and start now? Select No for now. You will install dependencies manually in the next commands so you can add the extra packages your project needs.
- Move into the new project folder by running:
cd school-attendance
- Install all dependencies, including the Supabase client library and the PWA plugin, by running these commands:
npm install
npm install @supabase/supabase-js
npm install -D vite-plugin-pwa
What are these packages?
@supabase/supabase-js is the JavaScript client that lets your app talk to your Supabase database and authentication service.
vite-plugin-pwa turns your Vite app into a Progressive Web App so teachers can install it on their phones from the browser.
- Start the development server to verify the scaffold worked by running:
npm run dev
Your browser opens to http://localhost:5173 and shows the default Vite starter page.
- Stop the dev server by pressing Ctrl+C in the terminal. You will replace the starter files before running it again.
Don't see the Vite page?
- Make sure you ran npm install before npm run dev. Without the install step, Vite itself is not downloaded.
- Check that your terminal is inside the school-attendance folder. Run pwd to confirm.
- If port 5173 is already in use, Vite will pick the next available port and print the URL in the terminal.
Still stuck? Help me troubleshoot my Vite dev server
Create a Supabase project and database schema
Your attendance system stores everything in a Supabase database: user profiles, classes, students, and attendance records. You need four tables and Row Level Security policies so the exposed API key cannot be misused.
- Open https://supabase.com in your browser and sign up for a free account (or sign in if you already have one).
- Click New Project.
- Enter a project name (for example, school-attendance).
- Set a strong database password and keep it somewhere safe.
- Choose a region close to you.
- Click Create new project and wait for the project to finish provisioning. This takes about one minute.
Once your project is ready, you will create the database tables using SQL.
- In the left sidebar of your Supabase dashboard, click SQL Editor.
- Click New Query.
- Paste the following SQL to create your four tables:
-- Profiles table links to Supabase auth users
create table profiles (
id uuid references auth.users on delete cascade primary key,
full_name text not null,
role text not null check (role in ('admin', 'teacher'))
);
-- Classes table stores each class and its assigned teacher
create table classes (
id uuid default gen_random_uuid() primary key,
name text not null,
teacher_id uuid references profiles(id)
);
-- Students table stores student names linked to a class
create table students (
id uuid default gen_random_uuid() primary key,
full_name text not null,
class_id uuid references classes(id)
);
-- Attendance table records each student's status per day
create table attendance (
id uuid default gen_random_uuid() primary key,
student_id uuid references students(id),
class_id uuid references classes(id),
date date not null,
status text not null check (status in ('present', 'absent')),
marked_by uuid references profiles(id)
);
- Click Run (or press Cmd+Enter on macOS, Ctrl+Enter on Windows).
You should see a success message confirming the tables were created.
What do these tables do?
- profiles stores each user's name and role (admin or teacher), linked to their Supabase Auth account.
- classes holds each class name and which teacher is assigned to it.
- students tracks student names and which class they belong to.
- attendance records whether each student was present or absent on a given date.
- Now enable Row Level Security and add access policies. Click New Query again in the SQL Editor, paste the following SQL, and click Run:
-- Enable Row Level Security on all tables
alter table profiles enable row level security;
alter table classes enable row level security;
alter table students enable row level security;
alter table attendance enable row level security;
-- Allow any logged-in user to read all tables
create policy "Authenticated read" on profiles for select to authenticated using (true);
create policy "Authenticated read" on classes for select to authenticated using (true);
create policy "Authenticated read" on students for select to authenticated using (true);
create policy "Authenticated read" on attendance for select to authenticated using (true);
-- Allow logged-in users to insert into classes, students, and attendance
create policy "Authenticated insert" on classes for insert to authenticated with check (true);
create policy "Authenticated insert" on students for insert to authenticated with check (true);
create policy "Authenticated insert" on attendance for insert to authenticated with check (true);
Why is Row Level Security important?
Your Supabase API key is bundled into the client-side code, which means anyone could inspect it. Row Level Security ensures the database itself enforces who can read and write data, regardless of whether someone has the API key.
- Verify the tables exist by clicking Table Editor in the left sidebar.
You should see profiles, classes, students, and attendance listed in the table editor.
Now copy your project credentials so your app can connect to Supabase.
- Click Connect at the top of your project dashboard. Copy the Project URL and paste it somewhere you can grab it in a moment.
- In the left sidebar, click Settings, then API Keys. Copy the anon (publishable) key.
Keep these values handy
You will paste the Project URL and anon key into your code in the next substep. The anon key is safe to include in client-side code because Row Level Security protects the data.
🙋♀️ Can't find the API keys?
- Make sure your project has finished provisioning. The settings page is not available until the project is ready.
- If you see Publishable key instead of anon, use the publishable key. It serves the same purpose.
Still stuck? Help me find my Supabase project URL and API key
Configure the PWA plugin and create the app files
The Vite scaffold created starter files, but you need to replace them with your app shell. You will also add the PWA configuration so the app can be installed on phones.
- Open the school-attendance folder in your code editor.
- Create a new file called vite.config.js in the root of the school-attendance folder and paste the following code:
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'School Attendance System',
short_name: 'Attendance',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#4f46e5',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/icon-512.png',
sizes: '512x512',
type: 'image/png'
}
]
}
})
]
})
What does this config do?
- registerType: 'autoUpdate' makes the app silently update when you deploy new code.
- display: 'standalone' hides the browser chrome so the app looks like a native app on the phone.
- The icons array tells the phone which images to use for the home screen shortcut. You can add placeholder PNGs later.
- Save vite.config.js.
- Now replace the contents of index.html (the file the scaffold created) with the following app shell:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>School Attendance System</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<div id="app">
<header>
<h1>School Attendance System</h1>
<button id="sign-out-btn" class="hidden">Sign Out</button>
</header>
<main id="main-content"></main>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
What is this HTML doing?
The #app div wraps the entire application. The header shows the title and a sign-out button (hidden by default). The #main-content element is where JavaScript will render different views depending on the user's role.
- Save index.html, then start the dev server to check your heading:
npm run dev
Refresh your browser at http://localhost:5173. You should see "School Attendance System" as a heading on a blank page.
Still seeing the old Vite starter page?
- Make sure you saved index.html after replacing its contents.
- Try a hard refresh: Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
Still stuck? Help me troubleshoot my index.html changes
Leave the dev server running. You will see your changes appear live as you create the remaining files.
- Open src/supabase.js (create this file inside the src folder) and paste the following code:
import { createClient } from '@supabase/supabase-js'
// Replace these with the values from your Supabase project
const supabaseUrl = 'your-supabase-url-here'
const supabaseKey = 'your-supabase-anon-key-here'
export const supabase = createClient(supabaseUrl, supabaseKey)
- Replace your-supabase-url-here with the Project URL you copied from the Supabase dashboard.
- Replace your-supabase-anon-key-here with the anon (publishable) key you copied.
- Save src/supabase.js.
Why is the anon key safe in client code?
The anon key only grants access that your Row Level Security policies allow. Without RLS, anyone with the key could read or write your entire database. With RLS enabled, the key is limited to exactly the permissions you defined.
- Replace the contents of src/style.css (the scaffold created this file) with the base styles. Paste this first block of styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #1a1a1a;
}
#app {
max-width: 600px;
margin: 0 auto;
padding: 1rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 2px solid #4f46e5;
margin-bottom: 1rem;
}
header h1 {
font-size: 1.25rem;
color: #4f46e5;
}
- Save src/style.css and check your browser.
The heading should now be indigo-colored with a bottom border. The page background is a light gray.
- Add the button and form styles below the existing CSS in src/style.css:
.hidden {
display: none;
}
button {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
#sign-out-btn {
background: #ef4444;
color: white;
}
.login-form {
max-width: 320px;
margin: 2rem auto;
}
.login-form h2 {
margin-bottom: 1rem;
}
.login-form input {
width: 100%;
padding: 0.75rem;
margin-bottom: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
}
What are these styles for?
The .hidden class toggles element visibility. The button and login form styles will take effect when you build the login form in Step 2.
- Continue adding styles to src/style.css for the login button, status messages, and tab navigation:
.login-form button {
width: 100%;
background: #4f46e5;
color: white;
padding: 0.75rem;
font-size: 1rem;
}
.error {
color: #ef4444;
margin-top: 0.5rem;
}
.success {
color: #22c55e;
margin-top: 0.5rem;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tab {
background: #e5e7eb;
color: #374151;
flex: 1;
}
.tab.active {
background: #4f46e5;
color: white;
}
- Add the form and list styles below the tab styles in src/style.css:
form {
margin-bottom: 1.5rem;
}
form input,
form select {
width: 100%;
padding: 0.5rem;
margin-bottom: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
}
form button {
background: #4f46e5;
color: white;
width: 100%;
}
ul {
list-style: none;
margin-bottom: 1rem;
}
li {
padding: 0.5rem;
border-bottom: 1px solid #e5e7eb;
}
li.present {
border-left: 3px solid #22c55e;
padding-left: 0.75rem;
}
li.absent {
border-left: 3px solid #ef4444;
padding-left: 0.75rem;
}
- Add the final block of styles for the attendance interface and headings at the bottom of src/style.css:
.student-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid #e5e7eb;
}
.status-toggle {
display: flex;
gap: 0.25rem;
}
.toggle-btn {
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
border-radius: 4px;
background: #e5e7eb;
color: #374151;
}
.toggle-btn.active.present-btn {
background: #22c55e;
color: white;
}
.toggle-btn.active.absent-btn {
background: #ef4444;
color: white;
}
- Add the last few styles for the submit button and section headings:
#submit-attendance {
width: 100%;
background: #4f46e5;
color: white;
padding: 0.75rem;
margin-top: 1rem;
font-size: 1rem;
}
#submit-attendance:disabled {
background: #9ca3af;
cursor: not-allowed;
}
h3 {
margin-bottom: 0.75rem;
color: #374151;
}
h4 {
margin: 0.5rem 0;
color: #6b7280;
}
- Save src/style.css and check your browser. The heading should still look styled with the indigo color and border.
The rest of the styles will take effect as you build the login form, admin dashboard, and attendance interface in later steps.
- Now create the router. Replace the contents of src/main.js with the first part of the app logic, the imports and role-fetching function:
import { supabase } from './supabase.js'
import { renderLogin } from './auth.js'
import { renderAdminDashboard } from './admin.js'
import { renderTeacherDashboard } from './teacher.js'
// Cache DOM references
const mainContent = document.getElementById('main-content')
const signOutBtn = document.getElementById('sign-out-btn')
// Fetch the user's role from the profiles table
async function getUserRole(userId) {
const { data } = await supabase
.from('profiles')
.select('role')
.eq('id', userId)
.single()
return data?.role
}
What does this code do?
The imports bring in the Supabase client and three view functions you will build in later steps. The getUserRole() function queries the profiles table to determine if a user is an admin or teacher, which decides which dashboard they see.
- Add the authentication handler and event listeners below the getUserRole function in src/main.js:
// Route user to the correct view based on their role
async function handleAuthState(session) {
if (!session) {
signOutBtn.classList.add('hidden')
renderLogin(mainContent)
return
}
signOutBtn.classList.remove('hidden')
const role = await getUserRole(session.user.id)
if (role === 'admin') {
renderAdminDashboard(mainContent, session.user.id)
} else if (role === 'teacher') {
renderTeacherDashboard(mainContent, session.user.id)
}
}
// Sign out when the button is clicked
signOutBtn.addEventListener('click', async () => {
await supabase.auth.signOut({ scope: 'local' })
})
// Listen for login/logout events and route accordingly
supabase.auth.onAuthStateChange((event, session) => {
handleAuthState(session)
})
What does this code do?
handleAuthState() checks whether a user is logged in. If not, it shows the login form. If so, it fetches their role and renders the matching dashboard.
onAuthStateChange() fires every time the user logs in, logs out, or refreshes the page. It keeps the app in sync with the authentication state.
The imports in main.js reference three files that do not exist yet: auth.js, admin.js, and teacher.js. You need to create placeholder files so the app does not crash.
- Create src/auth.js with a placeholder export:
// Placeholder - built in Step 2
export function renderLogin() {}
- Create src/admin.js with a placeholder export:
// Placeholder - built in Step 3
export async function renderAdminDashboard() {}
- Create src/teacher.js with a placeholder export:
// Placeholder - built in Step 4
export async function renderTeacherDashboard() {}
- Save all three files.
Before you check the result, what do you expect to see in the browser? The app imports worked, but no user is logged in.
- Check your browser at http://localhost:5173.
You should see the "School Attendance System" heading with an indigo underline. The page content area is blank because no user is logged in and the login form is just a placeholder. The Sign Out button is hidden.
Seeing an error in the browser?
- If you see a module import error, make sure all three placeholder files (src/auth.js, src/admin.js, src/teacher.js) exist and export the correct function names.
- If the browser console shows a Supabase connection error, double-check that you pasted the correct Project URL and anon key in src/supabase.js.
Still stuck? Help me fix my module import errors
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files. Your project should have vite.config.js, index.html, src/supabase.js, src/main.js, src/style.css, and three placeholder files.
ⓧ I'd like to double check the full code
Scroll up and confirm each code block is present in your files, in order:
- vite.config.js: PWA plugin configuration with manifest.
- index.html: app shell with header, sign-out button, and main-content element.
- src/supabase.js: createClient with your project URL and anon key.
- src/style.css: all six style blocks pasted in order (reset/layout, buttons/login, login-button/tabs, forms/lists, student-row/toggles, submit/headings).
- src/main.js: both code blocks (imports + getUserRole, then handleAuthState + event listeners).
- src/auth.js, src/admin.js, src/teacher.js: placeholder exports.
Your development environment is wired up and your database is ready. Next up, you will build the login form and route users to different dashboards based on their role.
Build Login and Role-Based Views
In Step 1, you set up the project scaffolding and the database schema. The app shows a heading, but it has no way to identify who is using it.
Without authentication, there is no way to separate admins from teachers. In this step, you will add email and password login using Supabase Auth and route each user to the correct dashboard based on their role.
In this step, get ready to:
- Build a login form that authenticates users with Supabase.
- Create admin and teacher dashboard shells with tab navigation.
- Seed test users and verify role-based routing.
Build the login form and wire up Supabase Auth
Your src/main.js already imports a renderLogin function from src/auth.js. That file does not exist yet, so the browser is showing an error. You will create it now.
- Create a new file called src/auth.js in your school-attendance project and paste this code:
import { supabase } from './supabase.js'
export function renderLogin(container) {
// Render the sign-in form HTML
container.innerHTML = `
<div class="login-form">
<h2>Sign In</h2>
<form id="login-form">
<input type="email" id="email" placeholder="Email" required />
<input type="password" id="password" placeholder="Password" required />
<button type="submit">Sign In</button>
<p id="login-error" class="error hidden"></p>
</form>
</div>
`
// Handle form submission
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault()
const email = document.getElementById('email').value
const password = document.getElementById('password').value
const errorEl = document.getElementById('login-error')
// Authenticate with Supabase
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
errorEl.textContent = error.message
errorEl.classList.remove('hidden')
}
})
}
What does this code do?
The renderLogin function injects a simple email and password form into the page. When the form is submitted, it calls supabase.auth.signInWithPassword() to authenticate the user.
If the credentials are wrong, the error message from Supabase is displayed below the form. If they are correct, main.js picks up the auth state change and routes the user to their dashboard automatically.
- Save src/auth.js.
- Check your browser. You should see an error in the console because main.js also imports from src/admin.js and src/teacher.js, which do not exist yet.
Why is the browser still showing an error?
JavaScript modules must resolve all imports before any code runs. Since main.js imports from three files and only one exists so far, the browser cannot load the app. You will fix this by creating the two remaining files next.
Create the admin and teacher dashboard shells
Each role needs its own dashboard. The admin sees tabs for managing classes, students, and records. The teacher sees tabs for taking attendance and viewing history.
- Create a new file called src/admin.js and paste this code:
import { supabase } from './supabase.js'
export async function renderAdminDashboard(container) {
// Render tab navigation for admin sections
container.innerHTML = `
<nav class="tabs">
<button class="tab active" data-tab="classes">Classes</button>
<button class="tab" data-tab="students">Students</button>
<button class="tab" data-tab="records">Records</button>
</nav>
<div id="tab-content"></div>
`
// Add click handlers to switch between tabs
const tabs = container.querySelectorAll('.tab')
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'))
tab.classList.add('active')
const tabName = tab.dataset.tab
if (tabName === 'classes') renderClassesTab()
else if (tabName === 'students') renderStudentsTab()
else if (tabName === 'records') renderRecordsTab()
})
})
// Show the Classes tab by default
renderClassesTab()
}
What does this code do?
The renderAdminDashboard function creates three tab buttons: Classes, Students, and Records. Clicking a tab calls the matching render function.
Those tab-content functions (renderClassesTab, renderStudentsTab, renderRecordsTab) do not exist yet. You will build them in Step 3. For now, the tab buttons will appear but clicking them will show a console error.
- Save src/admin.js.
- Now create a new file called src/teacher.js and paste this code at the top:
import { supabase } from './supabase.js'
export async function renderTeacherDashboard(container, userId) {
// Fetch the teacher's assigned class with its students
const { data: classes } = await supabase
.from('classes')
.select('*, students(*)')
.eq('teacher_id', userId)
const myClass = classes?.[0]
// Show a fallback if no class is assigned
if (!myClass) {
container.innerHTML = '<p>No class assigned to you yet. Contact your admin.</p>'
return
}
// Check if attendance was already submitted today
const today = new Date().toISOString().split('T')[0]
const { data: existingRecords } = await supabase
.from('attendance')
.select('id')
.eq('class_id', myClass.id)
.eq('date', today)
const alreadySubmitted = existingRecords && existingRecords.length > 0
- Continue by pasting this code directly below, still inside the same function:
// Render the tab navigation
container.innerHTML = `
<nav class="tabs">
<button class="tab active" data-tab="attendance">Take Attendance</button>
<button class="tab" data-tab="history">History</button>
</nav>
<div id="tab-content"></div>
`
// Set up tab click handlers
const tabs = container.querySelectorAll('.tab')
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'))
tab.classList.add('active')
if (tab.dataset.tab === 'attendance') renderAttendanceForm(myClass, today, alreadySubmitted, userId)
else renderTeacherHistory(myClass)
})
})
renderAttendanceForm(myClass, today, alreadySubmitted, userId)
}
What does this code do?
The renderTeacherDashboard function fetches the teacher's assigned class and its students from Supabase. If no class is assigned, it shows a fallback message.
It also checks whether attendance has already been submitted for today. This prevents duplicate submissions later. The two tabs (Take Attendance and History) call renderAttendanceForm and renderTeacherHistory. You will build those functions in Step 4.
- Save src/teacher.js.
- Return to your browser and refresh the page.
You should see the login form with an email field, a password field, and a Sign In button. The import errors are gone because all three files now exist.
Still seeing a blank page or an error?
- Make sure all three files (src/auth.js, src/admin.js, src/teacher.js) are saved inside the src folder.
- Check that each file starts with import { supabase } from './supabase.js'. A missing import causes the module to fail.
- Open the browser console (F12 or Cmd+Option+I) and look for the specific error message.
Still stuck? Help me debug why my login form is not showing up.
✔️ 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
Scroll up and confirm both code blocks for src/teacher.js are in your file, in order. The function opens with export async function renderTeacherDashboard and closes with a } after renderAttendanceForm(myClass, today, alreadySubmitted, userId).
For src/auth.js, here is the full file:
import { supabase } from './supabase.js'
export function renderLogin(container) {
container.innerHTML = `
<div class="login-form">
<h2>Sign In</h2>
<form id="login-form">
<input type="email" id="email" placeholder="Email" required />
<input type="password" id="password" placeholder="Password" required />
<button type="submit">Sign In</button>
<p id="login-error" class="error hidden"></p>
</form>
</div>
`
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault()
const email = document.getElementById('email').value
const password = document.getElementById('password').value
const errorEl = document.getElementById('login-error')
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
errorEl.textContent = error.message
errorEl.classList.remove('hidden')
}
})
}
For src/admin.js, here is the full file:
import { supabase } from './supabase.js'
export async function renderAdminDashboard(container) {
container.innerHTML = `
<nav class="tabs">
<button class="tab active" data-tab="classes">Classes</button>
<button class="tab" data-tab="students">Students</button>
<button class="tab" data-tab="records">Records</button>
</nav>
<div id="tab-content"></div>
`
const tabs = container.querySelectorAll('.tab')
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'))
tab.classList.add('active')
const tabName = tab.dataset.tab
if (tabName === 'classes') renderClassesTab()
else if (tabName === 'students') renderStudentsTab()
else if (tabName === 'records') renderRecordsTab()
})
})
renderClassesTab()
}
Seed test users in the Supabase dashboard
The login form is ready, but there are no user accounts to log in with. You need to create two test users in the Supabase dashboard and link each one to a role in the profiles table.
- Open the Supabase dashboard for your project.
- Click Authentication in the left sidebar.
- Click Users (it should already be selected).
- Click Add user and create a new user with email admin@test.com and a password you will remember.
- Click Add user again and create a second user with email teacher@test.com and a password you will remember.
Pick simple passwords
These are test accounts for development only. Use a short, memorable password like password123. You will delete these accounts when you clean up.
Both users now exist in Supabase Auth, but the app does not know their roles yet. You need to insert rows into the profiles table that link each user's ID to a role.
- In the Users list, find admin@test.com and copy its User UID (the UUID shown in the rightmost column).
- Do the same for teacher@test.com.
- Click SQL Editor in the left sidebar.
- Paste this SQL and replace the placeholder UUIDs with the ones you copied:
-- Link each auth user to a role in the profiles table
insert into profiles (id, full_name, role)
values ('[[ADMIN_USER_ID="your admin user UUID from the Users table"]]', 'Admin User', 'admin');
insert into profiles (id, full_name, role)
values ('[[TEACHER_USER_ID="your teacher user UUID from the Users table"]]', 'Teacher User', 'teacher');
What does this SQL do?
Each row in profiles links a Supabase Auth user (by their UUID) to a role. When someone logs in, main.js calls getUserRole() to look up this role and decide which dashboard to show.
The full_name column is a display name used later when assigning teachers to classes. The role column must be exactly admin or teacher to match the logic in handleAuthState().
- Click Run in the SQL Editor to execute both statements.
- You should see a success message confirming the rows were inserted.
Seeing a "duplicate key" or "violates foreign key" error?
- A duplicate key error means a profile row for that UUID already exists. Delete the existing row first or skip the insert.
- A foreign key error means the UUID you pasted does not match any user in auth.users. Go back to Authentication > Users and copy the UUID again carefully.
Still stuck? Help me fix my profiles insert error.
Now test the full login flow. Before you try it, think about this: what do you expect to see when you log in as the admin?
- Return to your browser at http://localhost:5173.
- Enter admin@test.com and the password you chose.
- Click Sign In.
You should see the admin dashboard with three tabs: Classes, Students, and Records. The red Sign Out button appears in the header.
- Click Sign Out.
- Log in again with teacher@test.com and the teacher password.
You should see the teacher dashboard with two tabs: Take Attendance and History. The app routed you to a different view based on your role.
- Refresh the page while logged in as the teacher.
You should still see the teacher dashboard. The session persists across page reloads because onAuthStateChange fires on load and restores the session automatically.
Login form shows an error message?
- Double check you typed the email exactly as you entered it in the Supabase dashboard. Emails are case-sensitive in some configurations.
- If you see Invalid login credentials, the password does not match. Try creating the user again in the dashboard.
- If you see the login form after signing in (it flashes and comes back), the profiles row might be missing. Check the SQL Editor to confirm both rows exist.
Still stuck? Help me figure out why login is not working.
Authentication and role-based routing are working. Next up, you will build the admin's class and student management features so there is real data for teachers to work with.
Admin Manages Classes and Students
In the last step, you built login and role-based routing so the admin and teacher each see their own dashboard. But those dashboards are empty shells with no real functionality.
Before any teacher can take attendance, the admin needs to create classes, assign teachers to them, and add students. In this step, you will build the class and student management interfaces that populate your Supabase database with real data.
In this step, get ready to:
- Build a class creation form with teacher assignment.
- Build a student creation form with class grouping.
Build the class management UI
The admin's Classes tab currently does nothing because the renderClassesTab() function the tab handler calls does not exist yet. You need a form to create new classes with a teacher assignment and a list that shows existing classes.
- In src/admin.js, find the closing brace } of the renderAdminDashboard function.
- Start the renderClassesTab function by adding this code below it:
async function renderClassesTab() {
const tabContent = document.getElementById('tab-content')
// Fetch all teachers for the dropdown
const { data: teachers } = await supabase
.from('profiles')
.select('id, full_name')
.eq('role', 'teacher')
// Fetch existing classes with teacher names
const { data: classes } = await supabase
.from('classes')
.select('*, profiles(full_name)')
// Build dropdown options from teacher list
const teacherOptions = (teachers || [])
.map(t => `<option value="${t.id}">${t.full_name}</option>`)
.join('')
// Build the class list HTML
const classList = (classes || [])
.map(c => `<li>${c.name} — ${c.profiles?.full_name || 'Unassigned'}</li>`)
.join('')
What does this code do?
This is the first half of the renderClassesTab function. It fetches data from Supabase and prepares the HTML strings that the second half will render.
- The first query fetches every profile with role = 'teacher' to populate the teacher assignment dropdown.
- The second query uses select('*, profiles(full_name)') to fetch classes and automatically join each class's teacher name from the profiles table.
- teacherOptions and classList convert the raw data into HTML strings ready to inject into the page.
- Complete the renderClassesTab function by adding this code directly below the previous block (still inside the same function):
// Render the form and existing classes list
tabContent.innerHTML = `
<h3>Create Class</h3>
<form id="class-form">
<input type="text" id="class-name" placeholder="Class name" required />
<select id="teacher-select" required>
<option value="">Select teacher</option>
${teacherOptions}
</select>
<button type="submit">Create Class</button>
</form>
<h3>Existing Classes</h3>
<ul id="class-list">${classList || '<li>No classes yet</li>'}</ul>
`
// Handle new class creation
document.getElementById('class-form').addEventListener('submit', async (e) => {
e.preventDefault()
const name = document.getElementById('class-name').value
const teacherId = document.getElementById('teacher-select').value
await supabase.from('classes').insert({ name, teacher_id: teacherId })
renderClassesTab()
})
}
What does this code do?
- tabContent.innerHTML renders a class creation form (name input + teacher dropdown) and a list of existing classes into the tab area.
- The form submit handler reads the class name and selected teacher, then calls supabase.from('classes').insert() to save the new class to the database.
- After inserting, the function calls itself (renderClassesTab()) to re-fetch and re-render the list with the new class included.
- Save src/admin.js.
- Switch to your browser. Make sure you are logged in as the admin account.
- Click the Classes tab. You should see a "Create Class" heading, a text input for the class name, a teacher dropdown, and a "No classes yet" message.
Don't see the form?
- Make sure you saved src/admin.js. Vite hot-reloads on save, but unsaved changes will not appear.
- Check your browser console for errors. A missing closing brace or a typo in renderClassesTab will show a syntax or reference error.
- If the teacher dropdown is empty, confirm that the teacher user's row in the profiles table has role set to teacher and has a value in full_name.
Still stuck? Ask for help.
Now test the form by creating your first class.
- Type Math 101 in the class name field.
- Select your teacher account from the dropdown.
- Click Create Class.
The page refreshes itself and you should now see "Math 101" listed under Existing Classes with the teacher's name next to it.
Build the student management UI
Classes exist, but they are empty. The admin needs a way to add students and assign each one to a class.
- In src/admin.js, find the closing brace } of the renderClassesTab function you just added.
- Start the renderStudentsTab function by adding this code below it:
async function renderStudentsTab() {
const tabContent = document.getElementById('tab-content')
// Fetch all classes for the dropdown
const { data: classes } = await supabase.from('classes').select('id, name')
// Fetch all students with their class names
const { data: students } = await supabase
.from('students')
.select('*, classes(name)')
.order('class_id')
// Build dropdown options from class list
const classOptions = (classes || [])
.map(c => `<option value="${c.id}">${c.name}</option>`)
.join('')
// Build the student list HTML
const studentList = (students || [])
.map(s => `<li>${s.full_name} — ${s.classes?.name}</li>`)
.join('')
What does this code do?
- The first query fetches all classes so the dropdown can show them as options when adding a student.
- select('*, classes(name)') fetches each student and joins their class name through the foreign key relationship.
- .order('class_id') groups students by class in the output so the list is organized.
- Complete the renderStudentsTab function by adding this code directly below the previous block (still inside the same function):
// Render the student form and existing students
tabContent.innerHTML = `
<h3>Add Student</h3>
<form id="student-form">
<input type="text" id="student-name" placeholder="Student name" required />
<select id="student-class" required>
<option value="">Select class</option>
${classOptions}
</select>
<button type="submit">Add Student</button>
</form>
<h3>All Students</h3>
<ul id="student-list">${studentList || '<li>No students yet</li>'}</ul>
`
// Handle new student form submission
document.getElementById('student-form').addEventListener('submit', async (e) => {
e.preventDefault()
const fullName = document.getElementById('student-name').value
const classId = document.getElementById('student-class').value
await supabase.from('students').insert({ full_name: fullName, class_id: classId })
renderStudentsTab()
})
}
What does this code do?
- The form takes a student name and a class selection. The class dropdown is populated from the classes you already created.
- On submit, supabase.from('students').insert() saves the student with their assigned class to the database.
- Like the classes tab, the function calls itself after insert to refresh the student list.
- Save src/admin.js.
- Switch to your browser and click the Students tab. You should see an "Add Student" form with a name input and a class dropdown showing "Math 101".
Students tab not loading?
- Check that the closing brace of renderClassesTab is on its own line and that renderStudentsTab starts after it. Accidentally nesting one function inside another causes a silent failure.
- If the class dropdown is empty, switch to the Classes tab and confirm Math 101 is still listed. If not, re-create it.
Still stuck? Ask for help.
Now add three students to your class.
- Type a student name (e.g. Alice Chen) in the name field.
- Select Math 101 from the class dropdown.
- Click Add Student.
- Repeat for two more students (e.g. Bob Patel and Clara Ruiz).
After each submission, the list refreshes and you should see the student appear under All Students with their class name next to them.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/admin.js.
ⓧ I'd like to double check the full code
Your src/admin.js file is now over 100 lines, so scroll through and confirm these sections are present in order:
- import { supabase } from './supabase.js' at the top.
- The renderAdminDashboard function (tab navigation + click handlers + calls renderClassesTab() at the end).
- The renderClassesTab function (fetches teachers + classes, renders form, handles submit).
- The renderStudentsTab function (fetches classes + students, renders form, handles submit).
Time to confirm everything persists. Before you refresh, do you think your class and students will still be there after a full page reload?
- Refresh the page in your browser.
- Click the Classes tab. Math 101 should still be listed with your teacher's name.
- Click the Students tab. All three students should still appear.
Everything survives the reload because the data lives in your Supabase database, not in the browser's memory.
Now check what the teacher experience looks like. Before you log in as the teacher, what do you expect to see?
- Click Sign Out.
- Log in with your teacher account credentials.
You will see the teacher dashboard tabs appear. The content area is blank because the attendance feature is not built yet. That is exactly the gap you will close in the next step.
Why can the teacher see the dashboard but not do anything?
The teacher's dashboard shell was built in Step 2, and it queries the classes table to find a class assigned to this teacher. That query now returns Math 101 because the admin just created it. But the functions that render the attendance form and history do not exist yet.
- Click Sign Out and log back in as the admin so you are ready for the next step.
Your database now has a class, a teacher assignment, and students. The building blocks are in place. Next up, you will give the teacher the ability to actually take attendance for their class.
Teacher Takes Attendance
In the previous step, you built the admin side of the app. The admin can create classes, assign teachers, and add students. But when you log in as the teacher, all you see is a dashboard shell with no way to actually record attendance.
This step builds the core feature of the entire app. You will create a tap-based attendance form where the teacher sees each student, toggles them as Present or Absent, and submits the records to Supabase in a single batch.
In this step, get ready to:
- Build the attendance form that displays each student with Present/Absent toggle buttons.
- Wire up the submit handler that batch-inserts attendance records into Supabase.
- Add an attendance history view so the teacher can review past records.
Render the attendance form for the class
The renderTeacherDashboard function you added in the previous step already fetches the teacher's assigned class and its students from Supabase. It calls a function called renderAttendanceForm that does not exist yet. Time to create it.
- In src/teacher.js, scroll to the bottom of the file, below the closing } of renderTeacherDashboard.
- Add the first part of the renderAttendanceForm function by pasting the following code:
function renderAttendanceForm(myClass, today, alreadySubmitted, userId) {
const tabContent = document.getElementById('tab-content')
// Block duplicate submissions for the same day
if (alreadySubmitted) {
tabContent.innerHTML = `
<h3>${myClass.name} — ${today}</h3>
<p class="success">Attendance already submitted for today.</p>
`
return
}
// Build a toggle row for each student
const studentRows = (myClass.students || [])
.map(s => `
<div class="student-row" data-student-id="${s.id}">
<span>${s.full_name}</span>
<div class="status-toggle">
<button class="toggle-btn present-btn active" data-status="present">Present</button>
<button class="toggle-btn absent-btn" data-status="absent">Absent</button>
</div>
</div>
`)
.join('')
// Render the form layout
tabContent.innerHTML = `
<h3>${myClass.name} — ${today}</h3>
<div id="student-list">${studentRows}</div>
<button id="submit-attendance">Submit Attendance</button>
<p id="submit-message" class="hidden"></p>
`
}
What does this code do?
The alreadySubmitted check at the top prevents a teacher from recording attendance twice for the same day. If records already exist for today, the function shows a success message and stops.
If no records exist yet, the function builds one row per student. Each row has the student's name and two toggle buttons: Present (selected by default) and Absent. A Submit Attendance button sits below the student list.
- Save src/teacher.js and refresh your browser at http://localhost:5173.
- You should see the class name (Math 101), today's date, and your three students listed with Present and Absent buttons next to each name.
Don't see the student list?
- Make sure you saved src/teacher.js. Unsaved changes will not reload in the browser.
- Confirm you are signed in as the teacher account, not the admin. The admin dashboard does not call renderAttendanceForm.
- Check the browser console for errors. If you see a ReferenceError, make sure the function name matches exactly.
- Still stuck? Help me debug why my attendance form isn't showing up.
Wire up the toggle buttons and submission
The form renders, but tapping the buttons does nothing yet. You need to add event listeners that toggle the active state and a submit handler that sends records to Supabase.
- In src/teacher.js, find the closing } at the bottom of renderAttendanceForm.
- Replace that } with the following code to add the toggle button listeners:
// Make toggle buttons switch between Present and Absent
tabContent.querySelectorAll('.student-row').forEach(row => {
row.querySelectorAll('.toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
row.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'))
btn.classList.add('active')
})
})
})
What does this code do?
When a teacher taps Absent, the code removes the active class from both buttons in that row, then adds active to the tapped button. The CSS you already have turns an active Present button green and an active Absent button red.
- Directly below the toggle listener code you just pasted, add the submit handler and the closing } for the function:
// Batch-insert all attendance records on submit
document.getElementById('submit-attendance').addEventListener('click', async () => {
const rows = tabContent.querySelectorAll('.student-row')
const records = []
rows.forEach(row => {
const studentId = row.dataset.studentId
const activeBtn = row.querySelector('.toggle-btn.active')
const status = activeBtn ? activeBtn.dataset.status : 'present'
records.push({
student_id: studentId,
class_id: myClass.id,
date: today,
status,
marked_by: userId
})
})
const { error } = await supabase.from('attendance').insert(records)
const msg = document.getElementById('submit-message')
if (error) {
msg.textContent = 'Error: ' + error.message
msg.className = 'error'
} else {
msg.textContent = 'Attendance submitted!'
msg.className = 'success'
document.getElementById('submit-attendance').disabled = true
}
})
}
What does this code do?
When the teacher taps Submit Attendance, the handler loops through every student row and reads which button is active (present or absent). It builds an array of records and batch-inserts them all at once with supabase.from('attendance').insert(records).
Each record includes the student_id, class_id, today's date, the status, and marked_by (the teacher's user ID). After a successful insert, the button is disabled to prevent duplicate submissions.
- Save src/teacher.js and refresh your browser.
- Tap the Absent button next to one of the three students. The button should turn red while the other two students stay green on Present.
- Tap Submit Attendance. You should see the green text "Attendance submitted!" appear below the button, and the button itself should become grey and unclickable.
Seeing an error after submitting?
- If you see a Row Level Security error, make sure your RLS policies on the attendance table allow inserts for authenticated users. You can temporarily disable RLS on the table in the Supabase dashboard to test.
- If the toggle buttons do not change colour, check that the toggle listener code is inside renderAttendanceForm and not accidentally placed outside the function.
- Need help? Help me debug why my attendance submission is failing.
- Below the renderAttendanceForm function, add the renderTeacherHistory function so the History tab works:
async function renderTeacherHistory(myClass) {
const tabContent = document.getElementById('tab-content')
// Fetch attendance records for this class, newest first
const { data: records } = await supabase
.from('attendance')
.select('date, status, students(full_name)')
.eq('class_id', myClass.id)
.order('date', { ascending: false })
if (!records || records.length === 0) {
tabContent.innerHTML = '<p>No attendance records yet.</p>'
return
}
// Group records by date for display
const grouped = {}
records.forEach(r => {
if (!grouped[r.date]) grouped[r.date] = []
grouped[r.date].push(r)
})
let html = `<h3>Attendance History — ${myClass.name}</h3>`
for (const [date, entries] of Object.entries(grouped)) {
html += `<h4>${date}</h4><ul>`
entries.forEach(entry => {
const statusClass = entry.status === 'present' ? 'present' : 'absent'
html += `<li class="${statusClass}">${entry.students?.full_name} — ${entry.status}</li>`
})
html += '</ul>'
}
tabContent.innerHTML = html
}
What does this code do?
This function queries the attendance table for records matching the teacher's class, pulling in each student's name via a join. It groups the results by date and renders them as lists with green (present) or red (absent) left borders using the CSS classes you already have.
- Save src/teacher.js and refresh your browser.
- Click the History tab at the top of the teacher dashboard.
- You should see today's date as a heading, with your three students listed below it. Two should have a green left border (present) and one should have a red left border (absent).
History tab showing no records?
- Make sure you actually submitted attendance in the previous verify step. The history view reads from the attendance table, which is empty until you submit.
- Check that renderTeacherHistory is placed below renderAttendanceForm in the file, not inside another function.
- Need help? Help me debug why my attendance history is empty.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/teacher.js.
ⓧ I'd like to double check the full code
Your src/teacher.js should contain three functions in this order:
- renderTeacherDashboard (from Step 2). This function fetches the teacher's class, checks for existing attendance records, renders the tab navigation, and calls renderAttendanceForm and renderTeacherHistory.
- renderAttendanceForm (added in this step). This function checks alreadySubmitted, builds the student rows with toggle buttons, renders the form, attaches toggle listeners, and handles the submit click.
- renderTeacherHistory (added in this step). This function queries attendance records, groups them by date, and renders the history list.
Scroll through your file and confirm all three functions are present and complete.
Before you check the Supabase dashboard, take a moment to predict what you will find. How many rows do you expect to see in the attendance table?
- Open the Supabase dashboard for your project.
- Click Table Editor in the left sidebar.
- Select the attendance table.
You should see three rows, one for each student. Each row has a student_id, class_id, today's date, a status of present or absent, and the teacher's user ID in marked_by.
The teacher can now take attendance and review past records. But the admin has no way to see attendance data across all classes. Next up, you will build the admin's records view and deploy the whole app as a PWA.
View Attendance Records and Deploy
Teachers submitted attendance in the previous step, but nobody can review it yet. The admin cannot verify that attendance was taken, and teachers have no way to check past records.
This step closes the loop by building a records view that shows attendance history by date and class. Then you will deploy the finished app to Cloudflare Pages so teachers can install it on their phones as a real Progressive Web App.
In this step, get ready to:
- Build an attendance records view with a date picker for the admin dashboard.
- Deploy the app to Cloudflare Pages and verify it installs on a phone.
Build the records tab UI
The admin dashboard already has a Records tab button wired up in src/admin.js, but clicking it calls a renderRecordsTab() function that does not exist yet.
- In src/admin.js, scroll to the bottom of the file (after the renderStudentsTab function).
- Add the renderRecordsTab function by pasting this code at the bottom of the file:
async function renderRecordsTab() {
const tabContent = document.getElementById('tab-content')
// Default to today's date in YYYY-MM-DD format
const today = new Date().toISOString().split('T')[0]
tabContent.innerHTML = `
<h3>Attendance Records</h3>
<label>Date: <input type="date" id="record-date" value="${today}" /></label>
<div id="records-list"></div>
`
// Re-fetch records whenever the date picker changes
const dateInput = document.getElementById('record-date')
dateInput.addEventListener('change', () => loadRecords(dateInput.value))
loadRecords(today)
}
What does this code do?
This function renders a date picker that defaults to today's date. It creates a records-list container where attendance data will appear.
Whenever the date picker value changes, it calls loadRecords() with the selected date. It also loads today's records immediately on render.
- Save src/admin.js.
- Switch to your browser and click the Records tab in the admin dashboard.
- You should see the "Attendance Records" heading and a date picker set to today's date.
Records tab not showing anything?
Make sure the new function is placed after the closing brace of renderStudentsTab, not inside it. Check your browser's console for syntax errors.
help me debug my renderRecordsTab function
Fetch and display attendance records
The date picker is ready, but selecting a date does nothing visible because loadRecords() does not exist yet.
- In src/admin.js, add the loadRecords function directly below renderRecordsTab:
async function loadRecords(date) {
const recordsList = document.getElementById('records-list')
// Fetch attendance for the selected date, joining student and class names
const { data: records } = await supabase
.from('attendance')
.select('status, students(full_name), classes(name)')
.eq('date', date)
.order('class_id')
if (!records || records.length === 0) {
recordsList.innerHTML = '<p>No records for this date.</p>'
return
}
// Group records by class name for organized display
const grouped = {}
records.forEach(r => {
const className = r.classes?.name || 'Unknown'
if (!grouped[className]) grouped[className] = []
grouped[className].push(r)
})
let html = ''
for (const [className, entries] of Object.entries(grouped)) {
html += `<h4>${className}</h4><ul>`
entries.forEach(entry => {
const statusClass = entry.status === 'present' ? 'present' : 'absent'
html += `<li class="${statusClass}">${entry.students?.full_name} — ${entry.status}</li>`
})
html += '</ul>'
}
recordsList.innerHTML = html
}
What does this code do?
The Supabase query uses .select('status, students(full_name), classes(name)') to join across three tables in a single call. The .eq('date', date) filter narrows results to the selected date.
The function then groups records by class name so each class appears as its own section. Each student row gets a present or absent CSS class for the green or red left border you styled earlier.
- Save src/admin.js.
- Before you check the browser, what do you think you will see when you click the Records tab with today's date selected?
- Click the Records tab (or refresh the page and navigate back to it).
You should see "Math 101" as a heading with the three students listed underneath, each showing their present or absent status with a colored left border.
Seeing "No records for this date"?
The date picker defaults to today in your local time zone, but the attendance records were stored with the date from Step 4. If you submitted attendance yesterday or at a different UTC offset, the dates may not match. Try selecting the date when you submitted attendance.
Also confirm that Row Level Security policies on the attendance table allow the admin role to read all rows.
help me debug why my attendance records are not showing up
✔️ Awesome, I've got everything!
Great. Double check you have saved src/admin.js and confirmed the records appear.
ⓧ I'd like to double check the full code
Your src/admin.js file is long at this point. Scroll through it and confirm these sections appear in order:
- The import { supabase } statement at the top.
- The renderAdminDashboard function with tab click listeners for classes, students, and records.
- The renderClassesTab function with the class creation form and class list.
- The renderStudentsTab function with the student creation form and student list.
- The renderRecordsTab function you just added with the date picker.
- The loadRecords function you just added with the Supabase query and grouped display.
Deploy to Cloudflare Pages
The app is fully functional locally. To turn it into a real PWA that teachers install on their phones, you need to deploy it to a live URL with HTTPS.
- Stop the dev server by pressing Ctrl+C in your terminal.
- Build the production files by running:
npm run build
What does this command do?
Vite compiles your JavaScript, CSS, and HTML into optimized production files inside a dist/ folder. The vite-plugin-pwa plugin also generates the service worker and manifest files that make your app installable.
- Confirm the build succeeded by running:
ls dist
You should see files including index.html and an assets folder with your bundled JavaScript and CSS.
Build failed?
Check that you saved all files before building. Syntax errors in any .js file will cause the build to fail. Read the error message in the terminal to find the file and line number.
help me fix my Vite build error
Now you will upload the dist/ folder to Cloudflare Pages using their drag-and-drop deploy.
- Open https://dash.cloudflare.com/sign-up in your browser and create a free Cloudflare account (or sign in if you already have one).
- In the Cloudflare dashboard, click Workers & Pages in the left sidebar.
- Click Create, then select the Pages tab.
- Select Upload assets (the direct upload option).
- Enter a project name like school-attendance in the project name field.
- Drag your dist folder from your file manager into the upload area.
- Click Deploy site.
Why is it safe to deploy with the Supabase anon key?
Your Supabase URL and anon key are bundled into the client-side JavaScript. This is fine because the anon key only grants access that your Row Level Security policies allow. Without valid authentication, nobody can read or write data they should not access.
After the deploy finishes, Cloudflare shows your live URL. It follows the pattern your-project-name.pages.dev.
- Click the live URL to open your deployed app in a new tab.
- Log in with your admin account to confirm the app works on the live URL.
You should see the same login screen and admin dashboard you used locally, now running on a global CDN.
App loads but login fails?
In your Supabase dashboard, go to Authentication > URL Configuration and add your Cloudflare Pages URL to the Redirect URLs list. Some Supabase auth flows require the redirect URL to be explicitly allowed.
help me fix login issues on my deployed Supabase app
The final test is installing the app on a phone.
- Open the Cloudflare Pages URL on your phone's browser.
- On iOS Safari, tap the Share button, then tap Add to Home Screen.
- On Android Chrome, tap the three-dot menu, then tap Add to Home screen.
- Open the app from your home screen. It launches in standalone mode with no browser toolbar, looking like a native app.
Your attendance system is live. Teachers can now install it on their phones and take attendance every weekend. In the Secret Mission, you will add date-range filtering and attendance percentage summaries to the records view.
Secret mission
Date-Range Filtering and Attendance Summaries
The records view only shows one day at a time. A real admin needs to spot trends across weeks of attendance. In this mission, you will upgrade the records page with date-range filtering and per-class attendance percentage summaries.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on free tiers, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Supabase Free tier and auto-pausing
Supabase Free tier pauses projects after 1 week of inactivity. For a weekend school used every Saturday or Sunday, the 5-6 day gap between sessions is safely under the limit.
During extended breaks (holidays, summer), the project will auto-pause. To unpause, log into the Supabase dashboard, navigate to your project, and click Restore project. It takes about 1 minute.
Resources you used:
- Supabase project (database, authentication, API) on the Free tier.
- Cloudflare Pages site (static hosting with global CDN) on the Free tier.
- Local school-attendance project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively using the attendance system for your weekend school.
- Both Supabase and Cloudflare Pages are on free tiers at $0 per month for 50 users.
- The Supabase project stays active as long as it receives a request at least once every 7 days. Regular weekend use keeps it alive automatically.
- Your local school-attendance folder stays on your Desktop in case you want to make changes later.
Pause - I'll come back to this later
Free up local resources while keeping your cloud services ready to resume. Supabase will auto-pause if unused for a week, so you only need to handle your local setup.
- Stop the Vite dev server in your terminal by pressing Ctrl+C if it is still running.
- Your Cloudflare Pages site continues serving at $0 with no action needed.
- If you take an extended break (holidays, summer), the Supabase project will auto-pause after 1 week of inactivity. When school resumes, log into the Supabase dashboard, select your project, and click Restore project to bring it back in about 1 minute.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes all attendance data, user accounts, and hosted files.
- Delete your Supabase project by opening the Supabase dashboard and selecting your project. Go to Settings in the sidebar, then General. Scroll down and click Delete Project. Enter your project name to confirm, then click Delete.
- Delete your Cloudflare Pages site by opening the Cloudflare dashboard. Go to the Workers & Pages page. Select your Pages project, then go to Settings and click Delete project.
- Delete your local project folder by running the command for your operating system.
macOS
rm -rf ~/Desktop/school-attendance
Windows
rmdir /s /q %USERPROFILE%\Desktop\school-attendance
Nice Work!
Nice Work!
Nice work! You just built and deployed a full-stack school attendance system that teachers can install on their phones and use every weekend.
You learned how to:
- Built a full-stack school attendance system with separate admin and teacher roles, where admins manage classes and students while teachers record attendance with a tap-based interface.
- Set up Supabase as a free backend with authentication, a PostgreSQL database, and Row Level Security to protect data so teachers can only access their own class.
- Made the app installable on any phone as a Progressive Web App using vite-plugin-pwa and deployed it to Cloudflare Pages for free global hosting.
- Secret Mission: Added date-range filtering with per-class attendance percentage summaries so admins can spot trends across multiple weekends at a glance.
Ready to quiz yourself?