What is Backflip?
Backflip is a set of libraries that allow you to build server-driven UIs (heavily inspired by this blog post). Server-driven UIs allow your back-end to provide the configuration for how your UI should be rendered, even on native platforms.
Backflip allows you to support this pattern in your front-end code, while still allowing you to use your front-end framework of choice. It also provides fallback behaviour(s), as well as on-device caching, to ensure that your UI is always available to your users, even when they are offline.
Getting started
At a minimum, you need to install the @backflipjs/client
and
@backflipjs/server
packages:
npm install @backflipjs/client @backflipjs/server
Depending on which front-end framework you are using, you may also want to
install the dedicated @backflipjs/<framework>
package as well (see
Frameworks)
Server
In your server code, create a Server
instance...
import { Server } from "@backflipjs/server";
const server = new Server();
//...
...then you can register your components
.
//...
server.component("example", () => ({
component: "Text",
props: {
content: "This is an example text component",
},
}));
//...
The return type for a component()
callback is as follows:
interface RenderedComponentConfig {
component: string;
props?: Record<string, unknown>;
children?: RenderedComponentConfig[];
}
The Server
instance exposes a
WinterCG
compatible fetch()
method that accepts a Request
and returns a Response
.
//...
// For example, in a Cloudflare Worker...
export default {
fetch: server.fetch.bind(server),
};
Client
In your client, you need to create a Registry
instance...
import { Registry } from "@backflipjs/client";
const registry = new Registry();
//...
...you can then register each component you'd like available to be configured via the server.
//...
// `Text` is the component, for example a React component...
registry.registerComponent(Text, { name: "Text" });
//...
You also need to create a Client
instance configured to point to your server.
import { Client } from "@backflipjs/client";
const client = new Client({
url: "/api/components", // Could also be an absolute URL
});
//...
This Client
can be used to send requests for a particular component
configuration from your server.
//...
const config = await client.send("example");
//...
The return value for send()
is the same as the one defined in the
component()
callback on the server, which can then be used to render the
example
component appropriately.
//...
function RenderComponent({
component,
props = {},
children,
}: RenderComponentProps) {
const Component = registry.get(component);
if (!Component) {
return null;
}
return (
<Component {...props}>
{children?.map((child, i) => (
<RenderComponent key={`${component}_${child.component}_${i}`} {...child} />
))}
</Component>
);
}
//...
return <RenderComponent {...config} />;
//...
How it works
Server
The server-side of Backflip provides the configuration to your front-end for how your components should be rendered. You can do anything you are accustomed to running on your back-end (e.g. database calls) to decide which configuration to send down to your client.
You register components with your Server
instance, which then exposes them via
its REST API.
import { Server } from "@backflipjs/server";
interface ServerContext {
locale: string;
}
const server = new Server<ServerContext>();
interface HomeInput {
buttonVariant?: "primary" | "secondary";
}
server.component<HomeInput>("home", ({ ctx, input, resHeaders }) => {
// Read a value from the `input` object
const { buttonVariant } = input;
// Run a convoluted "A/B" test
const ab = Math.random() > 0.5 ? 1 : 0;
// Setting a "Cache-Control" header instructs the Client how to cache the
// response (if a Cache has been configured)
resHeaders.set("Cache-Control", "public, max-age=60");
return {
component: "Container",
children: [
{
component: "Text",
props: {
content: ab
? "You are in the A group"
: "You are in the B group",
},
},
{
component: "Button",
props: {
label: `${buttonVariant} button`,
variant: buttonVariant,
},
},
{
component: "DateTime",
props: {
date: new Date(), // SuperJSON allows us to send some more complex data types
prefix: "It is now",
},
},
{
component: "Text",
props: {
content: `From the server: your locale is ${ctx.locale}`, // Using `ctx` to send data to the client
},
},
],
};
});
export { server };
The Server
exposes a
WinterCG
compatible fetch()
method that you can use to route requests to your Server
.
import { Hono } from "hono";
import { server } from "./server";
const app = new Hono();
app.get("/api/components/:component", (c) => server.fetch(c.req.raw));
export default app;
The only requirements for how you route your Server
are that it is exposed via
a GET
endpoint, and that the URL's last path parameter is the component
name
. Apart from that, you can customise your setup as you see fit.
Input
A Client
can pass some input
to the Server
when requesting a component
,
which can then be used in the component()
callback.
Context
The component()
method provides a ctx
object that you can use within your
callback to un isolated logic per request.
The values in ctx
can come from both the back-end and the front-end,
allowing your client to provide extra information per request.
ctx
is defined on the back-end as either an object
or function
that
returns one:
import { Server } from "@backflipjs/server";
interface ServerContext {
locale: string;
}
const server = new Server<ServerContext>({
// `reqCtx` is the incoming request context object
context: (req, reqCtx) => ({ locale: reqCtx.locale ?? "en-US" }),
});
//...
Serialization
The Server
uses
SuperJSON
to serialise/deserialise data, so your input
, ctx
and props
definitions
can include (as well as valid JSON
values):
undefined
BigInt
Date
RegExp
URL
Set
Map
Error
Registry
The Registry
is (typically) a singleton you provide to your front-end
logic which maps your components to the component names
that your back-end
describes when you request components
.
import { Registry } from "@backflipjs/client";
import { Text } from "./components";
const registry = new Registry();
registry.registerComponent(Text, { name: "Text" });
//...
components
can then be retrieved via their name
.
//...
const TextComponent = registry.get("Text");
//...
If you call get()
with a name
that doesn't have a component registered
to it, it will return null
.
names
are unique values, so you can only register one component
per
name
.
Components
A component
is any atomic UI presentation that can be described via the
following configuration:
interface RenderedComponentConfig {
component: string;
props?: Record<string, unknown>;
children?: RenderedComponentConfig[];
}
How the configuration of a component
translates to how it is rendered is an
implemetation detail that the core Backflip libraries are agnostic to
(although we do have framework specific libraries that do
handle the rendering of your components
as well).
A component
is registered with a unique name
, which is how the Server
refers to it.
Client
The Client
makes requests to your back-end from your front-end code, to
retreive your component
configurations.
import { Cache, Client } from "@backflipjs/client";
const client = new Client({
url: "/api/components", // The URL your `Server` is available at
cache: new Cache(), // *Optional* `Cache` instance to support front-end caching
context: () => ({ // *Optional* `ctx` to send to the server per request
locale: Intl.DateTimeFormat().resolvedOptions().locale,
}),
});
//...
The Client
exposes a send()
method that accepts a name
and returns a
Promise
that resolves to the component
configuration. If a request fails,
an Error
will be thrown.
If provided, the context
will be serialized and sent within the query
parameters of the request.
Cache
By default, every time your front-end renders your components
, the Client
will make a network request to get their configuration(s).
While ensuring your configurations include fresh data is desirable in many
situations, there will also likely be components
whose configuration rarely
change - in these cases, you can utilize a Cache
.
Any response from the Server
that contains a Cache-Control
header will be
respected by the Client
's Cache
(if configured), e.g. a response with a
Cache-Control
header of public, max-age=60
will be cached for 60 seconds
before the Client
will make another request to the Server
for that
component
configuration (with the same input
).
import { Server } from "@backflipjs/server";
const server = new Server();
server.component("home", ({ resHeaders }) => {
// ...
resHeaders.set("Cache-Control", "public, max-age=60"); // Cache the response for 60 seconds
// ...
});
//...
import { Cache, Client } from "@backflipjs/client";
const client = new Client({
//...
cache: new Cache(), // Simple default in-memory cache
//...
});
//...
The default Cache
provided is an in-memory one - if you would like
persistance between sessions then you need to utilize a Cache
with a storage
layer. The @backflipjs/browser
package provides two Cache
implementations
that support persisted storage:
LocalStorageCache
(using thelocalStorage
API)IDBCache
(usingIndexedDB
)
import { Client } from "@backflipjs/client";
import { IDBCache } from "@backflipjs/browser";
const client = new Client({
//...
cache: new IDBCache(),
//...
});
//...
For React Native caching see below.
Frameworks
React
Install the @backflipjs/react
package along with the other dependencies
npm install @backflipjs/client @backflipjs/server @backflipjs/react
In your front-end code, you need to create a Registry
and Client
and pass
them into the Provider
component.
import { Client, Registry, Provider } from "@backflipjs/react";
const registry = new Registry();
const client = new Client({ /* ... */ });
// ...
function App() {
return (
<Provider client={client} registry={registry} devMode>
{/* ... */}
</Provider>
)
}
//...
The devMode
prop optionally turns development mode on when true
, which
will log out warnings when for various misconfigurations that can occurr as you
develop your application with Backflip.
To render your components, you can use the RenderComponent
component:
import { RenderComponent } from "@backflipjs/react";
// ...
return <RenderComponent name="example" />;
//...
As well as the mandatory name
prop, you can also pass the following optional
props:
default
(a default configuration to use if the request to theServer
fails)error
(a React component to render if the component fails to render due to an error - typically from theServer
)fallback
(a React component to render if the component fails to render for any reason)input
(aninput
value to be passed to theServer
when processing the request)loading
(a React component to render while the component is loading a request from theServer
)
import { RenderComponent } from "@backflipjs/react";
// ...
return (
<RenderComponent
name="example"
default={{
component: "Text",
props: {
content: "This is a default text component",
},
}}
error={<p>There was an error rendering this component</p>}
fallback={<p>This component is not available</p>}
input={{ buttonVariant: "primary" }}
loading={<p>Loading...</p>}
/>
);
//...
An example React Remix application can be found in the Github repository.
React Native
React Native can also utilize the @backflipjs/react
dependency. For the most
part, you can follow the above React instructions to set it up.
There is a @backflipjs/native
package that provides a React Native Expo
compatible SecureStoreCache
that uses expo-secure-store
as a cache storager
layer.
import { SecureStoreCache } from "@backflipjs/native";
import { Client } from "@backflipjs/react";
const client = new Client({
//...
cache: new SecureStoreCache(),
//...
});
//...
An example Expo application can be found in the Github repository.
Vue
Coming soon...
Svelte
Coming soon...
SolidJS
Coming soon...