All work

DM Champ

SaaS platform for digital marketing agencies to manage clients and campaigns, with AI-assisted automation.

ReactTypeScriptElectronNode.jsSupabaseFirebaseCloud FunctionsGoogle CloudAnthropicOpenAIFlutterDart
DM Champ interface
The web app was built in Flutter Web. It worked, but it never behaved like a web page — Flutter renders to canvas, so the things users do without thinking, like selecting text or trusting the back button, had to be re-implemented or lived with. The package ecosystem for web-specific work was thin enough that most integrations became custom code. Moving to React meant rebuilding the frontend of a product agencies were already using daily, with three developers who also own the backend and the AI layer, and without pausing feature work.
One of three developers on the product. I led the migration of the web app from Flutter Web to React and maintain the Electron desktop client that ships from the same codebase. I also build and maintain the backend behind them: auth, the REST APIs, and the data model on Node.js, Supabase, and Firebase Cloud Functions. The AI features are mine end to end — the customer-facing chatbot, AI-assisted campaign creation, and automated appointment booking. Separately, I built the Flutter mobile app that ships under the same organisation. Small team, so I own most of what I build from design through to production.
The web app runs on React and the desktop client ships from that same codebase. Ongoing since May 2024, with features still landing across the frontend, the backend, and the AI layer behind them.

Migrated the web app from Flutter Web to React

Flutter Web renders to canvas, so ordinary web behaviour — text selection, browser navigation, form autofill — was something we re-implemented rather than inherited. React gives that back by default, and the ecosystem meant integrations became a dependency instead of a project. Most of the velocity gain came from the second part.

Rejected: Staying on Flutter Web and closing the gaps case by case

One React codebase on the web, wrapped in Electron for the desktop

The desktop product is the web product with a native shell around it, so a single codebase covers both surfaces. A separate desktop client would have doubled the frontend for a team that also owns the backend and the AI layer.

Rejected: A native desktop client alongside the web app

Product logic lives in the API; the clients stay thin

Auth, validation, and the campaign rules sit behind the REST API rather than in the frontend. That is what made the migration affordable — the frontend was being replaced while the rules underneath it were not, so it stayed a UI rewrite instead of a product rewrite.

Rejected: Pushing rules into the client for fewer round trips

Ran all model calls through Cloud Functions instead of the client

Kept API keys server-side, let us enforce a per-tenant cost cap, and meant the chatbot and campaign generation behaved identically before and after the migration — the AI layer never had to move.

Rejected: Calling the model providers directly from the client

The problem

DM Champ is where marketing agencies run their client work — campaigns, clients, and the automation around both. The web app that did all of that was written in Flutter Web.

Flutter Web does not render HTML. It paints the interface onto a canvas, which means the browser does not really know what is on the page. Text selection, autofill, the back button, the small reflexes users never think about until they fail — each one is something you handle deliberately rather than get for free. None of them is fatal alone. Together they make a product feel subtly unlike the web it runs in, and this is a product people have open all day.

The second problem was slower and mattered more. Web-specific work had a thin package ecosystem behind it, so integrations that would be an afternoon in React turned into custom code we then owned. For three developers who also carry the backend and the AI features, that is the constraint that compounds.

So: replace the frontend of a product agencies use daily, without a feature freeze and without a rewrite the team could not finish.

What I built

What made this survivable was where the boundary already sat.

Product logic lives behind the API. Auth, validation, and the rules that assemble a campaign run on Node.js, Supabase, and Firebase Cloud Functions. The frontend renders state and collects input. So the migration was scoped to the part that was genuinely wrong — the UI layer — while the model of the product underneath it never moved.

The AI layer never moved either. The chatbot, AI-assisted campaign creation, and automated appointment booking all run as Cloud Functions. No client holds an API key and no client holds a prompt, which meant none of it was in the blast radius of a frontend rewrite.

Web and desktop are one React codebase, with Electron supplying the native shell. Migrating the web app moved the desktop client with it rather than doubling the work.

Decisions

The call worth explaining is why leaving Flutter Web was worth the disruption at all, because "it feels slightly off" does not on its own justify rebuilding a shipping frontend. The honest answer is that the feel problem was the visible half and the ecosystem problem was the expensive half. Re-implementing web behaviour is bounded work you do once. Owning custom code for every integration because the package you needed did not exist is unbounded, and it was landing on the same three people who own the backend.

The compensating structure is the API boundary. Because the rules were already server-side, the migration cost a user interface, not a domain model — and the AI features, which are the part of the product hardest to re-verify, were never in scope.

Outcome

The web app runs on React, the desktop client ships from the same codebase, and the AI layer came through the migration untouched. A prompt or model change still lands in one Cloud Function and is live on both surfaces at once.

What I would weigh differently: the boundary that made this migration cheap held because the backend was already the place rules lived. That was partly a deliberate call and partly luck, and it is worth being deliberate about keeping it — the desktop client in particular has quietly accumulated the assumption that it is a browser.