The UmimaClean Stack, Part 4: Inertia.js, or Deleting the API Layer

What it means to build a React app with no API - how Inertia visits work, shared props via middleware, flash messages and validation errors that arrive automatically, why SSR is configured but off, and the mobile app I gave up to get here.

A 13-part series on the UmimaClean stack. 1 — AdonisJS · 2 — VineJS · 3 — Lucid · 4 — Inertia · 5 — Transformers · 6 — Transmit · 7 — Leaflet · 8 — Midtrans · 9 — Fonnte · 10 — ExcelJS · 11 — Frontend · 12 — Security · 13 — Testing

The default architecture for a modern web app is a React SPA talking to a JSON API. It is so default that choosing otherwise reads as a mistake.

I chose otherwise, and it is the decision from this project I would defend hardest.

What the Default Actually Costs

Spell out what “React SPA plus REST API” means in files:

Backend                          Frontend
─────────────────────            ─────────────────────
route definitions          ←→    route definitions
controller                 ←→    fetch call
response DTO               ←→    response type
validation rules           ←→    form validation (again)
                                 data-fetching library
                                 cache invalidation
                                 loading states
                                 error states
                                 auth token handling

Every row on the left has a partner on the right that has to agree with it, and nothing enforces the agreement except discipline and integration tests. Rename a field in a response and the frontend keeps compiling — it just renders undefined.

That cost is worth paying when the API has multiple consumers: a web app, a mobile app, a partner integration. Then the API is a genuine product with a genuine boundary.

UmimaClean has exactly one consumer: its own frontend. An API there is a boundary maintained for the benefit of nobody.

How Inertia Works

Inertia is often described as “a protocol”, which is accurate and unhelpful. Concretely:

On the first request, the server returns a normal HTML document with a root element carrying a JSON data-page attribute — the page component’s name and its props. The client boots React and renders it.

On every subsequent navigation, the client intercepts the click, sends the same request with an X-Inertia header, and the server responds with just JSON: the next page’s name and props. The client swaps the component and updates the URL.

First visit
  GET /orders/ORD260728-001
  → 200 text/html   <div id="app" data-page='{"component":"customer/order/show","props":{...}}'>

Subsequent visit
  GET /orders/ORD260728-002   X-Inertia: true
  → 200 application/json      {"component":"customer/order/show","props":{...}}

So: server-side routing, client-side rendering. You get SPA navigation — no full page reload, preserved scroll where you want it — with the routing, authorisation, and data loading all staying on the server where they already were.

The important consequence is what isn’t there. No client-side router. No fetch in a component. No cache to invalidate. No loading skeleton for the initial data, because the data arrives with the page.

A Controller Renders a Page

async create({ inertia, auth }: HttpContext) {
  const user = auth.getUserOrFail()

  const address = await this.addressService.getActiveAddress(user)
  const services = await this.orderService.getAvailableServices()

  return inertia.render('customer/order/create', {
    address: AddressTransformer.transform(address),
    services: ServiceTransformer.transform(services),
  })
}

'customer/order/create' resolves to inertia/pages/customer/order/create.tsx. The second argument is the props that component receives.

And the page is a plain React component:

type PageProps = InertiaProps<{
	address: Data.Address | null
}>

export default function Create({ address }: PageProps) {
	// address is already here. No fetch, no useEffect, no loading state.
}

That is the whole model. Compare it to the same screen in the default architecture — a route definition, a loader or query hook, a fetch, a response type, a loading branch, an error branch — and the difference is not a small refactor. It is most of a file.

Redirects work the way they do in a server-rendered app:

async store({ auth, request, response, session }: HttpContext) {
  const user = auth.getUserOrFail()
  const payload = await request.validateUsing(orderValidator)

  const order = await this.orderService.createOnlineOrder(user, payload)

  session.flash('success', 'Pesanan berhasil dibuat!')
  return response.redirect().toRoute('customer.order.show', { number: order.orderNumber })
}

Flash a message, redirect to a named route. The client follows it as an Inertia visit, so the user sees an SPA transition — but nothing in the controller had to know that. This is a normal POST-redirect-GET, which means the browser back button, refresh, and bookmarking all behave correctly for free.

The Client Setup

The entire frontend bootstrap:

import '@/css/app.css'
import { client } from './client'
import Layout from '@/components/layouts/default'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
import { TuyauProvider } from '@adonisjs/inertia/react'
import { resolvePageComponent } from '@adonisjs/inertia/helpers'

const appName = import.meta.env.VITE_APP_NAME || 'UmimaClean'

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' }
})

Thirty lines, and three of them are load-bearing.

`import.meta.glob(‘./pages//*.tsx’)`** is Vite’s glob import. It hands Inertia a map of every page module, lazily loaded, so page components are code-split automatically. A visitor to the booking page never downloads the admin dashboard.

The third argument to resolvePageComponent wraps every page in a default layout. Persistent layout, applied once — no <Layout> in 40 page files, and no layout remount between visits.

progress is the top-of-page loading bar. One line, and it is the thing that makes a server-routed app feel like an SPA during a slow request.

Shared Props: The Feature That Pays for Itself

Some data belongs on every page. The signed-in user. Validation errors. Flash messages. In an API architecture each of those is a decision: fetch the user once and cache it, thread errors through form state, invent a toast system for flashes.

