Data models for vibe coders
How to keep your database schema sane as a vibe-coded app grows — without becoming a database expert overnight.
You can vibe code a frontend forever. The database is where the past catches up to you.
Schemas have memory. Every column you add, every relation you sketch, every default you set today becomes a constraint on what you can ship tomorrow. AI tools will happily generate migrations all day; what they will not do is tell you when the shape of your data is quietly painting you into a corner.
Here is a working approach that does not require you to become a DBA.
Start with the verbs, not the nouns
The instinct is to model nouns: User, Project, Post. That is fine for the first hour. After that, the interesting structure is in the verbs — what users do, and when.
If your app lets users "favorite" projects, that is a verb. It probably wants its own table (favorites) with user_id, project_id, and a timestamp. Not a JSON array on the user row. Not a boolean column on the project. A real, queryable relation.
The rule of thumb: anything that needs to be counted, listed, sorted by time, or filtered later wants to be a row.
Three columns every table should have
Almost every table benefits from three columns:
id— usually a UUID. Avoid sequential integers in URLs.created_at— set at insert, never changes. This is your audit trail.updated_at— bumped on every update. Free version tracking.
You will not always use these columns on day one. You will be grateful for them on day ninety.
Avoid mega-tables
A common AI-generated pattern is one giant users table with thirty columns: profile bits, billing bits, settings bits, preferences bits. It works until you need to share the user object between contexts that should not share it (logging the billing fields to analytics, for example).
Split early. A small users table with a few core fields, plus a user_profiles and user_settings for the rest, is easier to extend, easier to permission, and easier to reason about. Joins are cheap; refactoring a 30-column table later is not.
Foreign keys are documentation
Use real foreign keys. Even if you "know" you will always join correctly in application code, write the constraint in the database. It is the cheapest form of documentation, and it prevents the class of bug where you delete a row and orphan its references.
Yes, it makes some seed scripts slightly more annoying. That is a feature.
Migrations: small, named, reversible
Whatever tool you use — Prisma, Drizzle, raw SQL — keep migrations small and meaningful. One migration per change, named for what it does. 2026_06_add_invoices_table.sql, not update_schema.sql.
Reversibility is harder. For most early projects, "we can restore from a backup" is acceptable. As soon as you have real users, write the down step too. AI tools can usually generate it; you should review it.
When to denormalize
Eventually you will hit a query that is too slow because it joins five tables. The temptation is to denormalize — copy a field, cache a count, add a materialized view. That can be the right move, but treat it as a deliberate decision, not a default. Every cached field is a place where the source of truth and the cache can drift.
Rule of thumb: normalize until something is provably slow, then denormalize that specific thing, with a comment explaining why.
You do not need to be perfect
A pragmatic schema beats a perfect one you never ship. The principles above are not gospel — they are the shape of decisions that age well. If you find yourself agonizing over a column name, that is also a sign you are over budget on schema time. Ship a version, learn from real data, migrate later.