A Non-Technical Colleague Vibe Coded a SaaS in 5 Days. The App Looks Spectacular. The Code? Not So Much.

A code review of a SaaS app a non-technical colleague vibe coded in 5 days. God tables, missing auth, dual systems, and the meta-lesson about AI-compressed timelines.

Here’s what I found when I opened the hood.

A while back, a colleague shared a project with me and asked what I thought. He’s not a developer. He’s an ops guy. Spent years managing training delivery for enterprise clients, coordinating instructors across timezones, juggling spreadsheets that would make your eyes bleed. He knows the problem space inside and out.

One week he sat down with an AI coding assistant and said: “Build me this.” Five days later, he had a working multi-page SaaS application. Course management, calendar views, instructor scheduling, role-based access, email notifications, venue management with maps, CSV exports, a reporting dashboard.

It looked real. It felt real. A PM could log in, create a course, assign an instructor, get an email notification. The calendar rendered beautifully. Venues had interactive maps with markers. You could even paste a meeting invite and it auto-parsed the details.

I was genuinely impressed.

Then I opened the code.


⚡ What was actually built

Before I get into the problems, let me be clear: what he built in 5 days is remarkable. The feature coverage was extensive:

15 pages, 20 API endpoints, a permission system with 22 granular permissions, 5 different roles, a multi-view calendar (month, week, day), venue management with geocoding, instructor availability blocks, conflict detection, ICS calendar downloads, CSV exports, email templates, completion evidence uploads, and a survey system.

A startup could demo this to investors and get a check. It’s the kind of thing that used to take a small team weeks to prototype.

But here’s the thing about vibe coding: the AI builds forward. It solves the immediate problem. It doesn’t think about what happens in six months when you have 50 tenants and your single 50-column table is groaning under the weight of 7 different concerns.


🗄️ Problem #1: The God Table

This was the big one. The courses table had 50+ columns. Fifty. One single table mixing:

  • Core info (name, status, dates)
  • Conferencing (host key, session link, webinar number, password, panelist password)
  • Physical location (venue, address, building name, wing, floor)
  • Materials (lab link, lab guide, credentials for students, credentials for instructors, presentation deck, course material link)
  • Points of contact (name, email, title, then also a JSONB array of POCs because someone asked for multi-POC support)
  • Survey scores (4 different score fields + notes)
  • Admin stuff (expense report URL, expense report file path, placeholder sent timestamp)
-- This was one table. ONE.
CREATE TABLE courses (
  id uuid PRIMARY KEY,
  session_name text NOT NULL,
  status course_status,
  start_datetime timestamptz,
  course_timezone text,
  vendor_id uuid,
  venue_id uuid,
  instructor_id uuid,
  panelist_id uuid,
  conf_host_key text,
  conf_session_link text,
  webinar_number text,
  webinar_password text,
  panelist_password text,
  region text,                    -- Not even a FK, just raw text
  poc_name text,                  -- Legacy single POC...
  poc_email text,
  poc_title text,
  pocs jsonb DEFAULT '[]',        -- ...AND a new JSONB array
  delivery_mode text,
  address_onsite text,
  end_datetime timestamptz,
  recording_link text,
  recording_password text,
  lab_info text,
  lab_link text,
  lab_credentials_instructor text,
  lab_credentials_students text,
  lab_guide_link text,
  course_material_link text,
  presentation_deck_link text,
  enrollment_count integer,
  survey_score_instructor decimal,
  survey_score_overall decimal,
  survey_score_material decimal,
  survey_score_lab decimal,
  survey_notes text,
  expense_report_url text,
  expense_report_file_path text,
  -- ...and more
);

This is classic vibe coding: every time a new feature was needed, the AI added a column. Need conferencing? Add 5 columns. Need expense tracking? Add 2 more. Need a different POC format? Don’t migrate the old one, just add a JSONB column alongside it.

The fix: decompose into 6 normalized tables (courses, course_sessions, course_conferencing, course_pocs, course_materials, course_expense_reports). Core table goes from 50+ to about 20 columns.


🗺️ Problem #2: The Schema That Lies

The schema.sql file defined 10 tables. The application actually used 14 tables. Four entire tables (venues, notifications, event_type_resources, and course_survey_responses) existed only in scattered migration files and API routes.

It gets better. The schema defined a status enum with go and no_go. The application used tentative, confirmed, on_hold, and running. A migration somewhere renamed them, but the schema file, the supposed source of truth, was never updated.

The TypeScript interfaces in database.ts had ~20 fields for Course that didn’t exist in schema.sql. The app was essentially working against a database that didn’t match any documented schema.


🔓 Problem #3: Three Endpoints With Zero Auth

POST /api/notifications      -- Anyone can create notifications
GET  /api/courses/check-instructor-block  -- Anyone can query instructor schedules
GET  /api/geocode            -- Open proxy to a public geocoding API

No authentication. No rate limiting. The notification endpoint meant anyone who knew the URL could inject fake notifications into any user’s feed. The instructor block check leaked scheduling data.

And the two “protected” admin endpoints? They used a secret passed as a query parameter instead of a Bearer token, meaning the secret shows up in server logs, browser history, and any proxy’s access log.

Then look back at that courses table. webinar_password, panelist_password, recording_password, lab_credentials_instructor, lab_credentials_students, the conferencing host key. All plaintext, all in the same row as the course name, so any policy that lets someone read a course lets them read every credential attached to it. These can’t be hashed, because people need to see them. They can live in their own table, behind a policy that only lets through the people who actually need them.

