May 22, 2026
Rive is the server-driven UI engine I wanted

A few weeks ago I wrote about how Rive lets designers own the behavioral surface of a UI. That article was about authoring—where the design comes from. This one is about where the component lives, and how it gets its data.
Rive is almost always introduced as a tool for "cute animations"—a mascot that blinks.

That framing isn't wrong, but it's just one interesting thing Rive does. The more I use it on real projects, the more I'm convinced it's something else entirely: like a cross-platform engine for server-driven UI. And it happens to solve that problem better than anything I've built or used before.
The "cute animations" framing undersells it
Strip away the marketing and look at what a .riv file actually is. It's a self-contained binary artifact that bundles a layout, vector graphics, a keyframe timeline, a state machine describing behavior, and—crucially—a typed data contract in the form of view models. It renders through a small runtime that already exists for the web, iOS, Android, macOS, Windows, and more.
That is not an animation. That is a portable, data-driven UI component.
And once you see a .riv that way, server-driven UI stops looking like a hard problem. You don't need to invent a serialization format for your widget tree, or write a renderer to interpret it on every platform. The component is the file. You host the file. You update the file. The app downloads the file. The rendering and the behavior come for free, because they were baked in by the designer.
I've been down this road before
Server-driven UI is not a new obsession of mine. At Flutter Connection 2024 I gave a talk about exactly this—the motivation being dashboards made of cards that each user could rearrange and customize, where the layout genuinely had to come from the backend.
I explored the rfw package first, hit its limitations, and ended up building my own approach: a Dart package called swap that let the server describe UI in real Dart code instead of a pseudo-Dart dialect.
But there's an honest postmortem to write. A server-driven system is only ever as good as the surface it can describe—and swap's surface was Flutter's widget tree. That meant two things. First, I was maintaining a rendering and serialization layer that had to mirror the framework, forever. Second, and more fundamentally, the whole thing was locked to Flutter. A "dynamic component" in that world is a Flutter concept. The moment a web team or a native iOS team needs the same component, the abstraction stops paying for itself.
That second limitation is the one Rive quietly erases.
Why Rive changes the equation
Three properties, taken together, are what make Rive the right tool here.
The .riv is the artifact, not a description of one. With rfw or swap, the server sends a description and the client reconstructs the UI from it. With Rive, the designer produces the finished component directly. There's nothing to reconstruct. You ship the file as-is.
View models are a real, typed data contract. A Rive component doesn't just look a certain way—it declares which strings, numbers, booleans, colors, and enums it expects, right inside the file. It's the same idea as preparing a view model in code, except the contract is part of the design artifact instead of something a developer reverse-engineers afterward.
The runtimes are genuinely cross-platform. This is the part that still feels slightly unfair. The exact same .riv file, with the exact same view model names and instance names, runs through rive-flutter, rive-react / rive-wasm, rive-ios, and rive-android. A dynamic component is no longer a Flutter concept, or a web concept. It's an organization concept. Design once, host once, consume everywhere.
So the rest of this article is concrete: let's remotely load a component and feed it data.
Loading a component from a remote file
In my previous article the .riv was a bundled asset, loaded with FileLoader.fromAsset. For server-driven UI you want the opposite—the file lives on your CDN, so you can update the component without shipping an app release.

The rive Flutter package has a loader for exactly that:
// A .riv served from your own CDN — update it without an app store release.
late final fileLoader = FileLoader.fromUrl(
'https://cdn.myapp.com/components/promo_banner.riv',
riveFactory: Factory.rive,
);
Because the file now travels over the network, the loading and failure states actually matter. RiveWidgetBuilder hands them to you as a sealed type, so a switch covers every case:
@override
Widget build(BuildContext context) {
return RiveWidgetBuilder(
fileLoader: fileLoader,
artboardSelector: ArtboardSelector.byName('PromoBanner'),
onLoaded: (state) {
// ... bind data here, see below
},
builder: (context, state) => switch (state) {
RiveLoading() => const Center(child: CircularProgressIndicator()),
RiveFailed() => const SizedBox.shrink(), // degrade gracefully
RiveLoaded() => RiveWidget(controller: state.controller, fit: .layout),
},
);
}
In production you'll want to wrap the URL loader with your own cache—download the file once, persist it, and feed subsequent loads through FileLoader.fromFile—so you're not re-fetching on every rebuild. But that's plumbing. The interesting part is onLoaded.
Feeding it data: local or distant
A remote component is only half the story. The other half is making it say the right thing. This is where view models earn their place.
Picture a PromoBanner artboard backed by a view model called Banner.

