restage

pub package   CI   License: BSD-3-Clause

Restage is a server-driven UI toolkit for Flutter. Build any part of your app with the widgets you already use and ship it over the air. Everything renders as real Flutter widgets in your app, using your theme.

This package is the runtime SDK. It renders what the build produced as real Flutter widgets in your widget tree, through Remote Flutter Widgets. Nothing it loads over the air is executable.

One runtime renders every surface: paywalls, onboarding, in-app messages, surveys, and whole screens.

Why Restage

  • Your widgets, your theme. The build compiles the code you wrote, and Theme.of(context) resolves when the surface renders.
  • Any part of the app. A whole screen, a paywall, an onboarding flow, or one card inside your own Scaffold.
  • It ships only content. An update changes what your app shows. It runs no new code.
  • It fails safe. A surface never reaches a client too old to render it, and a failed fetch renders your bundled copy.

Quick start

Here's a paywall, written as a plain Flutter widget with one annotation:

import 'package:flutter/material.dart';
import 'package:restage/restage.dart';

@Paywall(id: 'pro_upgrade')
class ProUpgradePaywall extends StatelessWidget {
  const ProUpgradePaywall({super.key});

  @override
  Widget build(BuildContext context) => Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('Go Pro', style: Theme.of(context).textTheme.headlineMedium),
          const Text('Choose the plan that fits your needs.'),
        ],
      );
}

Compile it with restage_codegen:

dart run build_runner build

Render it anywhere in your app. The SDK loads the compiled artifact and draws it as real Flutter widgets:

RestagePaywall(id: 'pro_upgrade')

Change the widget, rebuild, and the surface updates. Push it with restage surface push pro_upgrade, then make that revision live with restage surface publish pro_upgrade, and installed apps pick it up over the air. The Quickstart walks through all of it, including the build.yaml for bundled assets.

Surfaces

One host widget per surface kind:

  • Screens: any single surface you write with @Screen, such as a welcome page, a notice, or a settings card. The build generates a typed mount such as WelcomeScreenSurface(...) with the authored constructor arguments and typed events. If delivery or rendering becomes unavailable, including after an event rejection or render failure, it displays the compiled-in authored widget. Use RestageScreen(screen: welcomeScreenRef, ...) directly for a different unavailable policy.
  • Paywalls: a @Paywall surface for upgrade or subscription content. Mount it with RestagePaywall(id: 'pro_upgrade', ...).
  • Flows: a @FlowGraph sequence of screens, such as onboarding, a survey, or a multi-step message. Mount it with RestageFlowGraph(flow: firstRunFlowRef, ...); the build generates the descriptor. See doc/flows.md for the full example, host actions, and data minimization, and doc/flow_navigation_and_customization.md for back navigation and the system-back policy.

Host data

Every surface host accepts an optional context: map. Its values are available to authored widgets under data.context.*.

Context accepts strings, integers, finite doubles, booleans, lists, and maps with string keys through 32 collection levels below the root. A context may contain up to 10,000 retained normalized nodes including the root and may inspect up to 100,000 map entries or list elements per normalization. Null map values are omitted, and null list elements are dropped and compacted. Normalization synchronously deep-copies accepted input. Invalid values, unreadable collections, and exceeded limits throw in debug; release reports diagnostics and omits the offending value or collection.

For example, a screen can render state already owned by the app:

RestageScreen(
  screen: accountSummaryScreenRef,
  unavailable: const SurfaceScreenUnavailablePolicy.hide(),
  context: <String, Object?>{
    'profile': <String, Object?>{
      'displayName': profile.displayName,
      'tasksRemaining': remainingTasks.length,
    },
  },
)

Accepted input is normalized and copied synchronously. Equal normalized snapshots issue no renderer update. Setting context: back to null withdraws the namespace.

Changing one scalar rebuilds the node whose own arguments read that path and the node's subtree; unchanged scalar siblings stay untouched. A list change rebuilds the list host and its rows. Reconciliation is positional: on insert and reorder, persistent row state stays with its position while authored row ids move.

Commerce

Restage.commerce is the inert typed commerce seam. Using the facade with its typed request and result types requires both imports:

import 'package:restage/commerce.dart' as commerce;
import 'package:restage/restage.dart' show Restage;

Commerce is inert in 2.0 and has no configuration hook. Any implementation requires explicit host opt-in; a package update alone never activates purchasing. Authored surfaces cannot initiate purchases or restores; explicit host-controlled code invokes the typed boundary.

Delivery

Apps that bundle their artifacts use the asset resolvers. To fetch surfaces from a server, call Restage.configure(baseUrl: ...) at startup. That installs RestageVariantResolver, which fetches the active published surface and falls back to the bundled asset when the fetch fails. Flows take the same path through ServerFlowResolver (pass it as flowResolver:). Point baseUrl at your own backend, or at the hosted service.

A surface never reaches a client that is too old to render it. If a fetch fails, the SDK renders your bundled copy. For flows, FlowUnavailablePolicy is required, so a flow that can't run falls back or hides instead of running partway.

See doc/live_refresh.md for opt-in in-place updates to surfaces that are already on screen.

Build

