Six months building hiignite: Rust, Expo, and one typed contract

A mobile app, a web dashboard and an API, built as one connected system instead of three separate stacks — and the stack decisions that have actually paid off.

Six months ago I started building hiignite, a coaching platform for personal trainers. Mobile app, web dashboard, and API — built as one connected monorepo system instead of three separate stacks. I'm still building it in my free time, so every decision had to survive a specific test: will this still make sense to me after two weeks away from the code?

Here's what I picked, and why.

One system, not three stacks

The obvious way to build this is three repositories: an app, a dashboard, and a backend. Three CI setups, three dependency trees, three places where the definition of "a client" can quietly drift apart.

I went with a monorepo instead. Not for the tooling fashion — for the seam. When the app, the dashboard and the API live in one tree, a change to a data shape is one commit that touches every consumer of it, and the thing either builds or it doesn't. Working solo, in evenings, that feedback is worth more than any amount of separation-of-concerns purity. The failure mode I was designing against isn't "the code is messy." It's "I shipped a field rename on Tuesday and found out on Sunday that the app never got the memo."

Expo for mobile and the web dashboard

Coaches work from a laptop. Their clients work from a phone. That's two products with a lot of shared vocabulary — the same programs, the same sessions, the same clients — and I didn't want to write that vocabulary twice.

Expo covers both from one codebase. iOS, Android and the web dashboard come out of the same components, the same navigation, the same state. Where the platforms genuinely diverge they diverge deliberately, in a file I had to open on purpose, rather than by accident across two codebases that slowly stop agreeing with each other.

Rust and Axum for the API

This one I'll be honest about: I was learning Rust and I wanted to use it on something real. Toy projects teach you syntax. They don't teach you what the borrow checker feels like at 11pm when you're four layers into a request handler and something doesn't live long enough.

Axum turned out to be a good fit for exactly that reason — handlers are plain async functions, extractors are types, and most of what would be framework magic elsewhere is just the type system doing its job. The learning curve was real. What I got in return is an API where a whole category of "what if this is null" questions is answered at compile time rather than in production.

Would a solo builder ship faster in something they already know? Yes — and not by a little. Development in Rust is slower than in any language I'd already learned, and I took that trade deliberately. The typed contract further down is a big part of what I got back for it.

SeaORM, because I came from Laravel

Most of my background is Laravel, and the thing I missed most when I moved to Rust wasn't the syntax — it was migrations. Having a versioned, ordered, reversible history of the schema is not a nice-to-have; it's how you stay sane when the schema changes weekly.

SeaORM is the option that felt most familiar coming from that world. Entities, relations, and a migration story that maps closely onto how I already thought about the database. It's not a one-to-one Eloquent port and it doesn't pretend to be, but the mental model transferred, which meant I spent my learning budget on Rust itself instead of on re-learning how to add a column.

Specta: the decision that paid off most

Here's the seam that would have hurt: the API is Rust, and both clients are TypeScript. Every endpoint is a place where a Rust struct becomes JSON and a TypeScript type has to agree with it. Hand-write those types on the client and you've built a bug generator — one that only fires at runtime, on a device, usually after you've shipped.

Specta generates the TypeScript from the Rust. The struct is the source of truth:

#[derive(Serialize, Type)]
pub struct Client {
    pub id: Uuid,
    pub name: String,
    pub active: bool,
}

…and the client-side type falls out of it:

export type Client = { id: string; name: string; active: boolean }

Which sounds like a small convenience and isn't. Rename a field in Rust, regenerate, and the app and the dashboard both stop compiling in exactly the places that need attention. The classic mobile/web type-mismatch bug — where one client was updated and the other wasn't — stops being a bug you find and starts being a build you can't finish. It got caught before it happened, which is the only kind of catching that's actually cheap.

If I had to keep one decision from this whole list, it would be this one.

Bruno, so the API explains itself

API collections in Bruno, checked into the repo alongside the code. The point is that the API is explorable without digging through handler source to work out what a request is supposed to look like.

Right now the person that helps most is me — future me, coming back to an endpoint I wrote in March. But it's also the difference between "here's the API" and "here's the API, and here's a folder of working requests you can fire at it." Bruno keeps those requests as plain files in git rather than trapped in someone's account, which is the part that made me pick it.

And Docker, because why not

Docker for the environment. Postgres and the API come up with one command, the same way on any machine I sit down at. It costs almost nothing to set up early and it's genuinely painful to retrofit later, so it went in early.

Six months in

The stack, in one line: Expo for mobile and the web dashboard, Rust + Axum for the API, SeaORM for the schema, Specta for the type contract between them, Bruno for exploring the API, Docker for the environment — all in one monorepo.

None of it is exotic. What's held up is the theme running through it: put the seams where a machine can check them. The monorepo makes a cross-cutting change one commit. Expo makes the app and the dashboard one vocabulary. Specta makes the Rust/TypeScript boundary a compile error instead of a support ticket. For someone building in their free time, the compiler is the only teammate reviewing my work — so I've tried to give it as much to review as possible.

Still building. More notes as things break.

Prefer hiignite on Google Mark hiignite as a preferred source and these posts show up more often in your Google Search results. Add as preferred source

Want to see what I'm building?

hiignite is personal trainer and online coaching software — free to start, $19/mo Pro, no per-client or per-message fees. Join the waitlist for founders-tier pricing.

// No spam. One email when we launch.