How I Built EventPro: A Full-Stack SaaS Event Management Platform From Scratch

From a blank editor to a live multi-role SaaS platform with real-time attendance, CSV uploads, JWT auth, and a PostgreSQL backend โ€” the full story.


The Problem That Started It All

Event management in Nigeria and honestly across much of Africa, is still largely manual. Organizers uses spreadsheet, WhatsApp groups to confirm attendees. Check-in is done with printed lists and pens. Duplicate registrations go undetected until someone shows up claiming a seat that was already taken.Long queues of attendees in hot weather. Attendance data lives in Excel files that get emailed around after the event, if at all.

EventPro was built to solve exactly this problems. The goal was a single platform where an admin could manage organizers, organizers could manage their events, and attendees could register for events seemlessly, get confirmed, and check in with ease without long standing, โ€” all without a single WhatsApp message or printed list.

The core question: Can a self-taught developer with no team build a production-grade, multi-role SaaS platform entirely in vanilla JavaScript and Node.js?

Spoiler: yes. But it took longer, broke more, and taught me more than any course or tutorial ever has. This is that story.

Deciding the Architecture Before Writing a Line

The first week was entirely planning. No code. Just a lot of diagrams, questions, and second-guessing.

The three-role system โ€” Admin, Organizer, and Attendee was the foundational decision that shaped everything else. Each role needed:

I debated using a framework. React would have made the component reuse cleaner. Next.js would have given me routing for free. I chose neither. The reason was deliberate: I wanted to prove to myself and to anyone looking at my portfolio, that I could build something production-grade without leaning on a framework as a crutch. Vanilla HTML, CSS, and JavaScript for the frontend. Node.js and Express for the backend. PostgreSQL for the database.

The file structure decision

With 20+ screens across three roles, the file structure had to be planned carefully or it would become a nightmare. I landed on this:

EventPro/
EventPro-Frontend/
โ”œโ”€โ”€ assets/
โ”‚   โ””โ”€โ”€ images/              โ€” logos, illustrations, icons
โ”œโ”€โ”€ js/
โ”‚   โ”œโ”€โ”€ services/
โ”‚   โ”‚   โ””โ”€โ”€ auth-service.js  โ€” login, logout, token storage, profile fetch
โ”‚   โ”œโ”€โ”€ utils/
โ”‚   โ”‚   โ””โ”€โ”€ load-components.js โ€” role-aware sidebar + topbar injection
โ”‚   โ”œโ”€โ”€ admin-dashboard.js
โ”‚   โ”œโ”€โ”€ admin-events.js
โ”‚   โ”œโ”€โ”€ admin-login.js
โ”‚   โ”œโ”€โ”€ admin-report.js
โ”‚   โ”œโ”€โ”€ attendees.js
โ”‚   โ”œโ”€โ”€ create-event.js
โ”‚   โ”œโ”€โ”€ csv-validation.js
โ”‚   โ”œโ”€โ”€ duplicate-detection.js
โ”‚   โ”œโ”€โ”€ forgot-password.js
โ”‚   โ”œโ”€โ”€ organizer-dashboard.js
โ”‚   โ”œโ”€โ”€ organizer-events.js
โ”‚   โ”œโ”€โ”€ organizer-management.js
โ”‚   โ”œโ”€โ”€ organizer-report.js
โ”‚   โ”œโ”€โ”€ real-time-attendance.js
โ”‚   โ”œโ”€โ”€ role-selection.js
โ”‚   โ”œโ”€โ”€ settings.js
โ”‚   โ”œโ”€โ”€ sign-in.js
โ”‚   โ”œโ”€โ”€ signup.js
โ”‚   โ”œโ”€โ”€ ticket-details.js
โ”‚   โ””โ”€โ”€ upload.js
โ”œโ”€โ”€ pages/
โ”‚   โ”œโ”€โ”€ admin-dashboard.html
โ”‚   โ”œโ”€โ”€ admin-events.html
โ”‚   โ”œโ”€โ”€ admin-login.html        โ€” private URL, not linked publicly
โ”‚   โ”œโ”€โ”€ admin-report.html
โ”‚   โ”œโ”€โ”€ attendees.html
โ”‚   โ”œโ”€โ”€ auth-reset-password.html
โ”‚   โ”œโ”€โ”€ create-event.html
โ”‚   โ”œโ”€โ”€ csv-validation.html
โ”‚   โ”œโ”€โ”€ duplicate-detection.html
โ”‚   โ”œโ”€โ”€ forget-password.html
โ”‚   โ”œโ”€โ”€ organizer-accounts.html
โ”‚   โ”œโ”€โ”€ organizer-dashboard.html
โ”‚   โ”œโ”€โ”€ organizer-events.html
โ”‚   โ”œโ”€โ”€ organizer-management.html
โ”‚   โ”œโ”€โ”€ organizer-reports.html
โ”‚   โ”œโ”€โ”€ real-time-attendance.html
โ”‚   โ”œโ”€โ”€ role-selection.html
โ”‚   โ”œโ”€โ”€ settings.html
โ”‚   โ”œโ”€โ”€ sign-in.html
โ”‚   โ”œโ”€โ”€ signup.html
โ”‚   โ”œโ”€โ”€ sms-verification.html
โ”‚   โ”œโ”€โ”€ ticket-details.html
โ”‚   โ””โ”€โ”€ upload.html
โ”œโ”€โ”€ styles/
โ”‚   โ”œโ”€โ”€ dashboard-layout.css   โ€” shared sidebar + topbar shell
โ”‚   โ”œโ”€โ”€ admin-events.css
โ”‚   โ”œโ”€โ”€ attendees.css
โ”‚   โ”œโ”€โ”€ csv-validation.css
โ”‚   โ”œโ”€โ”€ organizer-events.css
โ”‚   โ”œโ”€โ”€ real-time-attendance.css
โ”‚   โ”œโ”€โ”€ settings.css
โ”‚   โ”œโ”€โ”€ sign-in.css
โ”‚   โ”œโ”€โ”€ signup.css
โ”‚   โ”œโ”€โ”€ upload.css
โ”‚   โ””โ”€โ”€ ...
โ””โ”€โ”€ README.md
      