The designer declared two kinds of properties on it:
- Localized content—
title,description—text that changes per language. - Dynamic data—
amount—value that only your app knows at runtime.
The trick for localization is delightful: a view model can have several named instances, each one a preset of values authored directly in the Rive editor. So the designer creates one instance per locale—en, fr, de, ja—and fills the localized properties in each. The translations ship inside the .riv file.

At runtime, adapting the component to the current language is just picking the right instance by name:
onLoaded: (state) {
final vm = state.file.viewModelByName('Banner')!;
// The designer authored one named instance per locale, inside the .riv.
final locale = Localizations.localeOf(context).languageCode;
final instance = vm.createInstanceByName(locale)
?? vm.createInstanceByName('en')!; // safe fallback
state.controller.dataBind(DataBind.byInstance(instance));
}
That's the whole localization layer. No .arb files for this component, no string lookups, no risk of the layout breaking because German is 40% longer than English—the designer already saw every language in context and made it fit.
Now layer the dynamic data on top. The instance we picked already carries the localized labels; we just write the runtime values into the remaining properties before binding:
onLoaded: (state) {
final vm = state.file.viewModelByName('Banner')!;
final locale = Localizations.localeOf(context).languageCode;
final instance = vm.createInstanceByName(locale)
?? vm.createInstanceByName('en')!;
// Localized labels already live in the instance.
// Now the dynamic data — from a local store or a backend, it doesn't matter.
instance.number('amount')!.value = offer.discountPercent;
state.controller.dataBind(DataBind.byInstance(instance));
}

Whether offer came from a local cache or a GET /offers call is irrelevant to Rive—the view model is the only contract, and it doesn't care where the values originate. When the user switches language, you simply create the instance for the new locale, re-apply the dynamic values, and re-bind. If you want a cleaner separation, the designer can split the model into nested view models—a Localized child with per-locale instances, and a Live child for runtime data—so locale switches never touch the dynamic part at all.
The same component, every platform
Here's the payoff, and the reason I keep coming back to that word cross-platform.
Everything above—the Banner view model, the en / fr / de / ja instances, the amount property—lives inside the .riv file, not inside the Flutter code. The Flutter snippets are just one consumer. A React web app calls viewModelByName and createInstanceByName with the same strings. So does the native iOS app. So does Android.
That means a designer can ship a new promo banner—new layout, new animation, fixed Japanese line break, an added locale—by uploading one file to a CDN. Every platform picks it up on next load. No coordinated release, no per-platform reimplementation, no widget-tree serialization format to keep in sync with three runtimes. The thing I spent a conference talk working around, Rive treats as a non-problem.
Conclusion
Server-driven UI usually fails on the same rock: you end up maintaining a bespoke rendering layer, and it's welded to one framework. Rive removes both halves of that. The renderer already exists, on every platform you care about. And the unit you ship—a .riv with a typed view model and named instances—is a real, finished artifact, not a description some client has to faithfully rebuild.
It comes back to the single source of truth idea from my last article. The component, its behavior, its animations, and now its translations all live in one file. Updating the UI becomes updating that file—not a release, not a ticket, not a three-platform reimplementation.
Rive can absolutely make a cute mascot blink. It can also be the quietest, most capable server-driven UI engine I've come across. I'd love for more people to notice the second part.