How I keep 98 tools and 32 games in one Flutter app: metadata in a plain const list, builders in a deferred registry map, one parametric route, one test.
My portfolio site currently ships 98 interactive browser tools and 32 games. Every one of them is a real Flutter widget with its own URL, its own SEO title and description, its own Open Graph share card and its own tile in the browse grid. The number that impresses people is 130. The number I actually care about is what it costs me to make it 131 — and right now that cost is a new file, one entry in a metadata list, and one entry in a registry map. No route file to edit, no switch statement to extend, no navigation shell to re-wire, no new import that lands in the initial JS bundle.
It did not start that way. The first dozen tools were hand-wired the way every go_router tutorial shows you: an import at the top of router.dart, a GoRoute in the routes array, a card added to whatever list renders the directory page, and SEO copy-pasted into the widget. That shape is completely fine at twelve. At forty it's a chore. At ninety-eight it's a liability — not because typing four things is hard, but because there is nothing keeping those four things in agreement, and because every one of those imports makes the router a live reference to every tool's code, which is exactly what stops a bundler from splitting anything.
This post is the pattern I replaced it with, as it actually exists in the repo: metadata in a plain const list that anything may import, widget builders in a Map<String, WidgetBuilder> that only the router imports and only deferred, one parametric route that resolves a slug through that map, and a shared scaffold that hands every feature its shell, SEO and related-items rail for free. I'll go through why the metadata/implementation split is the load-bearing decision rather than a tidiness preference, what you genuinely give up (compile-time route safety, and it's a real loss), the one test that buys most of it back, and the cases where this whole thing is overkill and you should just write the routes.
Take the naive version. Adding "Cron Parser" means touching four files: router.dart gets an import and a GoRoute, the tools page gets a card entry, the tool widget itself gets its SEO block, and the sitemap gets a <loc>. Four edits, four chances to typo a slug.
The failures that come out of that are all silent. A card that links to /tools/cron-parser while the route is registered as /tools/cron. A route with no card, so the tool exists but nothing links to it and nobody ever finds it. A tool in the sitemap that 404s, which is a genuine SEO own-goal. None of these are compile errors. All of them get found by a user, or by Search Console three weeks later.
But the worst part is the imports. Every import '.../cron_parser_page.dart' at the top of router.dart is a hard, non-deferred reference. Dart's deferred loading is a whole-program reachability analysis: a library goes in the main chunk if any non-deferred path reaches it. One eager import in the router and that tool's code — plus its transitive dependencies, which for my image tools means a PDF writer and an image codec — is in main.dart.js forever. Somebody who lands on a blog post downloads all 98 tools in order to read 1,200 words about offline sync.
So the goal isn't "fewer keystrokes." The goal is: make the four things structurally incapable of disagreeing, and make the router's knowledge of a feature a string instead of a symbol.
lib/pages/tools/interactive/builtin_tools.dart is 852 lines and contains no logic. It defines one record type and one const list of them:
class BuiltInTool { final String name; final String description; final String category; final String route; // /tools/<slug> final IconData icon; const BuiltInTool({...});}const List<BuiltInTool> kBuiltInTools = [ BuiltInTool( name: 'Cron Parser', description: 'Explain any cron expression in plain English and preview ' 'the next run times.', category: 'Developer', route: '/tools/cron-parser', icon: Icons.schedule_rounded, ), // ...97 more];The single most important property of this file is what it does not import. There is no cron_parser_page.dart in it, no page class, no reference to any tool's implementation. Its only import is package:flutter/material.dart, for IconData — and that's a deliberate compromise I'll come back to, because it's the one impurity in the design.
The slug is derived, not stored: route.split('/').last. One string, one source of truth, no chance of route and slug drifting apart. There's an index built from the list for lookups:
final Map<String, BuiltInTool> _builtInBySlug = { for (final t in kBuiltInTools) t.route.split('/').last: t,};BuiltInTool? builtInToolForSlug(String slug) => _builtInBySlug[slug];IconData iconForToolSlug(String slug) => _builtInBySlug[slug]?.icon ?? Icons.extension_outlined;tools_registry.dart is the mirror image: 98 imports, and one map. That's the entire file — 204 lines, about half of them import statements.
/// slug -> builder for every built-in interactive tool. Imported/// `deferred` by the router so all 98 tool widgets are split into their/// own JS chunk, downloaded only when a visitor opens a tool.final Map<String, WidgetBuilder> kToolBuilders = { 'json-formatter': (_) => const JsonFormatterPage(), 'base64': (_) => const Base64Page(), 'cron-parser': (_) => const CronParserPage(), // ...95 more};Note it's final, not const — WidgetBuilder closures can't be const. That has a consequence worth knowing: a duplicate key in a const map literal is a compile error, but in a final one it's last-write-wins at runtime, silently. Two entries for 'base64' and the first one just quietly stops existing. That's one of the things the test at the end catches.
The games side is identical in shape — builtin_games.dart holds 32 BuiltInGame records with the same five fields plus a String get slug => route.split('/').last, and games_registry.dart holds kGameBuilders. Same pattern, second domain, and the fact that it transplanted without modification is the best evidence it was the right shape.
The router has no per-tool entries at all. It has one route for tools and one for games:
import 'package:my_portfolio/pages/tools/interactive/tools_registry.dart' deferred as tools_lib;GoRoute( path: '/tools/:slug', builder: (context, state) { final slug = state.pathParameters['slug']!; return DeferredPage( load: tools_lib.loadLibrary, builder: () => tools_lib.kToolBuilders[slug]?.call(context) ?? const NotFoundPage(), ); },),Ninety-eight routes collapse into eleven lines. DeferredPage is a small StatefulWidget that holds the loadLibrary() future, renders the site shell plus a spinner while the chunk downloads, renders a "check your connection and refresh" empty state if it fails, and calls builder() when it resolves. It matters that the loading and error states live in one widget rather than in each feature: 98 pages each handling their own chunk-load failure would be 98 opportunities to handle it differently.
ToolScaffold is what makes a tool file only about the tool. It takes an eyebrow, title, subtitle, the SEO pair, the path, and a child. On mount it does the two things every page has to do and nobody remembers to do:
@overridevoid initState() { super.initState(); final slug = widget.path.split('/').last; Seo.update( title: widget.seoTitle, description: widget.seoDescription, path: widget.path, type: 'tool', entityName: widget.title, section: builtInToolForSlug(slug)?.category, imageUrl: '${AppConfig.siteUrl}/og/tools/$slug.png', ); Analytics.pageView(widget.path, title: widget.seoTitle);}Look at what it derives rather than demands. The category for structured data comes from the metadata list by slug. The Open Graph image URL is computed from the slug, so a per-tool share card is wired up by existing, not by being registered anywhere. And the related-tools rail at the bottom of every tool page is a three-line query over kBuiltInTools — same-category siblings first, then anything else, take three. Ninety-eight pages of internal linking that nobody maintains.
So the actual cost of tool #99 is: write foo_page.dart wrapped in a ToolScaffold, add a BuiltInTool(...) to the const list, add 'foo': (_) => const FooPage(), to the map. Everything else — route, shell, SEO, analytics, share card, browse tile, related links — falls out.
Everything above is nice. This part is the reason the pattern works at all, and it's the bit people skip when they copy it.
lib/widgets/cards/tool_card.dart — the widget that renders each tile on the /tools directory page — imports builtin_tools.dart. It has to: it needs iconForToolSlug(tool.slug) to draw the right glyph on a card whose data came from Firestore. The directory page renders 98 of those cards, plus search, plus category filters, all off metadata.
Now imagine the metadata and the builders lived in the same file, the way "keep related things together" instinct tells you to write it. tool_card.dart imports it. tool_card.dart is reachable from the home page. Therefore all 98 tool widgets are reachable from the home page, therefore they're in the main chunk, therefore the deferred import in the router does absolutely nothing. You would still have your elegant one-line registration and your bundle would be exactly as big as before. The pattern would look like it was working while achieving none of its point.
Here's what the split actually buys, from the current release build:
| chunk | raw | brotli |
| --- | --- | --- |
| main.dart.js | 4.98 MB | 1.09 MB |
| main.dart.js_1.part.js (98 tools) | 3.53 MB | 783 KB |
| main.dart.js_3.part.js (32 games) | 310 KB | 82 KB |
A visitor reading a blog post downloads the main chunk and none of that 865 KB of feature code. Someone who opens the JSON formatter pays 783 KB once and then has all 98 tools cached. The tools chunk is fat because those pages drag in a PDF writer, an image codec and a QR encoder; the games chunk is small because 32 games are mostly CustomPainter and arithmetic. Either way, neither is in the critical path of a first paint.
The same metadata list has four other consumers that would each have triggered the same trap: the related-tools rail, the sitemap, the per-slug OG card generation, and builtInToolSeeds() — the function behind the admin panel's "Sync built-in tools" action, which projects the code registry into Firestore documents so the directory page can serve built-ins and external links through one query. Five consumers of the data, exactly one consumer of the code.
Which gives the rule I'd write on a whiteboard: metadata is data about a feature and must be importable from anywhere; implementation is the feature and must be importable from exactly one place. If a second place needs the implementation, you have a design problem, not an import problem.
The one impurity: builtin_tools.dart imports Material for IconData. It's fine here because Material is in the main bundle regardless, but if you're doing this in a package that shouldn't depend on Flutter, store an icon name or a code point and resolve it at the render site. The principle is that the metadata file's dependency set should be as close to empty as the language allows.
I want to be straight about this, because the pattern trades a real guarantee for a real benefit and pretending otherwise is how people get burned.
context.go('/tools/cron-parsr') compiles. It compiles happily, ships, and 404s for a user. With hand-written routes and go_router's typed routes you'd have caught that at build time. Here the compiler has no opinion about strings, and the ?? const NotFoundPage() at the end of the builder is doing an enormous amount of load-bearing work.
Three more losses worth naming:
CronParserPage and you get exactly one hit: the registry map. You can no longer ask the IDE "who navigates here," because nobody navigates there — they navigate to a string.You mitigate the first by never hand-writing a tool URL anywhere in the app — every link goes through tool.route from the metadata, so the strings all originate in one file. And you mitigate the rest with a test.
The invariant is one sentence: the set of slugs in the metadata list and the set of keys in the builder map are identical. That's a unit test, it needs no widgets, and it runs in well under a second.
test('metadata and builder registry agree on every slug', () { final meta = kBuiltInTools.map((t) => t.route.split('/').last).toSet(); final built = kToolBuilders.keys.toSet(); expect(meta.difference(built), isEmpty, reason: 'metadata with no builder → a live card that 404s'); expect(built.difference(meta), isEmpty, reason: 'builder with no metadata → unreachable page, no card, no SEO');});test('slugs are unique and well-formed', () { final routes = kBuiltInTools.map((t) => t.route).toList(); expect(routes.toSet().length, routes.length, reason: 'duplicate route'); for (final t in kBuiltInTools) { expect(t.route, startsWith('/tools/')); expect(t.name.trim(), isNotEmpty); expect(t.description.trim(), isNotEmpty); // becomes the meta description }});That second test is also what surfaces the silent duplicate-key overwrite in the final map, because a duplicated slug there shows up as a size mismatch against the metadata list.
Once you have the harness, keep extending it with every invariant the compiler can't express. Every slug has an OG card on disk at web/og/tools/<slug>.png. Every slug appears in web/sitemap.xml. No description exceeds the length Google will actually render. Categories come from a known set, so a typo'd 'Developr' doesn't create a ghost filter chip on the browse page.
That's the honest summary of the trade: you give up guarantees the compiler was handing you for free, and you buy them back at test time for the price of remembering to write the test once. It's a good deal, but only if you actually write it — an unenforced convention is just a comment.
I wouldn't reach for it in most codebases, and it's worth being precise about why.
Under about ten features, don't. Ten explicit GoRoutes are clearer than any indirection, they're typed, and you can read the whole navigation surface top to bottom. Registries pay off on the slope of the curve, not at the start of it.
When the features aren't structurally identical, don't. This works because all 98 tools have exactly the same shape: no constructor arguments, no nested routes, no route-specific guards, one scaffold. The moment a feature needs /tools/x/:sub, or a required parameter, or an auth check, it wants a real GoRoute and it should get one. A registry with three special cases bolted onto it is worse than no registry.
When typed navigation is a hard requirement, don't. go_router's @TypedGoRoute codegen gives you compile-checked navigation and it is fundamentally incompatible with "look the destination up by string." If your team's bar is "the compiler proves every link resolves," this pattern is not for you, and that's a legitimate position.
When nothing but the router needs to enumerate the features, probably don't. Half the value here comes from the browse page, the search, the sitemap and the related rail all reading the same list. If the router is the only consumer, you've built a lookup table to replace a lookup table.
The four conditions where it does earn its keep: N is large and still growing; the features are uniform; something other than the router needs to enumerate them; and you need code splitting so those features stay out of the initial download. My tools and games hit all four, which is why the same pattern got written twice, and why adding the 99th tool this week will be an afternoon of building the actual tool and about thirty seconds of wiring.
deferred keywords the router has.Map<String, WidgetBuilder> replaces N hand-written route entries, and the ?? const NotFoundPage() fallback is the only error handling the whole scheme needs.The thing I'd underline is that none of this is clever. There's no code generation, no reflection, no build_runner step, no annotations — just a const list, a map, and the discipline to keep them in different files. That's the whole trick. Adding a feature should feel like adding a row to a table, and when it does, the number of features stops being an architectural question and goes back to being a product one.