The key insight was auth-service.js โ€” a single file that owns every API call in the entire frontend. No page ever calls fetch() directly. This meant when the backend URL changed (it did, twice), I updated one file.

Building the Backend First

This was the decision I'm most proud of. Most frontend developers build the UI first and bolt on the backend later. I did the opposite. The backend was entirely built and tested in Postman before a single dashboard HTML file existed.

The backend runs on Node.js + Express, hosted on Render's free tier, with a PostgreSQL database. The API has Swagger documentation โ€” not because I had to, but because I'd seen enough professional APIs to know that undocumented endpoints are a collaboration nightmare.

The auth system

All three roles โ€” Admin, Organizer, Attendee โ€” share the same login endpoint. The backend returns a JWT that contains the user's role. The frontend reads that role and redirects accordingly. Simple in principle. Surprisingly easy to get wrong in practice.

The first version had a bug where an organizer could modify the role in their JWT and gain admin access. The fix was server-side role validation on every protected route โ€” never trusting what the client sends.

Security lesson: Never trust the client. Always validate role and permissions server-side on every single API call, not just on login.

Social login via Appwrite

SMS and email verification were integrated through Appwrite โ€” a backend-as-a-service that handles OTP delivery reliably. This was a deliberate choice over building a custom verification system. Building OTP delivery from scratch means managing email deliverability, SMS gateway integrations, and rate limiting. Appwrite handles all of that. Good engineers know when not to reinvent the wheel.

The Features That Were Hard to Build

Real-time attendance tracking

This was the feature I was most nervous about. Real-time means the dashboard updates live as attendees check in โ€” no page refresh required.

The implementation uses polling rather than WebSockets. Every 10 seconds, the attendance dashboard silently fetches the latest check-in data and updates the UI if anything changed. It's not as elegant as a persistent WebSocket connection, but it's significantly simpler to deploy on a free-tier server and handles the scale of events this platform targets (tens to hundreds of attendees, not thousands).

The result: organizers see a live counter and attendee list that updates in near-real-time without ever touching a refresh button.

CSV upload with duplicate detection