Inertia middleware shares them into every render:

export default class InertiaMiddleware extends BaseInertiaMiddleware {
	share(ctx: HttpContext) {
		const { session, auth } = ctx as Partial<HttpContext>

		const error = session?.flashMessages.get('error') as string
		const success = session?.flashMessages.get('success') as string

		return {
			errors: ctx.inertia.always(this.getValidationErrors(ctx)),
			flash: ctx.inertia.always({ error, success }),
			user: ctx.inertia.always(auth?.user ? UserTransformer.transform(auth.user) : undefined)
		}
	}
}

Three things arrive on every page without any page asking.

errors — validation failures. A controller throws, Adonis flashes the errors and redirects back, and the form renders them. No error state in the component, no mapping of server errors onto fields.

flash — one success and one error message, consumed once. session.flash('success', ...) in a controller becomes a toast on the next page.

user — the signed-in user, transformed. Layouts read it to draw the nav; no page fetches it.

ctx.inertia.always() is the modifier that matters: normally Inertia sends only the props a partial reload asked for, but always props are included in every response regardless. Without it, a partial reload could arrive with no flash message and swallow a “saved successfully”.

The defensive cast at the top is not paranoia:

/**
 * The share method is called everytime an Inertia page is rendered. In
 * certain cases, a page may get rendered before the session middleware
 * or the auth middleware are executed. For example: During a 404 request.
 */
const { session, auth } = ctx as Partial<HttpContext>

A 404 renders an Inertia page having skipped the router middleware stack entirely — no session, no auth. Assume they exist and your error page throws while rendering, which turns a 404 into a 500. Worth knowing before you meet it.

The whole thing is typed back into the framework at the bottom of the file:

declare module '@adonisjs/inertia/types' {
	type MiddlewareSharedProps = InferSharedProps<InertiaMiddleware>
	export interface SharedProps extends MiddlewareSharedProps {}
}

The shared props’ types are inferred from the middleware that produces them. Add a field to share() and every page can read it, typed, with no declaration to update.

Where Authorisation Lives

An SPA has to answer “can this user see this?” twice: once on the server so the data is protected, once on the client so the UI does not offer something that will fail. The second copy inevitably drifts.

Here it is answered once, in route middleware:

router
	.group(() => {
		/* admin routes */
	})
	.prefix('admin')
	.as('admin')
	.use([middleware.auth(), middleware.role(Role.ADMIN)])

A customer hitting an admin URL is redirected before any controller runs. There is no client-side route guard because there is no client-side router.

And the props themselves are the authorisation boundary. The staff task board renders a transformer variant that carries the order number and a status badge — and no customer name, phone, or address, because a queue of unclaimed work is visible to every staff member on shift. Those fields appear only in the variant used after a task is claimed. Part 5 covers that properly; the point here is that with no API, “what does the client receive” is decided per screen by the code that renders it, rather than by a general-purpose endpoint that returns a whole model and trusts the frontend to hide fields.

SSR Is Configured and Off

const inertiaConfig = defineConfig({
	ssr: {
		enabled: false,
		entrypoint: 'inertia/ssr.tsx'
	}
})

Inertia supports server-side rendering: run React on the server, send real HTML, hydrate on the client. Better first paint, and crawlable.

It is off, and the reasoning is specific.

The staff and admin areas are behind a login — nothing to crawl. The customer area is a booking flow, also behind a login. The one public page is a marketing home page, and Adonis renders that through an Edge template rather than through Inertia.

SSR would mean running a second Node process for the render server, and a class of bugs where the same component behaves differently on server and client — window access, localStorage, hydration mismatches. All of that to improve a first paint that nobody is waiting on.

The entrypoint stays configured, which is the whole point: turning it on is a flag change, not a project.

The Cost

Being straight about what this gives up.

No mobile app can consume this. There is no API to point one at. If UmimaClean wants a native app, that is a real project — building the layer I skipped.

I took that bet deliberately. Building an API on day one is a certain cost paid every day, against a possible cost paid on a day that may never come. And if that day does come, the work is known: the services already contain every business rule and already return models. An API layer over them is controllers and serialisers, not a rewrite.

Every response carries all its props. Inertia has partial reloads (only: [...]) and deferred props for this, and I use neither — the pages here are small enough that the whole payload is cheaper than the complexity. That would not hold for a screen with a heavy table plus a heavy chart.

Fewer people know it. React and REST is the common vocabulary. Someone joining this codebase has a day of “wait, where’s the API?” before it clicks.

You inherit the server’s page granularity. Something that would be a small client-side state change in an SPA is often a server round trip here. Fine on a fast connection to a nearby server; noticeable on a bad mobile connection, which is exactly what staff have in a van.

What It Actually Bought

The honest measure is what does not exist in this codebase.

No API routes. No response DTOs. No fetch in any component. No data-fetching library. No cache invalidation. No loading skeletons for initial data. No auth token refresh. No CORS configuration for a first-party frontend. No duplicated validation. No client-side router. No route guards.

That is not a small list, and none of it is complexity I outsourced — it is complexity that the architecture removed the need for. Everything left is either a business rule or a screen.

For an application with one frontend, that trade is not close.


Next: Part 5 — Transformers, Typed Routes, and Forms, where the props boundary becomes a permission boundary and forms stop needing state.

© 2026 r3p.dev. All rights reserved.