Apps that depend on restage must build with --no-tree-shake-icons, because RFW builds IconData from runtime values:

flutter build ios --no-tree-shake-icons
flutter build appbundle --no-tree-shake-icons
flutter build web --wasm --no-tree-shake-icons

Telemetry and data

Restage includes a conversion-analytics layer for surface and experiment results. It's built to be boring and honest:

  • It's off until you connect a backend. Analytics activates only when you pass baseUrl to Restage.configure(...). In local mode (no baseUrl) the SDK renders everything on device and calls no backend.
  • No endpoint is baked in. Events go to your configured baseUrl (<baseUrl>/analytics/events), authenticated with your public key (rs_pk_...). Point it at Restage Cloud and your events power your dashboard and usage-based billing. Point it at your own backend and they go there. There is no hidden Restage host in the SDK.
  • The identity is pseudonymous. Each install gets a random UUID. It isn't derived from any device or advertising identifier, and it resets on uninstall or Restage.reset(). It's a pseudonymous identifier rather than an anonymous one. Treat it as personal data under GDPR and similar laws, as we do. The SDK attaches no user identity of your own. No call binds your account id to an event.
  • What Restage.reset() does, and doesn't do. It rotates the pseudonymous id on the device and rotates the session. That id is also the experiment assignment key, so the install becomes a new randomized unit: assignment is re-drawn on the next surface presentation, and nothing records a link between the old unit and the new one. It's a local call: it sends nothing and tells the server nothing. It does not erase or amend events already sent, and it does not clear the metering token described below. It isn't a deletion request. Treat it as rotating an identifier going forward, not as erasing a history.

What each event contains: a dedup id; the event name and a UTC timestamp; which surface it was, with its id, version, and session; the pseudonymous install id and an app-session id; an app context of platform, locale, SDK version, and optional app version or build; conversion dimensions (variant and experiment) where they apply; and the event's own typed fields, after a scrub that keeps render and host context out of analytics.

What it never collects: advertising identifiers (IDFA/GAID), device fingerprints, location, contacts, or screen content. Beyond the fields listed above, it collects no personal data you don't explicitly attach. Ordinary request metadata such as an IP address is visible to whatever backend you point it at, as with any network call.

Delivery is fail-safe. Events are batched, capped, retried safely, and never throw into your app.

Turning it off: run in local mode (omit baseUrl) for zero telemetry, or pass analyticsEnabled: false to Restage.configure(...) to keep hosted delivery and disable analytics. If you use the hosted service, surface fetches still include the metering token described below.

Disabling measurement at build time: pass measurementEnabled: false to Restage.configure(...). The generator still produces ordinary surface artifacts, but emits no measurement candidates or bindings, and the SDK opens no measurement sessions. The setting defaults to true and is independent of analyticsEnabled, hosted operational controls, and platform admission. None of those controls can enable measurement in a build that disables it.

The metering token

The hosted service is billed by monthly active users, so the SDK needs a way to count them. How it works:

  • If you don't use the hosted service, there is no token. Without a baseUrl the SDK never creates one. Even with a baseUrl, the SDK creates the token only the first time it fetches a surface from the server.
  • It's a random UUID. The device generates it, stores it in shared_preferences, and loses it when the app is uninstalled. It contains nothing about the user or the device, and it isn't connected to the analytics install id.
  • It's sent only with surface fetches. It goes in the body of the request to <baseUrl>/sdk/v1/surface and nowhere else. It never appears in analytics events.
  • It goes only to the server you configured. There is no baked-in Restage endpoint. If your baseUrl is your own backend, the token goes there, and your server is free to ignore the field.
  • analyticsEnabled: false doesn't remove it. That flag turns off analytics. The metering token is how use of the hosted service is counted, so it stays as long as you fetch surfaces from the server. Run without a baseUrl and the SDK sends nothing at all.

If you need to describe it in your own privacy policy: a random per-install identifier, used only to count active users for billing, reset when the app is uninstalled. The implementation is in lib/src/metering/ and it's short.

All of this is BSD-3-Clause and readable: see lib/src/analytics/ and lib/src/metering/.

Linking a signed-in user

By default, measurement knows no user. Events and experiment assignment key off the pseudonymous install id, and nothing ties them to an account.

If your app has signed-in users and you want measurement tied to a user or an account, you link one explicitly:

final result = await Restage.measurement.linkSubject(request);
final reset = await Restage.measurement.resetSubject(resetRequest);
final withdrawal = await Restage.measurement.withdrawConsent(withdrawalRequest);

Linking is deliberately not a one-liner. A request carries proof from your own auth system that the user is signed in, the user's consent and region, and a challenge from the service, so a link can't happen by accident. You get back a receipt or a failure, and nothing you sent is ever echoed back. resetSubject unlinks; withdrawConsent blocks future linking. Until your app installs a verifier for these proofs, every call here fails closed and does nothing.

License

BSD-3-Clause. See LICENSE.

Libraries

a2ui
A2UI-specific custom catalog authoring annotations.
commerce
restage
Restage Flutter SDK.
rfw
RFW-specific custom catalog authoring annotations.
widgetbook
Widgetbook-specific custom catalog authoring annotations.