The Stack Behind UmimaClean: AdonisJS, Inertia, Leaflet, Midtrans

A tour of every library in a real production codebase and what each one actually cost - why AdonisJS over Express, Inertia instead of a REST API, Leaflet with Google tiles, Midtrans Core API over Snap, and the two data-type gotchas that broke my spreadsheet exports.

I wrote about the decisions behind UmimaClean — the frozen prices, the claim lock, the payment escape hatch. This post is the other half: the actual libraries, why each one is there, and what it cost.

Stack posts usually read like a list of things the author enjoyed. I want this one to be more useful than that, so every choice below comes with the part that was annoying.

The Shape of It

Browser
  │
  │  React 19 + Inertia 2          ← pages, no API client
  ▼
AdonisJS 7  (Node 24, TypeScript, ESM)
  │
  ├── VineJS          validation
  ├── Lucid 22        ORM
  ├── Transmit        server-sent events
  ├── Drive           private file storage
  ├── Limiter         rate limiting
  ├── Shield          CSRF
  └── ExcelJS         spreadsheet exports
  │
  ▼
PostgreSQL

External:  Midtrans (QRIS)  ·  Fonnte (WhatsApp)  ·  Google tiles via Leaflet

One process. One database. No queue, no worker, no Redis. The only scheduled work is a nightly housekeeping command.

AdonisJS 7

Why not Express. Express is a router with a middleware chain. Everything else — validation, ORM, auth, sessions, CSRF, rate limiting, file storage, the folder layout — is a decision you make yourself, and then remake slightly differently on the next project.

For an application built once by one person and then maintained for years, I wanted the framework to have already made those decisions. Adonis is the Node framework that behaves like Laravel or Rails: batteries included, opinionated structure, first-class TypeScript.

What it gives you that matters day to day. Dependency injection is the one I would miss most:

@inject()
export default class TaskService {
	constructor(
		private routeService: RouteService,
		private broadcastService: BroadcastService
	) {}
}

No container registration, no factory, no service locator. The constructor’s types are the wiring. OrderService depends on TaskService and TransactionService; TaskService depends on RouteService and BroadcastService; nobody writes a line of setup for any of it.

Subpath imports keep the import block readable and refactor-proof:

import Order from '#models/order'
import { OrderStatus } from '#enums/order_status_enum'
import TaskService from '#services/task_service'

Those are real Node subpath imports declared in package.json, not a bundler alias — so they work in tests, in Ace commands, and in the production build without extra configuration.

The cost. The ecosystem is small. When something goes wrong the answer is in the Adonis docs, the Adonis Discord, or the framework source — Stack Overflow will not have it, and neither will an LLM trained mostly on Express. Adonis 7 is recent enough that a fair amount of what you find online is for v5 or v6 and quietly wrong.

That is a real tax, and it is worth being honest that it is paid in the moments you can least afford it. What made it acceptable is that the source is readable. Twice I ended up in node_modules/@adonisjs to answer a question, and both times the answer was obvious once I was there.

Inertia.js

This is the choice I would defend hardest.

The default modern architecture is a React SPA talking to a REST or GraphQL API. That means: route definitions on both sides, request and response types defined twice, a data-fetching library, loading states everywhere, and an API surface maintained for the benefit of exactly one consumer.

Inertia deletes the API. A controller renders a page component with props:

return inertia.render('customer/order/show', {
	order: OrderTransformer.transform(order).useVariant('toDetail')
})

The page is a normal React component that receives those props. No fetch, no useEffect, no cache invalidation, no loading spinner — the props arrive with the page. It is server-side routing with a client-side rendered UI, and for an app with one frontend it is the right trade almost every time.

The client setup is small enough to read in one screen:

createInertiaApp({
	title: (title) => (title ? `${title} - ${appName}` : appName),
	resolve: (name) => {
		return resolvePageComponent(
			`./pages/${name}.tsx`,
			import.meta.glob('./pages/**/*.tsx'),
			(page: ReactElement<Data.SharedProps>) => <Layout children={page} />
		)
	},
	setup({ el, App, props }) {
		createRoot(el).render(
			<TuyauProvider client={client}>
				<App {...props} />
			</TuyauProvider>
		)
	},
	progress: { color: '#4B5563' }
})

