← Back to list

We Built a Super App Without Shipping Any Frontend Code From Our Backends

Most teams building super apps or micro frontend architectures end up in the same trap. They adopt module federation, share bundled…

Ryan Tallmadge · 2026-05-04 18:22 · 0 claps · 6.8 min read
#javascript #micro-frontends #micro-front-end #module-federation
Open on Medium ↗
Wiki topics: GRW · Growth & Analytics 🌐 · Web Development 🏛️ · Architecture

We Built a Super App Without Shipping Any Frontend Code From Our Backends

Most teams building super apps or micro frontend architectures end up in the same trap. They adopt module federation, share bundled JavaScript between services, and suddenly every deploy is a coordination headache. Version mismatches, shared dependency conflicts, and a build pipeline that makes you question your career choices.

We took a different path. We built a super app platform where backend services never ship a single line of UI code to the client. Instead, they send JSON. The client figures out how to render it. And honestly, it changed everything about how we think about frontend architecture.

The Problem We Were Trying to Solve

We needed a platform where multiple teams could independently build and deploy mini apps (think dashboards, wallets, rewards programs, event listings) and have them all show up inside a single shell application. Oh, and that shell needed to run on both web and native mobile.

The obvious answer was module federation. Everybody talks about it. But once you start down that road, you realize you are coupling your remotes to specific framework versions, bundler configurations, and runtime assumptions. You are shipping executable code from one service into another service’s browser context. That is a massive surface area for things to break.

We wanted something simpler. Something where a new team could spin up a service, describe what their screen looks like, and have it just appear in the app without anyone touching the shell codebase.

The Architecture: JSON All the Way Down

Here is the core idea. Every mini app is just an HTTP server. When the shell asks it for a screen, it responds with a JSON document that describes a UI tree. We call this Server Driven UI, or SDUI.

That JSON document has a root node, and that node has children, and those children have children. Each node has a type (like “Card” or “Button” or “DataTable”), some props, optional actions, and optional conditional visibility rules. It is a tree. It looks a lot like a virtual DOM, except it never executes on the server. It is pure data.

The shell application has a component registry that maps each type string to an actual platform component. The web shell maps “Card” to a React card component. The native shell maps “Card” to a React Native card component. Same JSON, two completely different renderers.

Writing Screens in TSX That Compiles to JSON

One of the things we are most proud of is the developer experience for building remotes. Nobody wants to write raw JSON trees by hand. That is miserable. So we built a custom JSX runtime.

Remote developers write what looks like normal React TSX. They import components from a shared schema package and compose them just like they would in any React app. But under the hood, that JSX does not create DOM elements or React fiber nodes. It creates plain JSON objects. The “component” names become type strings. The props become prop objects. The children become child arrays.

So a remote developer writes something like this:


const screen = (
 <Screen title=”My Dashboard”>
 <Card>
 <Text variant=”heading”>Welcome back</Text>
 <Text>Here is what happened today.</Text>
 </Card>
 <DataTable columns={columns} rows={rows} />
 </Screen>
);

And what gets sent over the wire is a JSON tree with type “Card”, type “Text”, type “DataTable”, and so on. No bundling. No webpack. No module federation plugin. Just data.

The Registry: How the Shell Discovers Remotes

We have a lightweight registry service that acts as the catalog. It is a tiny HTTP server that returns a list of remote definitions. Each definition includes an ID, a display name, an icon, a base URL, and a map of screen paths.

When the shell boots up, it fetches the registry. That gives it everything it needs to build navigation, populate sidebars, and know where to fetch screens from. Adding a new mini app to the platform means deploying a new HTTP server and adding one entry to the registry. Nobody rebuilds the shell. Nobody redeploys the web app. The next time the shell refreshes its registry (which it does on a short cache interval), the new app just appears.

This is a huge deal for team autonomy. A team can go from idea to deployed mini app without ever opening a pull request against the shell.

Two Shells, One Contract

We run the same SDUI contract against two completely different rendering targets.

The web shell is a Next.js application. It fetches screen JSON on the server side (so the initial page load is fast and SEO friendly if needed), then hands it off to a client side renderer that walks the tree and maps each type to a web component. The web component registry has around 70 registered types covering everything from basic layout primitives to complex data visualization widgets.

The native shell is an Expo (React Native) application. It has its own component registry with a subset of those types, mapped to native UI elements. Same fetch logic, same auth flow, same action handling. Different pixels.

This means a remote team builds one service, defines one screen, and it renders on both platforms. They do not think about web versus native. They think about what data they want to show and what actions the user can take.

Actions, State, and Interactivity Without Remote JavaScript