Bulk importing attendees sounds simple. It isn't. The requirements were:

  • Accept a CSV file with arbitrary column names
  • Map the columns to the expected fields (name, email, phone)
  • Detect and flag duplicates before they hit the database
  • Give the organizer a clear error report for every invalid row
  • Import valid rows without stopping for the invalid ones

The duplicate detection runs at two levels: within the uploaded file itself (someone adding the same email twice in the spreadsheet) and against existing attendees in the database for that event. The frontend shows a summary "47 imported successfully, 3 duplicates skipped, 1 invalid email format" before anything is committed.

The deactivation modal

A small feature that took disproportionate thought: when an admin deactivates an organizer, what happens to their active events? The decision was to cascade โ€” events remain published and accessible to attendees, but the organizer loses dashboard access until reactivated. The data doesn't disappear. This matters because real events have real attendees who bought tickets or made plans.

The Frontend โ€” 20+ Screens in Vanilla JS

Building 20+ screens without a component framework means solving the shared UI problem manually. The navigation sidebar appears on every dashboard screen. In React, you'd use a layout component. In vanilla JS, I built load-components.js โ€” a utility that fetches shared HTML fragments and injects them into a designated placeholder on each page.

The CSS architecture was equally deliberate. dashboard-layout.css defines every design token, shared component (cards, tables, badges, modals, toasts), and responsive breakpoints. Each page imports it first, then adds only its own page-specific styles. This means a button looks identical on the admin dashboard and the organizer events page โ€” because they're literally the same CSS class.

The design system

The purple sidebar (#6F00FF) was chosen intentionally. Purple signals premium, authority, and trust โ€” the qualities an admin dashboard should convey. The white active state on the sidebar link creates a strong visual anchor that tells users exactly where they are at all times.

Deployment and What Broke

The backend is deployed on Render's free tier. This comes with one significant caveat: the server spins down after 15 minutes of inactivity. The first request after sleep takes 30โ€“60 seconds. This is why the auth service has a built-in timeout message "The server may be starting up โ€” please try again" โ€” rather than a generic error.

The frontend is deployed on GitHub Pages with a custom landing page added later. The deployment issue that cost me an afternoon: GitHub Pages is case-sensitive. Sign-In.html and sign-in.html are different files on Linux but identical on Windows. Every internal link had to be audited and corrected.

Deployment trap: Always develop on a case-sensitive file system or explicitly lower-case every filename and every link before deploying to Linux-based hosting.

What I'd Do Differently

Honest retrospective โ€” three things I'd change if I started today:

1. Use a module bundler from day one. With 30+ JavaScript files and no bundler, managing load order and avoiding global scope pollution became a real problem by the end. Vite would have added minimal overhead and solved this completely.

2. Write the database schema before the API. I wrote the Express routes first and designed the PostgreSQL schema to fit them. That's backwards. The data model should drive the API design, not the other way around. I refactored two tables mid-build because of this.

3. Plan the mobile layout before building desktop. The dashboard was designed desktop-first and mobile was retrofitted. It works, but the mobile experience on the organizer management screen is noticeably tighter than it should be. Mobile-first from the start would have caught this.

What I'm Proud Of

Despite those lessons, EventPro is the project I'm most proud of in my portfolio. Not because it's perfect โ€” it isn't โ€” but because it's real. Real users have logged in. Real events have been created. Real check-ins have happened.

The admin dashboard currently shows 17 published events and 83 registered users. Those aren't placeholder numbers. Those are actual people who used something I built from nothing.

For a self-taught developer who started with HTML, building a platform with multi-role JWT authentication, real-time data, CSV processing, and a live PostgreSQL backend โ€” that's the proof of concept I needed to prove to myself that I can build anything.

What's Next for EventPro

The immediate roadmap has three priorities: payment integration for ticket sales, a mobile-responsive organizer dashboard, and moving off Render's free tier to eliminate the cold start delay. Beyond that, QR code check-in generation is planned โ€” instead of manual entry, attendees get a unique QR code in their confirmation email that the organizer scans at the door.

The codebase is open source. If you're a developer learning Node.js or building your own SaaS, the source is on GitHub and the Swagger docs are live โ€” dig in, borrow what's useful, and open an issue if you spot something broken.