Shared props are the feature that pays for itself. Middleware attaches errors, flash.success, flash.error, and the current user to every page render, so no page ever has to fetch the signed-in user or thread flash messages through props.

Transformers are the boundary. Since props are serialised to the page, something has to decide what a model looks like on the wire. Each model gets a transformer with named variants, and the variants encode a permission decision rather than just a shape:

/**
 * Deliberately the order number and the two fields the badge is drawn from,
 * and deliberately nothing else. A queue is a list of work that is up for
 * grabs, seen by every staff member on shift — it is not a place to publish
 * customer names, phone numbers and home addresses.
 */
toQueue() {
  return {
    ...this.pick(this.resource, ['id', 'orderNumber']),
    status: this.resource.status,
    type: this.resource.type,
  }
}

The task board renders toQueue. Customer details appear only in the variant used after a task is claimed. Without a transformer layer, “just pass the model” would have put every waiting customer’s home address in the page source of a screen every staff member can open.

SSR is off. config/inertia.ts has the entrypoint wired and enabled: false. This is an internal tool plus a customer-facing booking flow behind a login — nothing needs to be crawlable, and the first paint is fast enough on the phones the staff actually carry. Turning it on later is a config flag, which is the whole reason it stays configured but off.

The cost. No mobile app can consume this. There is no API to point one at. If UmimaClean ever wants a native app, that is a real project — building the API layer I skipped. I took that bet knowingly: the work saved every day for years is worth more than the work deferred to a day that may never arrive.

Tuyau

@tuyau/core generates a typed route registry from the Adonis routes:

export const client = createTuyau({
	baseUrl: window.location.origin,
	registry
})

Which means a React component links to a named route rather than a hand-written string, and renaming a route breaks the build instead of breaking a link in production. Small piece, and it closes the last place where frontend and backend could silently disagree.

Lucid and PostgreSQL

Lucid is Adonis’s ORM — active record, so a model is a class you call methods on rather than an object you hand to a repository.

The thing I use most is not the ORM part, it is the query builder’s conditional helper:

return Order.query()
	.where('status', OrderStatus.AWAITING_PAYMENT)
	.if(filters.search, (query) => {
		query.where((matches) => {
			matches.whereILike('order_number', searchTerm).orWhereILike('customer_name', searchTerm)
		})
	})
	.preload('transactions', (q) => q.orderBy('created_at', 'desc'))

Filterable admin tables are most of the admin area, and .if() is what keeps them from becoming a pile of conditionally-concatenated query fragments.

Where Postgres does the work instead. Two rules I wanted the database to hold, not the application:

CREATE UNIQUE INDEX one_active_address_per_user
  ON addresses (user_id) WHERE is_active = true;

CREATE UNIQUE INDEX transactions_order_id_pending_unique
  ON transactions (order_id) WHERE status = 'pending';

Partial unique indexes. One active address per customer; at most one pending payment per order. An application-level check can be raced or forgotten by the next feature. An index cannot.

The numeric Gotcha

This one cost me an afternoon and it is worth knowing before you meet it.

The pg driver returns numeric columns as JavaScript strings. Not by accident — a JS number is a float64 and cannot represent every numeric value exactly, so returning a string is the correct, lossless thing to do. Node’s driver is right and my mental model was wrong.

The consequence is that order.totalPrice is '85000.00' at runtime, however it is typed. Which means totalPrice + shipping produces '85000.005000' and nobody notices until a total looks strange.

The screens never hit it, because their transformers run every amount through formatRupiah, which coerces on the way past:

export function formatRupiah(value: number | string) {
	return new Intl.NumberFormat('id-ID', {
		style: 'currency',
		currency: 'IDR',
		minimumFractionDigits: 0,
		maximumFractionDigits: 0
	}).format(Number(value))
}

