Server-driven widgets
Server-driven widgets allow your Android Home Screen widgets to periodically fetch fresh content from a remote server—without the user opening the app. This is powered by WorkManager, which handles scheduling, retries, and network constraints automatically.
Before you start, make sure the widget is registered in the Voltra plugin config and plan to rebuild the native app after adding or changing server-driven widget settings.
Android semantic color tokens from AndroidDynamicColors work in server-rendered widgets too, so your backend can return dynamic Material roles instead of fixed hex values.
How it works
Section titled “How it works”- You configure a
serverUpdateURL in your Android widget’s plugin config - WorkManager runs a periodic background task at the configured interval
- Your server renders Voltra JSX components into a JSON payload
- The worker parses the payload and pushes a
RemoteViewsupdate to the widget
Your app doesn’t need to be running. WorkManager handles everything in the background.
A widget that also has an entry works the other way round: your server returns plain JSON data and the widget renders it on the device, so the backend can be written in any language. See Returning data instead of UI.
Plugin configuration
Section titled “Plugin configuration”Add the serverUpdate option to your Android widget in app.json or app.config.js:
{ "expo": { "plugins": [ [ "@use-voltra/android-client", { "widgets": [ { "id": "dynamic_weather", "displayName": "Dynamic Weather", "description": "Weather with live server updates", "targetCellWidth": 2, "targetCellHeight": 1, "serverUpdate": { "url": "https://api.example.com/widgets/render", "intervalMinutes": 60 } } ] } ] ] }}serverUpdate options:
url: The endpoint the widget fetches from. Voltra appendswidgetId,platform,theme, andlocalequery parameters automatically (e.g.?widgetId=dynamic_weather&platform=android&theme=dark&locale=en-US). Optional — leave it out to mark the widget server-driven and supply the URL after login withsetWidgetServerUpdate.intervalMinutes: How often the widget fetches updates. Defaults to60, or15for a widget that has anentry. The minimum is 15 minutes.refresh: Whether to show a native refresh button in the top-right corner of the widget. When tapped, triggers an immediate server fetch. Defaults tofalse.
After updating plugin configuration, run npx expo prebuild if you’re using Continuous Native Generation, then rebuild the app so the generated native widget code picks up the new server update settings.
Building the server
Section titled “Building the server”Voltra provides widget server handlers for the common runtime styles. Use createAndroidWidgetUpdateHandler() for Fetch-compatible runtimes, createAndroidWidgetUpdateNodeHandler() for node:http, and createAndroidWidgetUpdateExpressHandler() for Express-style handlers. All three share the same request parsing, platform validation, token validation, and response serialization.
import { createServer } from 'node:http'import React from 'react'import { createAndroidWidgetUpdateNodeHandler } from '@use-voltra/android-server'import { AndroidDynamicColors, VoltraAndroid } from '@use-voltra/android'
const handler = createAndroidWidgetUpdateNodeHandler({ render: async (req) => { // req.widgetId — the widget requesting an update // req.platform — always "android" for Android widget requests // req.theme — the system color scheme ("light" or "dark") // req.token — the auth token (if credentials were set)
const weather = await fetchWeatherData()
const content = ( <VoltraAndroid.Box style={{ width: '100%', height: '100%', backgroundColor: AndroidDynamicColors.surface, padding: 16, }} > <VoltraAndroid.Column style={{ width: '100%', height: '100%' }}> <VoltraAndroid.Text style={{ fontSize: 32, color: AndroidDynamicColors.onSurface }}> {weather.temp}° </VoltraAndroid.Text> <VoltraAndroid.Text style={{ fontSize: 14, color: AndroidDynamicColors.onSurfaceVariant }}> {weather.condition} </VoltraAndroid.Text> </VoltraAndroid.Column> </VoltraAndroid.Box> )
// Return size breakpoints for different widget sizes return [ { size: { width: 200, height: 100 }, content }, { size: { width: 200, height: 200 }, content }, { size: { width: 300, height: 200 }, content }, ] },
validateToken: async (token) => { return token === 'valid-token' },})
createServer(handler).listen(3333)The handler responds to GET requests with these query parameters:
| Parameter | Description |
|---|---|
widgetId |
The widget identifier (required) |
platform |
The requesting platform. Must be android (required). |
family |
Not used on Android |
theme |
The system color scheme (light or dark) |
locale |
The device locale as a BCP-47 tag, e.g. en-US |
instance |
A stable hash of the placement’s merged configuration. Present only when the widget has configuration parameters (see Per-instance requests). |
configuration |
The placement’s merged configuration as canonical JSON. Present only when the widget has configuration parameters. |
The User-Agent header is set to VoltraWidget/<version> (Android/<version>).
Per-instance requests
Section titled “Per-instance requests”Each placed widget can be configured independently (see Configure each placed widget separately). When a widget has configuration parameters, every request carries the placement’s merged configuration — defaults, overlaid with widget-type values, overlaid with the placement’s own values — so a server can answer each placement differently:
GET https://api.example.com/widgets/weather ?widgetId=weather&platform=android&theme=dark&locale=en-US &instance=3f9a2c1e &configuration=%7B%22city%22%3A%22London%22%2C%22units%22%3A%22metric%22%7Dconfigurationis the merged configuration map, serialized canonically: keys sorted by code point, no whitespace, values as JSON strings. Decoded, it is exactly what the widget sees asenv.configuration.instanceis a stable, non-cryptographic hash of that canonical string. Two placements with identical configuration send the sameinstanceand share one fetch, one cached response, and one props slot — placing the same widget twice with the same values does not double your request volume.- Both parameters are absent when the widget has no configuration parameters at all, so an existing server-driven widget’s requests are unchanged by this feature.
instanceandconfigurationare reserved query keys: aqueryyou pass tosetWidgetServerUpdatenaming either is rejected.
Configurations are meant for identifiers and short choices, not large payloads — percent-encoding roughly triples the size of braces and quotes in the JSON.
Fetch count. A server-driven widget fetches once per distinct configuration among its current placements, not once per placement. If ten placements of one widget each use a different value for a free-text parameter, that is ten fetches per interval (each still floored at 15 minutes) rather than one. A server can still stretch the interval per instance with Cache-Control: max-age, exactly as it can today.
A widget with no configuration parameters keeps behaving exactly as it did before: one request, one cached response, shared by every placement.
Returning data instead of UI
Section titled “Returning data instead of UI”Everything above assumes your server renders Voltra components and returns a UI payload, which means it has to run Node. If you give the widget an entry, it renders on the device instead and your server returns plain JSON — so it can be written in any language.
Add both keys to the same widget:
{ "id": "portfolio", "displayName": "Portfolio", "description": "Your holdings", "targetCellWidth": 2, "targetCellHeight": 2, "entry": "./widgets/android/portfolio.tsx", "initialStatePath": "./widgets/android/portfolio.tsx", "serverUpdate": { "url": "https://api.example.com/widgets/portfolio", "intervalMinutes": 30 }}The response body becomes the widget’s props, verbatim:
<?phpheader('Content-Type: application/json');
echo json_encode([ 'total' => 12480.55, 'change' => 1.8, 'holdings' => [ ['symbol' => 'AAPL', 'value' => 8200.00], ['symbol' => 'MSFT', 'value' => 4280.55], ],]);Your widget entry receives that object as its first argument, the same shape you would pass to updateAndroidDynamicWidget:
export default function PortfolioWidget(props, env) { return ( <VoltraAndroid.Column style={{ padding: 16 }}> <VoltraAndroid.Text style={{ fontSize: 28 }}>${props.total.toFixed(2)}</VoltraAndroid.Text> <VoltraAndroid.Text style={{ fontSize: 14 }}>{props.change}% today</VoltraAndroid.Text> </VoltraAndroid.Column> )}Check the endpoint with curl before wiring up the widget:
curl "https://api.example.com/widgets/portfolio?widgetId=portfolio&platform=android&theme=dark&locale=en-US" \ -H "Accept: application/json"What the response has to be
Section titled “What the response has to be”- Status
200withContent-Type: application/jsonand a JSON object. An array, a string, a number ornullat the top level is rejected. - At most 256 KB.
- Return
ETagand Voltra sends it back asIf-None-Matchon the next fetch. A304means the widget keeps what it has and counts as fresh. This applies to widgets with anentry; a payload widget’s request is unconditional. Cache-Control: max-age=Nmoves the next fetch, clamped between 15 minutes and 24 hours.Retry-Afteron a429or503is honoured the same way.
Returning a rendered Voltra payload from an endpoint whose widget has an entry is rejected with a log line naming the mismatch. The widget has an entry, so it wants the data, not the picture.
Telling fresh data from stale
Section titled “Telling fresh data from stale”The widget is never blanked by a failed fetch: it keeps rendering the last props that arrived, whether from the server or from updateAndroidDynamicWidget. env.serverUpdate says which:
export default function PortfolioWidget(props, env) { const stale = env.serverUpdate?.status === 'stale'
return ( <VoltraAndroid.Column style={{ padding: 16, alpha: stale ? 0.6 : 1 }}> <VoltraAndroid.Text style={{ fontSize: 28 }}>${props.total?.toFixed(2) ?? '—'}</VoltraAndroid.Text> {env.serverUpdate?.fetchedAt && ( <VoltraAndroid.Text style={{ fontSize: 12 }}> Updated {new Date(env.serverUpdate.fetchedAt).toLocaleTimeString()} </VoltraAndroid.Text> )} </VoltraAndroid.Column> )}| Field | Meaning |
|---|---|
status |
fresh after a 200 or 304; stale when a fetch has succeeded before but the last one failed; never before the first success; disabled while your app has taken the widget over |
fetchedAt |
Epoch milliseconds of the last 200 or 304. Absent until a fetch succeeds. |
error |
network, http, unauthorized, parse, or render. Absent when status is fresh. |
httpStatus |
Status code of the last response, when there was one |
env.serverUpdate is undefined on widgets without a serverUpdate.
Props that throw during rendering are never committed — Voltra renders them once off screen first, and keeps the previous props when that fails. env.serverUpdate.error is render while that lasts.
Authentication
Section titled “Authentication”Widgets on Android are part of the main app binary, so the WorkManager background worker can access credential storage directly. Voltra credentials are encrypted at rest on-device.
Setting credentials
Section titled “Setting credentials”Call setWidgetServerCredentials after the user logs in:
import { setWidgetServerCredentials } from '@use-voltra/android-client'
await setWidgetServerCredentials({ token: userAccessToken, headers: { 'X-App-Version': '1.0.0', },})The token is required and is sent as Authorization: Bearer <token> on every server request. Any additional headers are also included. If your widget endpoint does not require authentication, skip setWidgetServerCredentials() entirely.
Clearing credentials
Section titled “Clearing credentials”Call clearWidgetServerCredentials when the user logs out:
import { clearWidgetServerCredentials } from '@use-voltra/android-client'
await clearWidgetServerCredentials()All widgets are automatically reloaded after credentials are cleared, so they revert to their default/unauthenticated state immediately.
Changing settings at runtime
Section titled “Changing settings at runtime”serverUpdate in app.json is the default. Once the app runs it can change any of it, per widget or for all of them, without a rebuild:
import { setWidgetServerUpdate } from '@use-voltra/android-client'
await setWidgetServerUpdate( { url: `https://${tenant}.example.com/widgets/portfolio`, intervalMinutes: 30, headers: { Authorization: `Bearer ${accessToken}` }, }, { widgetId: 'portfolio' })| Setting | |
|---|---|
url |
Must be https, or http to a local dev host (localhost, 127.0.0.1, ::1, 10.0.2.2, 10.0.3.2) in a debug build |
intervalMinutes |
Clamped between 15 minutes and 24 hours |
enabled |
false stops fetching until you set it back |
method |
GET (default), POST, PUT, PATCH or DELETE |
query |
Extra query parameters |
headers |
Extra request headers |
body |
Sent as application/json |
Leave out widgetId to set the same values for every server-driven widget. A widget-scoped call wins over a global one; headers and query merge per key across the two, everything else takes the more specific value.
Each call replaces everything it set last time, so pass every field you want to keep. Setting anything reschedules the widgets it affects and fetches once straight away.
Use this instead of setWidgetServerCredentials, which does the same thing for the Authorization header alone:
// Beforeawait setWidgetServerCredentials({ token: accessToken, headers: { 'X-App-Version': '1.0.0' } })
// Afterawait setWidgetServerUpdate({ headers: { Authorization: `Bearer ${accessToken}`, 'X-App-Version': '1.0.0' },})To go back to what app.json configured, clear the settings:
import { clearWidgetServerUpdate } from '@use-voltra/android-client'
await clearWidgetServerUpdate({ widgetId: 'portfolio' })await clearWidgetServerUpdate()Clearing the global settings is the logout gesture. Along with the settings it drops what the server last sent — the props and the “updated at” of every server-driven widget — so a Dynamic Widget goes back to rendering {} with env.serverUpdate.status of never rather than showing the previous account’s data. A widget-scoped clear only drops that widget’s overrides and leaves its props alone.
Read the settings back with getWidgetServerUpdate:
import { getWidgetServerUpdate } from '@use-voltra/android-client'
const snapshot = await getWidgetServerUpdate({ widgetId: 'portfolio' })if (snapshot?.enabled) { console.log(`portfolio fetches ${snapshot.url} every ${snapshot.intervalMinutes}m`)}With a widgetId, this is the fully resolved settings that widget would fetch with right now — every layer flattened and app.json’s defaults applied, or null if the widget isn’t server-driven. Without one, it’s the raw global layer only: what the last widget-less setWidgetServerUpdate call wrote, with no defaults applied, or null if nothing has been set globally.
Credentials set with the deprecated setWidgetServerCredentials are stored separately and are not affected; clear those with clearWidgetServerCredentials.
Calling either function for a widget that has no serverUpdate in app.json throws. Whether a widget is server-driven is decided when the native project is generated, so a runtime URL cannot turn a local widget into one — add serverUpdate to app.json and rebuild.
A body set alongside GET or HEAD is dropped with a warning, because neither platform’s HTTP stack can send one.
Driving a widget from the app
Section titled “Driving a widget from the app”updateAndroidWidget and updateAndroidDynamicWidget work on server-driven widgets, but the next scheduled fetch overwrites what you wrote. To keep it, turn fetching off first:
await setWidgetServerUpdate({ enabled: false }, { widgetId: 'portfolio' })await updateAndroidDynamicWidget('portfolio', localProps)While fetching is off, env.serverUpdate.status is disabled, so the widget can hide its “updated N minutes ago” line. Set enabled: true, or clear the settings, to hand it back to the server.
Refresh button
Section titled “Refresh button”Server-driven widgets can display a native refresh button that lets users trigger an immediate update on demand. Enable it in your widget config:
{ "serverUpdate": { "url": "https://api.example.com/widgets/render", "intervalMinutes": 60, "refresh": true }}When enabled, a small circular button (↻) appears in the top-right corner of the widget.
On a payload widget, tapping it performs an inline HTTP fetch and pushes the update directly—without waiting for the next scheduled fetch. On a widget with an entry, a tap with no signal is queued and retries until it succeeds, rather than failing silently.
Resize handling
Section titled “Resize handling”Your server should return all size variants in every response. When the user resizes a widget on the home screen, Voltra re-renders from cached data—no network request is made. The RemoteViews(sizeMapping) mechanism automatically picks the closest matching variant.
Triggering manual refreshes
Section titled “Triggering manual refreshes”You can force-refresh server-driven widgets outside of the regular interval:
import { reloadAndroidWidgets } from '@use-voltra/android-client'
// Reload specific widgets (triggers an immediate WorkManager fetch)await reloadAndroidWidgets(['dynamic_weather'])
// Reload all widgetsawait reloadAndroidWidgets()For server-driven widgets, this enqueues an immediate one-time WorkManager request to fetch fresh content. For local-only widgets, it re-renders from cached data.
Initial state
Section titled “Initial state”Server-driven widgets still need content to display before the first server fetch completes. Use initialStatePath to provide a pre-rendered default:
{ "id": "dynamic_weather", "displayName": "Dynamic Weather", "description": "Weather with live server updates", "targetCellWidth": 2, "targetCellHeight": 1, "initialStatePath": "./widgets/android/weather-initial.tsx", "serverUpdate": { "url": "https://api.example.com/widgets/render", "intervalMinutes": 60 }}See Widget pre-rendering for details on creating initial state files.
Cross-platform server
Section titled “Cross-platform server”A single server can handle both iOS and Android requests using createWidgetUpdateHandler from @use-voltra/server:
import { Voltra } from '@use-voltra/ios'import { AndroidDynamicColors, VoltraAndroid } from '@use-voltra/android'import { createWidgetUpdateHandler } from '@use-voltra/server'
const handler = createWidgetUpdateHandler({ renderIos: async (req) => { // Return WidgetVariants (systemSmall, systemMedium, etc.) return { systemSmall: <Voltra.Text>Hello</Voltra.Text> } }, renderAndroid: async (req) => { // Return AndroidWidgetVariants (size breakpoints) return [{ size: { width: 200, height: 100 }, content: <VoltraAndroid.Text>Hello</VoltraAndroid.Text> }] }, validateToken: async (token) => { // Shared token validation for both platforms return verifyJwt(token) },})The handler uses the required platform query parameter to route requests to the correct render function.
If you’re serving the cross-platform endpoint from Node or Express, use createWidgetUpdateNodeHandler() or createWidgetUpdateExpressHandler() from @use-voltra/server instead.
Error handling and retries
Section titled “Error handling and retries”WorkManager automatically handles failures with exponential backoff. After 5 consecutive failed attempts, the worker gives up to avoid infinite retry loops. The next periodic run will start fresh.
- Network unavailable: The request is deferred until connectivity is restored (via
NetworkType.CONNECTEDconstraint). - Server errors (non-2xx): The worker retries with exponential backoff, up to 3 attempts.
- Empty response: The worker retries with exponential backoff, up to 3 attempts.
- Parse errors: If the JSON is stored but parsing fails, the data is still saved so Glance can attempt to use it later. This counts as a success since the data is persisted.
401or403: The worker stops rather than backing off — the status will not change until you set a new token, and doing so reloads the widget anyway.
For widgets that return props rather than UI, the widget keeps rendering the last props it has through every one of these, and env.serverUpdate says what went wrong.