I'm building Underpeaks, a headless CMS that generates Flutter and Next.js apps from the same data models. It's in early testing, and most of what I've learned has come from things breaking. Here are four of those bugs, with what each one changed about how I build.
1. A UI flag that deleted the primary key
Fields in a model can be marked hidden. I added that for the admin UI: hide a field from the editing form. Later I reused the same flag in the public API, so hidden fields would also be stripped from responses. One boolean, two meanings.
The first generated Flutter app that used a model with a hidden field crashed with:
type 'Null' is not a subtype of type 'String'
The generated Dart model declares id as a non-nullable String. The API had removed id from the response, because id was a hidden field in the admin UI (nobody edits it by hand). So fromJson read null where it expected a string.
The fix was to strip hidden fields from API responses but never the primary key, in every branch of the route that returns records. In simplified form:
function stripHidden(row: Row, fields: Field[], pk: string) {
const out = { ...row };
for (const f of fields) {
if (f.hidden && f.name !== pk) delete out[f.name];
}
return out;
}
The real lesson is the one underneath it: "hidden from the editor" and "hidden from the API" are different decisions. I'm treating the primary key exemption as a stopgap. A field that can affect what clients receive should have its own flag, so the next feature doesn't inherit the problem.
2. Derived routes that depended on the wrong thing
A list page implies a detail page, a create page and an edit page, so the generator derives those routes instead of asking the user to declare all four. My first version only derived them for pages that were in the bottom navigation bar. Any list page that wasn't a tab got no CRUD routes, and the generated "Add" button pushed to a path that didn't exist.
The fix was to stop deriving from the navigation list and derive from the set of pages that can have CRUD at all:
const crudPages = pages.filter((p) =>
CRUD_DERIVABLE_TEMPLATE_TYPES.has(p.template_type)
);
If a derived route depends on where a page is displayed, you have coupled two things that should vary independently. Navigation is presentation. Routes are structure.
3. Flutter's NavigationBar needs two destinations
Material's NavigationBar asserts that it has at least two destinations. A project with a single bottom-nav page generated code that failed in debug mode, and one with none generated an empty bar.
The generator now only emits the shell when there are two or more:
const navPages = bottomNavPages.length >= 2 ? bottomNavPages : [];
// ...
if (navPages.length > 0) {
// emit AppShell with NavigationBar
}
Material guidance also puts bottom navigation at three to five destinations, so the editor refuses a sixth item instead of letting the generator emit something cramped. The pattern here is that validity rules belong where the user is making the choice, not only in the generator that has to cope with it afterwards.
4. A settings row that was doing too much
My theme is stored as one row: colours, typography, spacing and feature flags together. The API replaces that row wholesale on save. That was fine while one page edited it all, and it stopped being fine when I wanted feature flags to live on their own page.
A new page outside the provider can't call useTheme(), and it can't safely save on its own either, because a save from there would overwrite colours, fonts and spacing with defaults. The only safe writer is one that loaded the whole row first, which is exactly what the shared provider is for.
Separating flags properly means a new endpoint, a column change, and a migration across all five databases I support. I decided that wasn't worth doing this week, and I wrapped the page in the provider as a stopgap. It works, but it's a cost I created.
The lesson: data that changes independently should be stored independently. Full-row replace is a convenient API until the second editor shows up.
What I'd do differently
- Give every flag one meaning, and name it after what it controls.
- Derive structure from structure, not from presentation.
- Put constraints in the editor, not only in the generator.
- Split storage along the lines that data changes, before it costs five migrations.
Your turn
If you've built a code generator or a schema-driven tool, which of these have you hit? And what's the bug that taught you the most? I'd like to hear it.
If you want to check it out for yourself. Here is a free version - Underpeaks Core Download
Top comments (0)