Note the parameter type: number | string. That signature is not permissiveness, it is documentation of the problem.

The exports had no such step, so amounts landed in the spreadsheet as text — right-aligned, unsummable, and looking completely normal until an admin selects the column and Excel reports no total. The fix is one explicit coercion at the boundary:

export function excelNumber(value: number | string | null | undefined): number | null {
	if (value === null || value === undefined || value === '') return null

	const parsed = Number(value)
	return Number.isFinite(parsed) ? parsed : null
}

The general lesson: TypeScript types describe what you meant, not what the driver hands back. Anywhere data crosses a boundary the compiler cannot see — a DB driver, JSON.parse, a webhook body — the type is a claim, not a guarantee.

Transmit (Server-Sent Events)

Two channels, both one-directional.

orders/:orderNumber is what a customer’s open order page listens on, so the status moves under them while a staff member works. admin/orders is the shop-wide feed. Authorisation is declarative, in the routes file:

transmit.authorize<{ orderNumber: string }>('orders/:orderNumber', async (ctx, { orderNumber }) => {
	const user = ctx.auth.user
	if (!user) return false

	if (user.role === Role.STAFF) return true

	const order = await Order.query().where('order_number', orderNumber).first()
	return order?.userId === user.id
})

transmit.authorize(ADMIN_ORDERS_CHANNEL, async (ctx) => {
	return ctx.auth.user?.role === Role.ADMIN
})

One rule the broadcast layer holds: payloads carry stored values, never display strings.

transmit.broadcast(orderChannel(order.orderNumber), {
	orderStatus: order.status, // 'awaiting_payment'
	transactionStatus: transactionStatus ?? null
})

Not "Menunggu Pelunasan". A broadcast is data arriving at a screen that already knows how to print it — bake the Indonesian label in here and two places decide what a status is called, and one of them eventually drifts. Same reason the transformers send raw enum values and let the page render them.

The admin feed is deliberately one channel carrying three events (order:created, order:updated, order:paid) rather than a live mirror of everything happening in the shop. An admin needs to know when work arrives, when it moves, and when money lands. Push the rest and the dashboard becomes a firehose nobody can leave open.

Why not WebSockets. Nothing goes upstream. SSE is a plain HTTP response that reconnects on its own, needs no protocol upgrade, and traverses proxies without configuration. Socket.IO would have added a dependency, a second transport, and a set of failure modes to solve a problem I did not have.

Leaflet, and the Marker That Does Not Move

Customers pin their pickup address on a map. Staff get stops ordered nearest-first.

Leaflet over Google Maps JS. Leaflet is ~40 KB, MIT licensed, and needs no API key or billing account. react-leaflet wraps it in components and hooks that behave like React rather than like an imperative map library bolted to it.

The tiles, though, are Google’s:

<LayersControl.BaseLayer name="Default">
  <TileLayer url="https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}" attribution="Google Maps" />
</LayersControl.BaseLayer>

<LayersControl.BaseLayer checked name="Satellite">
  <TileLayer url="https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}" attribution="Google Maps Satellite" />
</LayersControl.BaseLayer>

OpenStreetMap coverage of Bandung’s residential streets is patchy, and the customers using this are pinning a house on a small side road. Satellite is the layer that defaults to checked, because people recognise their own roof faster than they recognise a street name.

That URL is Google’s internal tile endpoint rather than a licensed Maps Platform key, which is a licensing corner worth being upfront about — a production deployment that cares about terms should be on a proper Maps key or a paid tile provider.

The interaction decision I like most. The marker does not move. It is fixed dead centre of the container, outside the map, and the map pans underneath it:

;<CenterWatcher onChange={onChange} /> // fires on 'moveend', reports map.getCenter()

{
	/* Fixed center marker, pointer-events-none, translate(-50%, -100%) */
}

Dragging a small pin with your thumb on a phone means your thumb covers the exact thing you are trying to place. Dragging the map under a fixed pin means you can always see the target. Every mapping app on your phone works this way, and it is worth copying rather than reaching for <Marker draggable> because that is what the library documents first.

