Mobile apps
Rebuilding a field-service app: Cordova to Expo + Supabase
A klimaattechniek company in Apeldoorn ran 38 monteurs on a 9-year-old Cordova app held together with 41 plugin patches. We rebuilt it on Expo and Supabase.

At 06:42 on a Tuesday in February, a monteur is in a crawl space in Apeldoorn-Zuid with a Vaillant ecoTEC that won't hold pressure. There is no cell signal. The old app, written in Cordova and jQuery Mobile and signed by a developer who left the company in 2019, has just decided that the F-gassen form needs to reload from the server.
It cannot. He photographs the manometer with his personal phone, writes the reading on a scrap of paper, and drives to the next address. The office will type it in tonight.
This is the moment we were hired to fix. The company runs 38 monteurs out of one location, handles roughly 1,840 storingsbezoeken a week across the Veluwe, and was about to fail a Play Store deadline on target SDK 34 with an app that still bundled a WebView shim from 2016.
The forty-one patches
The original app shipped in 2017. By the time we audited it, the repo contained 41 Cordova plugin patches, of which 19 pointed at GitHub forks owned by accounts that hadn't pushed since 2020. Two of those forks were soft-deleted. The build server was a Mac mini under a desk in IT, and only the office manager knew the keychain password.
The monteurs liked the app, in the sense that they had built a vocabulary of workarounds. "Just kill it and restart" was the documented step three of every troubleshooting flow. The F-gassen logbook crashed on iOS if you tried to add a fourth photo. Service reports synced when they felt like it. Twice a year the entire database had to be rebuilt from a CSV export because IndexedDB ran out of quota on older Android devices.
None of this was a crisis on any given day. It was a tax of about ten minutes per monteur per day, every day, which is roughly 100 working hours a week the company was donating to a 2017 web stack.
Expo, Supabase, and a deliberately boring stack
We chose Expo and Supabase for the rebuild, and we want to be specific about why, because the choice gets misread as fashion.
Expo gave us EAS Build (no Mac mini), config plugins (no manual native edits), and over-the-air updates that the office manager can ship without the Play Store taking three days. Supabase gave us Postgres, row-level security, edge functions, and storage in one product, billed in one place, with one set of credentials to rotate. The team that has to keep this running after we leave is two part-time IT staff and one external developer. The fewer accounts, the better.
We did not choose React Native CLI. We did not choose Firebase. We did not choose Flutter. Both options were on the table; the deciding factor was operational, not aesthetic. EAS Update lets us push a hotfix to all 38 phones in under an hour when a monteur calls at 17:30 to say something is wrong. Nothing else we tested matched that loop.
The principle underneath: pick the stack the in-house team can keep alive. The best architecture is the one that doesn't need you back in eighteen months.
Offline-first NEN 1010 inspections
The hard part was not the UI. The hard part was that a NEN 1010 keuring is a 40-field form that has to be completable in a kruipruimte with zero connectivity, photographed, signed by both parties on the device, and arrive at the office before the monteur does, ideally without conflict.
We used Expo SQLite as the source of truth on device, with a sync layer that pushes mutations to a Postgres table via Supabase. The schema is intentionally append-only at the row level for inspection records: a monteur cannot edit a submitted keuring, only file an addendum. This solved the conflict problem by removing it.
create table inspections (
id uuid primary key,
job_id uuid not null references jobs(id),
monteur_id uuid not null references users(id),
payload jsonb not null,
signed_at timestamptz not null,
client_signature_svg text not null,
monteur_signature_svg text not null,
device_id text not null,
created_at timestamptz not null default now()
);
alter table inspections enable row level security;
create policy "monteur reads own"
on inspections for select
using (monteur_id = auth.uid());
create policy "monteur inserts own"
on inspections for insert
with check (monteur_id = auth.uid()
and signed_at <= now() + interval '5 minutes');
The five-minute clock-skew tolerance on signed_at exists because monteur phones are not NTP-synced when they're underground. We learned that the hard way during the first beta week.
Photos are a separate problem. Each inspection carries up to twelve photos, and the connection back at the van is often a single bar of 4G in a metal cage. We queue uploads with exponential backoff, deduplicate by SHA-256, and let the form submit before the photos are done. The keuring is legally complete the moment the signature lands; the photos catch up on the drive home.
A diagnose-coach kept on a short leash
The most-watched feature, internally, was the AI diagnose-coach. The monteurs wanted it. The works council was nervous. We agreed on a narrow scope and held the line.
The coach takes three inputs: the device type and model, the fault code (if any), and the monteur's free-text description of what they're seeing. It returns a ranked list of likely causes and the next diagnostic step. It does not order parts. It does not close jobs. It does not talk to the customer. It writes a suggestion into a side panel, which the monteur reads, ignores, or follows. Every interaction is logged.
We use Claude Sonnet 4.5 through the Anthropic API, with a system prompt that pins the model to Dutch, restricts it to HVAC diagnostics, and forces a JSON response. The recurring lesson from the public discussion on reliable agentic AI systems is the same one we landed on internally: the trick is to give the model less authority, not more. Our coach is not an agent. It is a search engine that knows about boilers.
// edge function: diagnose-coach
const system = `Je bent een HVAC diagnose-assistent voor monteurs in Nederland.
Antwoord altijd in het Nederlands en uitsluitend in geldig JSON volgens dit schema:
{ "oorzaken": [{ "oorzaak": string, "kans": number, "volgende_stap": string }] }
Geef maximaal 5 oorzaken, gesorteerd op kans (0-1).
Verwijs niet naar onderdelen tenzij de monteur die expliciet noemt.`;
const res = await anthropic.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 700,
system,
messages: [{ role: "user", content: prompt }],
});
One scheduling note, because it affected our rollout: Anthropic announced that certain API capabilities would require organisation ID verification starting July 8, 2025. We had to schedule the verification two weeks ahead of the cutover. If you're shipping a Claude-backed feature into a regulated industry on a deadline, build that step into the plan now rather than the day before.
If the coach is wrong, the monteur is liable. We made the disclosure visible in the side panel and logged every suggestion-and-action pair for the works council audit. Build the receipts before you build the feature.
F-gassen logbook into the RVO register
The boring part ate three weeks. EU regulation 517/2014 on fluorinated greenhouse gases requires operators of cooling systems above certain charge thresholds to keep a logbook of leak checks, refills, and recovery. In the Netherlands this is reported into the RVO F-gassen register.
The register has an upload format, not a real-time API. We built a per-monteur logbook in Postgres, with the same append-only pattern as inspections, and a nightly edge function that produces the RVO-compatible export and emails it to the bookkeeper for review and submission. The bookkeeper signs it off; the system never submits to a government endpoint without a human in the loop. That was a deliberate choice, made for liability reasons rather than technical ones.
The migration of the legacy logbook was the worst part. The old system stored F-gas readings as a single TEXT column with semicolons. We wrote a one-shot importer that parsed roughly 14,000 historical entries, flagged 73 that the regex couldn't handle, and handed those to the bookkeeper. She caught one entry from 2019 where a comma had been used as a decimal separator and the old app had silently dropped 0.6 kg of R-32 from a school's record. That single fix justified the migration on its own.
What shipped, and what it cost the day-to-day
The new app went live in March 2026 after a six-week internal beta with eight monteurs. Three months in:
- Average inspection completion time dropped from 18 minutes to 11.
- The "office types it in tonight" workflow is gone. End-of-day reconciliation went from 90 minutes for the planning team to under 15.
- App-related support calls from the field dropped by about 70%. The remaining calls are almost entirely password resets and broken screens.
- The diagnose-coach is used in roughly 40% of storingsbezoeken. The most experienced monteurs use it least, which is what we expected. Two senior monteurs told us they use it mainly to second-check their own gut.
The number we care about most is the one that doesn't appear on a dashboard: nobody types a manometer reading from a scrap of paper into a desktop computer at 19:00 anymore.
What we'd do differently
Three things, if we were starting over.
We would build the offline conflict UI first. We treated it as an edge case in week two and a feature in week six, and we had to retrofit it across three screens. Whatever you think your offline rate is for a field-service app, it is higher than that.
We would scope the AI feature even more narrowly at launch. The diagnose-coach we shipped is roughly half the feature we initially scoped. Cutting it was the right call. We would have cut it sooner.
We would version the export format from day one. The RVO submission template changed once during the project. We had to chase it across two tables and a JSON column because we'd flattened too early. A version field would have been ten minutes of work in week one.
When we built this for the Apeldoorn klimaattechniek company, the thing we kept running into was that operations regulations and tradesperson reality do not share a vocabulary. We ended up solving it by writing the data model from the regulation backwards and letting the UI translate. We do this kind of mobile app rebuild often enough that the first slide of every estimate is a crash-log count.
If you have a Cordova app held together with patches, a paper trail to a Dutch register, and a team that has stopped trusting the tool: open the old app's crash dashboard and count the unique device models from the last 30 days. That number is the size of your problem.
Key takeaway
Pick the stack your in-house team can keep alive. The best field-service rebuild is the one that doesn't need its consultants back in eighteen months.
FAQ
Why Expo and not bare React Native for a field-service app?
Operational reasons. EAS Build removes the in-house Mac, config plugins remove manual native edits, and EAS Update ships hotfixes in under an hour without a Play Store review. A small in-house team can keep that running.
How do you handle sync conflicts in an offline-first inspection app?
We made inspection records append-only at the row level. A submitted keuring cannot be edited, only amended with a new addendum row. The conflict problem disappears because two devices can never overwrite the same record.
Is it safe to put an LLM in front of a regulated trade like HVAC?
Yes, if its authority is narrow. Our coach suggests causes and next steps inside a side panel, never closes jobs, never orders parts, never speaks to the customer. Every suggestion is logged for audit. The monteur remains accountable.
How does the F-gassen logbook reach the RVO register?
We export a nightly RVO-compatible file from Postgres and email it to the bookkeeper, who reviews and submits it. No automated submission to a government endpoint. The human in the loop is a deliberate liability choice.