A common reaction when people hear “server driven UI” is: okay, but what about interactivity? If the server just sends a static tree, how do users interact with anything?

The answer is that the JSON tree is not just layout. It includes actions and client side state management.

Each screen can come with an initial data bag. Nodes in the tree can reference values from that bag using template bindings (like double curly braces around a path). The renderer resolves those bindings at render time.

Actions are declarative too. A button can specify an action that sets a value in the local state, navigates to another screen, calls an API, or chains multiple actions together. The renderer has built in modifiers for common state operations like toggling a boolean, incrementing a number, appending to a list, and so on.

Conditional visibility is handled with a “when” property on any node. If the condition evaluates to false, the node and its children are not rendered.

All of this happens in the shell. The remote never sends JavaScript. It sends instructions, and the shell interprets them. This keeps the security model clean and the bundle size predictable.

How We Deploy Everything

Each remote is a standalone Docker container running a tiny Hono server. We use a shared Dockerfile template that accepts the remote name and port as build arguments. The build step is just tsup compiling TypeScript to a single JS file, then Node runs it.

The web shell gets its own Dockerfile using Next.js standalone output mode. The registry is another small container.

Docker Compose wires it all together for local development and staging. In production, each container can scale independently. A remote that gets heavy traffic can scale horizontally without affecting anything else.

For local development, we use Turborepo with pnpm workspaces. One command starts everything. The monorepo structure means shared packages (the SDUI schema, the renderer, auth utilities) are developed alongside the apps that consume them, with full type safety across boundaries.

Why This Approach Wins

There are a few things that make this architecture genuinely better than the alternatives we explored.

Independent deployability without runtime coupling. Module federation technically gives you independent deploys, but your remotes still share a JavaScript runtime with the host. Version mismatches in shared dependencies can cause subtle, hard to debug failures. With SDUI, the contract is a JSON schema. There is no shared runtime. A remote can be written in any language that speaks HTTP and JSON.

Platform agnosticism for free. Because remotes produce data instead of UI code, adding a new rendering target is just building a new component registry. We went from web only to web plus native without rewriting a single remote.

Predictable performance. The shell knows exactly what components it has. The bundle size never grows because a remote team added a heavy dependency. The shell renders its own components with its own optimized code.

Security by default. Remotes cannot execute arbitrary code in the user’s browser. They can only express UI and actions within the vocabulary the shell defines. This is a meaningful constraint that eliminates entire categories of vulnerabilities.

Team autonomy at scale. New teams onboard by building an HTTP server that returns JSON conforming to a schema. They do not need to learn the shell’s internal architecture, its build system, or its component library internals. They need to know what types are available and what props those types accept.

What We Would Do Differently

It is not all sunshine. There are tradeoffs.

The component registry is a bottleneck in the sense that if a remote team needs a new component type, someone has to add it to the shell. We have mitigated this by making the registry comprehensive upfront, but it is still a coordination point.

The native renderer lags behind the web renderer in terms of supported types. Keeping them in parity takes discipline.

Debugging can be trickier when you are looking at a JSON tree instead of a component hierarchy in React DevTools. We have built tooling around this, but it is not as mature as what you get with traditional React development.

And the SDUI approach works best for content heavy, form heavy, and data display heavy applications. If you are building something with complex client side interactions like a real time collaborative editor or a 3D viewport, this is probably not the right pattern for those specific screens.

The Takeaway

If you are building a platform where multiple teams need to ship features into a shared application, and you are reaching for module federation, take a step back. Ask yourself whether your remotes really need to ship executable code to the client.

If what they are doing is mostly “show this data, let the user take these actions, navigate here,” then server driven UI might be a dramatically simpler architecture. You get independent deploys, cross platform rendering, a clean security boundary, and a developer experience that, once you have the JSX runtime trick in place, feels almost identical to writing normal React.

We have been running this in production and it has fundamentally changed how fast teams can ship. New mini apps go from concept to deployed in days, not weeks. And nobody has to touch the shell to make it happen.

That is the whole point. Build the platform once. Let teams build on top of it with nothing but an HTTP server and some JSON.


메타데이터
post_id
ff16a010669f
slug
we-built-a-super-app-without-shipping-any-frontend-code-from-our-backends-ff16a010669f
url
https://medium.com/@ryantallmadge/we-built-a-super-app-without-shipping-any-frontend-code-from-our-backends-ff16a010669f
canonical_url
https://medium.com/@ryantallmadge/we-built-a-super-app-without-shipping-any-frontend-code-from-our-backends-ff16a010669f
author_url
https://medium.com/@ryantallmadge
status
ok
fetched_at
2026-06-09 15:37:30