Rounded out with a GPS button (navigator.geolocation, enableHighAccuracy, 10 second timeout, a toast when permission is denied) and two hidden inputs so the coordinates submit with a plain form post — no controlled-form plumbing to synchronise.

Service Area: Not a Circle

The obvious way to answer “do we deliver there?” is a radius. Real coverage is not radial — the team goes much further along the good roads north and east than they do south or west.

So the limits are directional, and the boundary is blended between the two the address sits between:

const DIRECTIONAL_LIMITS_KM = { north: 30, south: 10, east: 30, west: 20 }
const verticalWeight = Math.abs(latitudeOffset) / totalOffset
const horizontalWeight = Math.abs(longitudeOffset) / totalOffset

const maxAllowedDistanceKm = Math.sqrt(
	(verticalLimit * verticalWeight) ** 2 + (horizontalLimit * horizontalWeight) ** 2
)

return distanceKm <= maxAllowedDistanceKm

An address due north gets the full 30 km. Due south gets 10. North-east gets something between the northern and eastern limits, weighted by how much of its offset is vertical versus horizontal. Roughly a quadrant-wise ellipse.

Distance itself is Haversine — straight-line, honest about it in the docblock, and wrong in the specific way that two kilometres across a river with one bridge is not two kilometres. A routing API would fix it, at the cost of a dependency and a per-call bill for a single van in one city.

Midtrans

Indonesian payments, and QRIS specifically — one QR standard that every Indonesian wallet and banking app can scan. For this shop it is the only online method that matters.

Core API, not Snap. Snap is Midtrans’s hosted checkout: redirect the customer, they pay on Midtrans’s page, they come back. It is less work. It also means the payment screen is not mine — I cannot show the order summary next to the QR, style it to match, or keep the customer on a page my own SSE channel is updating.

Core API is server-to-server. I charge, I get a QR URL back, I render it:

const response = await core.charge({
	payment_type: 'qris',
	transaction_details: {
		order_id: midtransOrderId,
		gross_amount: order.totalPrice
	},
	qris: { acquirer: 'gopay' }
})

const qrCode = (response.actions as { name: string; url: string }[] | undefined)?.find(
	(action) => action.name === 'generate-qr-code'
)?.url

Two details that are easy to get wrong.

The Midtrans order_id is not my order number:

const midtransOrderId = `${order.orderNumber}-${Date.now()}`

Midtrans requires it to be globally unique, forever. An order whose QR expires and gets retried would collide with itself on the second attempt, and the charge would simply fail. The timestamp suffix makes each attempt unique while keeping the order number readable in the Midtrans dashboard.

And the webhook signature. The endpoint is public — Midtrans calls it directly, so there is no session to authenticate — which means the payload has to prove its own authenticity:

export function verifyNotificationSignature(payload: MidtransNotification): boolean {
	const serverKey = env.get('MIDTRANS_SERVER_KEY').release()

	const expectedSignature = createHash('sha512')
		.update(`${payload.order_id}${payload.status_code}${payload.gross_amount}${serverKey}`)
		.digest('hex')

	return expectedSignature === payload.signature_key
}

SHA-512 over order_id + status_code + gross_amount + serverKey. Skip this and anyone who learns the URL can mark any order paid by POSTing JSON at it. It is four lines and it is the entire security of the payment flow.

The controller does nothing else:

if (!verifyNotificationSignature(payload)) {
	return response.forbidden({ message: 'Invalid signature' })
}

Where the rate limit lives. Not on the route:

export const midtransChargeLimiter = limiter.use({
	requests: 5,
	duration: '15 minutes',
	blockDuration: '15 minutes'
})

Applied around the charge itself, keyed on order id. Most “pay” requests just return the existing pending transaction and never contact Midtrans at all — metering those would punish a customer for reopening the payment page. Only a genuinely new charge counts.

There is a second, broader limiter on the route keyed to the signed-in user, because the per-order limit cannot see somebody spraying requests across many orders at once: each order stays well inside its own budget while the provider gets hammered all the same.

