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):
undefinedBigIntDateRegExpURLSetMapError
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 thelocalStorageAPI)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 theServerfails)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(aninputvalue to be passed to theServerwhen 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...