Open the average solo-built SaaS and you can reverse-engineer the database from the navigation in about ten seconds. Sidebar reads: Projects, Tasks, Users, Tags, Comments, Webhooks. That is not an information architecture. That is a SHOW TABLES with a coat of CSS on it.
It feels right when you build it because the schema is already the structure in your head. You designed the tables first, you wrote the CRUD endpoints next, and the fastest way to put a UI on top was one screen per table: a list view, a detail view, a create form, an edit form. The app shipped. It works. And it quietly tells every user that they are looking at your spreadsheet, not their work.
The fix is a mental reframe, and it is the whole chapter.
Your database tables are how you store data. They are not how the user thinks about their work. Organize the interface around the user's tasks and the user's language — the jobs they came to do — not around your entities and your CRUD operations. The schema is an implementation detail; the navigation is the product.
You know the shape because you have built it. Maybe you are looking at it right now.
The tell is a sidebar where every item is a noun that is also a table name, and every screen under it is a grid with a New button in the top-right corner. The user's relationship to your product becomes: pick a table, scroll the rows, click a row, edit the fields, save. Repeat across six tables to accomplish one actual goal.
This is CRUD as UX, and it has three specific failures.
It exposes your join tables. A project_members table is a real thing in your database. It is not a real thing in your user's mind. When "Project Members" shows up as a top-level nav item next to "Projects" and "Members," you have leaked a foreign-key relationship into the navigation. The user now has to understand your data model to find the button that adds a teammate.
It scatters one task across many screens. Onboarding a new client might touch the clients table, the projects table, the billing table, and the notifications table. In a schema-shaped app, that is four separate pages the user has to visit in the right order, each with its own form, none of which mention "onboarding a client." The task exists only in the user's head. Your app never named it.
It makes the common path as hard as the rare one. Every table gets equal billing in the sidebar. The thing users do forty times a day sits at the same visual weight as the thing they do twice a year. Your navigation has no opinion about what matters, because the schema has no opinion about what matters — every table is just a table.
Generic database tools like Airtable and Notion deliberately ship the schema as the UI — that is their entire value proposition. A table per table is correct for a tool whose job is "let users model anything." It is wrong for your product, whose job is to model one thing well. Do not borrow the navigation of a tool whose constraints are the opposite of yours.
A project_members table is a real thing in your database. It is not a real thing in your user's mind.
Here is the gap, stated plainly. You have a data model — tables, columns, foreign keys, the normalized structure that makes storage efficient and queries fast. Your user has a mental model — the way they frame their own work, in their own words, shaped by the job they are trying to finish.
These two models almost never match, and they are not supposed to. Normalization optimizes for non-redundant storage. A mental model optimizes for getting something done. Different goals produce different shapes.
Consider a typical case. You build an invoicing app. Your schema, sensibly, has invoices, line_items, clients, payments, and taxes as separate tables, because that is how you avoid duplicating data and how you sum a total without storing it twice.
Your user does not think in any of those nouns. Your user thinks: "I did some work, I need to get paid for it." That is one job. In their head it is a single motion — describe the work, send the bill, watch for the money. Your five tables are the plumbing under that one motion. They should be invisible.
Watch what happens when you map one side to the other:
| Your data model (tables) | Their mental model (jobs) |
|---|---|
invoices, line_items, taxes |
"Bill a client for work I did" |
payments, invoices.status |
"See who still owes me money" |
clients, contacts |
"Keep track of who I work with" |
invoices filtered by date |
"Get my numbers ready for taxes" |
The left column is six tables. The right column is four jobs. The right column never mentions a join, a status enum, or a foreign key — and the right column is what the navigation should say.
Normalization is an answer to a storage question. Navigation is an answer to a human question. Stop letting the storage answer write the human one.
There is a deeper reason this matters, and it connects to a framework from earlier in the book. When you ship the schema, every screen silently asks the user to learn your data model before they can do their job. That cognitive overhead is exactly the Competence Tax in another costume — except now you are not paying it on visual polish, you are paying it on comprehension. The user senses they are operating someone else's filing cabinet, and they trust the product a little less for it.
The move is to throw out "one screen per table" and replace it with "one entry point per job."
Start by writing down what your users actually come to do. Not the entities — the verbs and the outcomes. For the invoicing app, that list is something like: bill a client, chase a late payment, see what I'm owed, prep for taxes, add a new client. Five jobs. That list is your navigation, before you have touched a single table.
Then notice that a single job pulls from multiple tables, and a single table feeds multiple jobs. The relationship is many-to-many, which is exactly why a one-to-one mapping of tables to screens fails. "Bill a client" reads clients, writes invoices and line_items, computes against taxes. "See what I'm owed" reads invoices and payments and clients. The same invoices table shows up in three different jobs, wearing a different face each time.
This is the same instinct as Happy Path First. There, you designed the main successful flow end-to-end before worrying about edge cases. Here, you are deciding which flows even deserve to be top-level — and the answer is the jobs, not the tables. Task-shaped navigation is just Happy Path First applied to the sidebar.
A practical rule for grouping:
When you are unsure whether two things belong on the same screen, ask: "Would a user do these in one sitting, toward one goal?" If yes, they belong together even if they live in different tables. If no, split them even if they share a table. The unit of organization is the task, not the schema.
Your nav should also stop being a flat list of equals. The job done forty times a day is the first item, large and obvious. The job done twice a year — tax prep — can live behind a smaller link or a secondary menu. Let the navigation have an opinion. A flat alphabetical list of table names has no opinion; a task-ranked menu tells the user what this product is for.
Your sidebar should read like a to-do list for the user's job, not a table of contents for your database.
Half of shipping your schema is the structure. The other half is the vocabulary. Your column names leak into your labels, and column names are written for the machine.
You named a boolean is_active. You named a foreign key assignee_id. You named a timestamp created_at and a junction user_org_role. These are correct in the database and wrong on the screen. The user does not have an assignee_id; the user has "who's on this." They do not toggle is_active; they "pause" or "archive" a thing.
The fix is a deliberate translation layer between your columns and your copy. Build a small dictionary — left side the schema term, right side the human term — and enforce it in the UI.
// One place that owns schema-to-human translation.
const LABELS = {
assignee_id: "Owner",
due_at: "Due",
is_active: "Status",
org_id: "Workspace",
created_at: "Created",
} as const;
export function labelFor(field: keyof typeof LABELS) {
return LABELS[field];
}
This is the same discipline as Label the Outcome, pointed at nouns instead of buttons. Label the Outcome says a button should name the result (Create invoice) rather than the mechanism (Submit). The noun version: a field should name the thing the user cares about (Owner, Due) rather than the storage detail (assignee_id, due_at). Same principle, different part of speech.
Watch for the subtle leaks, not just the obvious ones:
PENDING_REVIEW instead of Pending review — or better, Waiting on you.Project Tags instead of Tags. The user does not care that tags belong to projects through a table; they just want tags.Invoices is acceptable; Invoice Line Items is database-speak that should never reach a human.Status enums leak more schema than any other field because there are usually several of them, written in SCREAMING_SNAKE_CASE for the code. Never render an enum value directly. Map every state to human words — and while you are at it, write them from the user's point of view: OVERDUE becomes Late, AWAITING_PAYMENT becomes Unpaid. The code keeps its constants; the human gets plain English.
Let me make this concrete with a before and after, because the reframe is easy to nod at and hard to actually do.
Take a project-management side-project. The schema-shaped version grew one nav item per table, because each table got a CRUD screen the day it was added.
Before — schema-shaped nav
Projects
Tasks
Subtasks
Project Members
Tags
Comments
Time Entries
Attachments
Eight items. Every one is a table. To do the most common real job — check what I should work on today — the user has to go to Tasks, apply a filter for "assigned to me," sort by due date, and ignore the seven other nav items that are noise for this purpose. The app made the user assemble the task out of raw tables.
Now reorganize around what people actually open the app to do:
After — task-shaped nav
My Day (today's tasks, assigned to me, due first)
Projects (a project = its tasks, files, people, in context)
Inbox (comments and mentions that need me)
Time (log and review hours)
── settings ──
Team
Tags & labels
Look at what changed. Subtasks, Comments, Attachments, and Project Members did not get their own nav items anymore — they appear inside a project, where they are relevant, because that is where the user is when they care about them. Tasks did not vanish; they got promoted into My Day, the job people do every morning. Tags and team management dropped below a divider into settings, because you configure them rarely and use them constantly without thinking about them.
The tables underneath did not change at all. Not one migration. You still have subtasks and project_members and attachments. You simply stopped letting the storage layer dictate the navigation layer.
There is a bonus you get almost for free. Once the navigation is organized by job, the landing screen practically designs itself — My Day is exactly the kind of opening view that Answer the First Question is about. The user's first question is "what should I do right now," and a task-shaped app can put that answer on screen the moment they log in. A schema-shaped app can't, because "Projects" doesn't answer any question — it just hands you a table and wishes you luck.
You can change the entire feel of a product by re-grouping and re-labeling the navigation, without touching a single line of your data layer.
This is a refactor you can do in an afternoon, and most of it is renaming and re-grouping — no schema changes required.
Write them down in one column exactly as they appear today. If the list reads like the output of SHOW TABLES, you have found your problem.
Next to each, write task (a job a user does) or table (a database entity). Every item marked "table" is a candidate to be moved, renamed, demoted into settings, or absorbed into a task screen.
In the user's words, as verbs and outcomes: "bill a client," "see what I'm owed," "check today's work." This list, not your tables, is what the navigation should say.
Make the most frequent job the first item. Pull join-table and config screens (members, tags, roles) below a divider into settings. Absorb child entities into the parent screen where the user already is.
Make one map from schema terms to human terms (assignee_id becomes Owner, is_active becomes a friendly Status). Map every enum value to plain words from the user's point of view. Render no raw SCREAMING_SNAKE_CASE and no UUIDs anywhere a human can see them.
Log in as a new user would. The single most common task should be the thing you land on, or one click away — not something you assemble by filtering a grid yourself.
Users never asked to see your database. They came to get something done — so name the doing, hide the tables, and the app will finally feel like it was built for them.