The cost. Sandbox and production behave subtly differently, the docs are uneven, and testing the webhook locally means a tunnel and patience. Payment integrations are simply not pleasant, and this one was about average.

Fonnte for WhatsApp

WhatsApp is how Indonesian customers expect to be contacted. Email is for receipts from companies you do not know. The system has no email field anywhere — the login identity is a phone number.

The official WhatsApp Business API needs a Meta Business account, a verified business, and an approved template for every message. Fonnte is an HTTP wrapper over a WhatsApp session: POST a target and a message.

const response = await fetch('https://api.fonnte.com/send', {
	method: 'POST',
	headers: {
		Authorization: env.get('FONNTE_API_KEY').release(),
		'Content-Type': 'application/json'
	},
	body: JSON.stringify({ target, message, preview: false })
})

The gotcha, flagged in a comment because it is genuinely surprising:

/**
 * Fonnte answers with HTTP 200 even when it rejects a message, so the
 * response payload's own `status` flag has to be checked as well.
 */
if (!response.ok || !payload.status) {
	throw new Error('Gagal mengirim pesan WhatsApp.')
}

response.ok is not enough. A rejected message comes back 200 with status: false in the body. Trust the status code alone and password reset links silently vanish into nothing.

A product decision inside the messaging layer. Payment reminders are sent by hand from the counter, not on a timer:

/**
 * Sent by hand from the counter rather than on a timer: staff are the ones
 * who can see that a customer simply forgot, as opposed to one who is
 * deciding, and an automatic nag to the second group costs goodwill.
 */

The automated version is easier to build and worse to receive.

The cost. This is an unofficial channel. It depends on a WhatsApp session that can be disconnected, and it is not something I would put a large business’s password resets on. For a shop this size, weighed against the Business API onboarding, it was the right call — with the honest caveat that it is the most fragile external dependency in the system.

ExcelJS, and the Timezone Bug

Every admin screen exports to .xlsx. Not CSV — the owner opens these in Excel, and multi-sheet workbooks with real currency formatting are the difference between a file that gets used and a file that gets downloaded once.

The design rule is that numbers must be numbers:

export const RUPIAH_FORMAT = '"Rp"#,##0'

The cell holds 85000 with a currency format, not the string "Rp 85.000". A formatted string looks identical on screen and cannot be summed, sorted, or charted.

Same for dates — and here is the bug that took the longest to see:

/**
 * ExcelJS writes a `Date` from its UTC components, so handing it the real
 * instant makes a Jakarta midnight land in the file as 17:00 the day before —
 * every date in the export off by one for anyone east of Greenwich.
 */

Jakarta is UTC+7. Midnight local is 17:00 the previous day in UTC. Pass the real instant to ExcelJS and every date in the export is off by one — and it is off by one consistently, which is exactly why nobody spots it. It does not look like corruption. It looks like the data.

The fix is to rebuild the value from the local wall clock as if it were UTC. A spreadsheet cell carries no timezone of its own, so nothing is lost by dropping it at the boundary.

Exports match the screen, in full. Whatever filters, search terms, and date range the admin is looking at go into the file — but never stopping at the page they happen to be on, capped by a limit far above what this shop produces in a year:

export const EXPORT_ROW_LIMIT = 5000

The Frontend Bits

Tailwind 4 with @tailwindcss/vite. The v4 config-in-CSS approach means no tailwind.config.js at all.

Base UI + shadcn-style components. inertia/components/ui/ holds generated primitives built on @base-ui/react — unstyled, accessible behaviour (focus management, keyboard navigation, ARIA) that I own the markup for, rather than a component library whose styling I would be fighting. Seventeen components, and they are copied into the repo rather than imported, so customising one is editing a file instead of overriding a library.

Above those, the components are organised by composition weight — atoms, molecules, organisms, layouts. PinpointMap and CustomerLookup are organisms; StatCard and Pagination are molecules. The value is not the taxonomy, it is that “where does this new component go?” has an answer.