Zero rate limiting across the entire application. Every endpoint.


🧩 Problem #4: The 1,000-Line Form Component

course-form.tsx. One thousand lines. Fifty-plus useState calls. Business logic for timezone conversions, conflict detection, auto-duration calculation, and meeting invite parsing, all embedded directly in the UI component.

// Simplified, but this is the vibe:
const [sessionName, setSessionName] = useState('')
const [vendorId, setVendorId] = useState('')
const [vendorList, setVendorList] = useState([])
const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
const [newVendorName, setNewVendorName] = useState('')
const [newVendorWebsite, setNewVendorWebsite] = useState('')
const [newVendorError, setNewVendorError] = useState('')
const [newVendorSaving, setNewVendorSaving] = useState(false)
// ...repeat for every field, every dialog, every loading state
// 50+ of these

No form library. No state management. No separation between UI and business logic. Every new field added more useState calls, more inline handlers, more spaghetti.


🔄 Problem #5: The Dual System Pattern

This showed up repeatedly: a feature would get built one way, then the requirements would change, and instead of migrating, the AI would build a second system alongside the first.

POCs: Started with three flat columns (poc_name, poc_email, poc_title). Then someone needed multiple contacts. Instead of migrating, a JSONB pocs array was added. The code now syncs the first POC from the array back to the legacy fields for “backward compatibility.” Two systems forever.

Roles: Started with a simple role enum on the users table (admin, pm, instructor). Then someone needed granular permissions. Instead of replacing the role column, a full RBAC system was built alongside it (roles table, user_roles table, role_permissions table). The permission resolver checks the new system first, then falls back to the legacy column if no RBAC roles are assigned. Two authorization systems in one app.

Surveys: Survey scores lived as 4 decimal columns on the courses table. Then individual survey responses needed their own table. Now you have aggregate scores on courses AND a course_survey_responses table, with code that tries to keep them in sync.


📉 Problem #6: No Indexes Beyond Primary Keys

The schema didn’t define a single index. The only ones in the database were those Postgres creates on its own for primary keys and unique constraints. Course queries filter by status, instructor, vendor, date range, and event type, and every one of those filters scans a 50+ column table from top to bottom. With demo data, nobody notices. With a few tenants and a couple of years of course history, everybody does.


👻 Problem #7: The Form That Writes to Columns That Don’t Exist

The venue quick-create dialog in the course form collects a website field. The venues table doesn’t have a website column. Supabase doesn’t quietly drop unknown fields: it rejects the whole insert with PGRST204: Could not find the 'website' column of 'venues' in the schema cache.

Nobody noticed because the happy path skips it. Leave the website empty and the venue gets created. Type one in and the dialog fails.


🏢 Problem #8: Multi-Tenancy That Isn’t

The demo used organization_id as a column on each table with Supabase RLS policies to filter by org. This is the simplest form of multi-tenancy, and it has problems:

  • Four tables (course_notes, survey_links, completion_evidence, instructor_blocks) don’t even have an organization_id column. They require a JOIN to courses or users to validate org membership, a performance hit on every single row-level security check.
  • There’s no schema isolation. All tenants share the same tables, same indexes, same vacuum cycles.
  • A bad RLS policy is a data leak. And some of the policies had gaps: event_type_resources didn’t validate organization_id in its WITH CHECK clause.

🧠 The Meta-Lesson

None of these are AI problems. They’re architecture problems that any developer could make. The difference is timing.

A human developer building this over 3 months would hit each problem sequentially and (hopefully) stop to refactor. The 50-column table would get uncomfortable around column 20. The dual role system would get questioned during code review. The missing auth would get caught in a security audit.

But vibe coding compresses the timeline. You go from zero to feature-complete in days. The AI never pushes back. It never says “this table is getting too wide, should we normalize?” It never says “this permission check is missing.” It adds the column you asked for and moves on.

The result is a codebase that works perfectly for a demo and would require a near-complete rewrite for production.


Keep the demo as a feature reference. It’s an incredible spec document that shows exactly what the product should do. But rebuild the code from scratch, with:

  • A properly decomposed data model (6 tables instead of 1 god table)
  • Schema-per-tenant isolation with dynamic Postgres search_path
  • Type-safe API layer (tRPC with Zod validation)
  • RBAC from day one (no legacy role column)
  • i18n infrastructure from day zero (not retrofitted at the end)
  • Proper auth on every endpoint, rate limiting, input sanitization
  • A backend server that isn’t coupled to the frontend (Fastify, not Next.js API routes)

The demo was built in 5 days. A proper rebuild takes months. But that’s the point: the demo proved the product works. Now the code has to prove the product scales.


📋 TL;DR

AreaFinding
Feature coverageRemarkable. 15 pages, 20 endpoints, 5 roles, full calendar
Data model50+ column god table, schema drift, no indexes beyond primary keys
Security3 unprotected endpoints, secrets in query params, plaintext credentials, no rate limiting
Frontend1,000-line form, 50+ useState calls, no separation of concerns
ArchitectureDual systems everywhere. Never migrated, just layered
Multi-tenancyIncomplete RLS, 4 tables missing org_id
VerdictPerfect demo, near-complete rewrite for production

Vibe coding is a superpower for validation. Just don’t ship it to production without a developer who’s willing to open the hood.