Recharts for the dashboard and revenue report. Composable, React-native, and it does what a small business dashboard needs without a visualisation grammar to learn.

Luxon on the server, date-fns on the client. This looks like an accident and is not. Adonis and Lucid speak Luxon DateTime natively — model timestamps come back as Luxon objects, so anything else means converting at every touch point. The client needs formatting and react-day-picker interop, which is date-fns territory. Two libraries, two environments, no overlap.

Luxon goes one step further via a global VineJS transform, so validated dates arrive as DateTime rather than Date:

data.pickupDate.toFormat('yyyy-MM-dd') // straight out of the validator

Sonner for toasts, Tabler Icons for icons, Embla for the carousel. Each one is small, does one thing, and has not needed a second thought since it went in — which is the entire specification for a dependency at this layer.

Validation: VineJS

Vine is Adonis’s validator and it is fast, but the thing that mattered was making Indonesian error messages a solved problem rather than a per-form chore.

start/validator.ts registers a global messages provider — every rule the app uses, worded once:

export const validationMessages = {
	required: '{{ field }} wajib diisi',
	minLength: '{{ field }} minimal {{ min }} karakter',
	'phone.regex': '{{ field }} harus berupa nomor HP Indonesia yang valid',
	'password.regex': '{{ field }} harus berisi huruf serta angka'
	// ...
}

And the vocabulary itself is shared, so format changes happen in one place:

RuleConstraint
name()1–50 chars, letters/spaces/dashes only
phone()/^08[1-9]\d{8,10}$/ — Indonesian mobile, 10–12 digits
password()8–16 chars, must contain a letter and a digit
image()≤5 MB, png/jpg/jpeg

A form that needs one field relabelled clones the messages object and overrides that key — the catalogue form does exactly this, because the shared name label reads as “Nama lengkap” (full name) and that is nonsense on a price list entry.

Testing: Japa

Three suites, and the split is by what each proves rather than by some ratio.

SuiteProvesCount
unit/Pure logic — validators, distance maths, report shaping8 files
functional/Full HTTP through the real router, DB and middleware25 files
browser/Playwright against the real React UI16 files

The bootstrap wires plugins per suite, so browser tests get an authenticated session without logging in through the form every time:

plugins: [
	assert(),
	pluginAdonisJS(app),
	dbAssertions(app),
	apiClient(),
	authApiClient(app),
	sessionApiClient(app),
	shieldApiClient(),
	inertiaApiClient(app),
	browserClient({ runInSuites: ['browser'] }),
	sessionBrowserClient(app),
	authBrowserClient(app)
]

inertiaApiClient is the one specific to this stack — it lets a functional test assert on the props a controller rendered rather than parsing HTML. That is the right level for an Inertia app: the contract between controller and page is the props object.

Tests are organised by role — tests/functional/admin/, staff/, customer/, guest/ — with a dedicated access.spec.ts per role. Role-based access is the thing most likely to break silently and most expensive to get wrong, so it gets its own file rather than being sprinkled through feature tests.

What I Would Change

The Fonnte dependency. It is the most fragile thing in the system. If the shop grows, the official Business API is the correct answer despite the onboarding.

The tile URL. Google’s internal endpoint works and is not a licence. A proper Maps key or a paid tile provider is the honest version.

Haversine. Straight-line ordering is useful rather than correct in a city with rivers and one-way systems.

Not the framework choices. AdonisJS and Inertia are the two I would make again without hesitating. Between them they removed an entire category of work — API design, client-side state, type duplication across a network boundary — that I would otherwise still be maintaining.

The pattern in everything above is the same. The choices I am happy with are the ones where I picked the smaller, more boring thing and spent the saved effort on the parts that are specific to this business. The choices I regret are the ones where I took the convenient path past something that was going to matter later.


Source at github.com/r3p-dev/skripsi. The decisions behind the product are in Building an Operations Platform for a Shoe Cleaning Shop; the infrastructure it runs on starts at Setting Up a VPS, Part 1.

© 2026 r3p.dev. All rights reserved.