diff --git a/examples/framesjs-starter/app/examples/page.tsx b/examples/framesjs-starter/app/examples/page.tsx
index 63bb4ab9f..0201ab7f7 100644
--- a/examples/framesjs-starter/app/examples/page.tsx
+++ b/examples/framesjs-starter/app/examples/page.tsx
@@ -34,6 +34,11 @@ export default function ExamplesIndexPage() {
Multi protocol
+
+
+ Simplified api
+
+
Slow request
diff --git a/examples/framesjs-starter/app/examples/simplified-api/[[...routes]]/route.tsx b/examples/framesjs-starter/app/examples/simplified-api/[[...routes]]/route.tsx
new file mode 100644
index 000000000..1c96d25e2
--- /dev/null
+++ b/examples/framesjs-starter/app/examples/simplified-api/[[...routes]]/route.tsx
@@ -0,0 +1,72 @@
+import { AnyJson } from "../new-api";
+import {
+ FrameImage,
+ Frame,
+ FramesApp,
+ FrameLink,
+ renderFramesToResponse,
+} from "../react-api";
+
+type FrameState = {
+ clicked: number;
+};
+
+function isFrameState(
+ frameState: AnyJson | undefined
+): frameState is FrameState {
+ if (frameState && typeof frameState === "object" && "clcked" in frameState) {
+ return typeof frameState.clicked === "number";
+ }
+
+ return false;
+}
+
+export const framesApp = (
+
+
+ {({ frameState }) => (
+ <>
+
+ Test
+
+
+ >
+ )}
+
+
+ {({ frameState }) => (
+ <>
+
+
+ Click #
+ {isFrameState(frameState)
+ ? frameState.clicked
+ : "Not clicked yet"}
+
+
+
+ >
+ )}
+
+
+);
+
+/**
+ * GET renders always only initial frames
+ */
+export async function GET(req: Request) {
+ return renderFramesToResponse(framesApp, req);
+}
+
+/**
+ * POST always render subsequent frames in reaction to buttons
+ */
+export async function POST(req: Request) {
+ return renderFramesToResponse(framesApp, req);
+}
diff --git a/examples/framesjs-starter/app/examples/simplified-api/new-api.tsx b/examples/framesjs-starter/app/examples/simplified-api/new-api.tsx
new file mode 100644
index 000000000..11c18d6e3
--- /dev/null
+++ b/examples/framesjs-starter/app/examples/simplified-api/new-api.tsx
@@ -0,0 +1,286 @@
+import assert from "assert";
+import { ImageResponse } from "@vercel/og";
+import { ImageResponseOptions } from "next/server";
+import { getFrameMessage } from "frames.js/next/server";
+import { FrameActionDataParsedAndHubContext } from "frames.js";
+
+/** A subset of JS objects that are serializable */
+export type AnyJson = boolean | number | string | null | JsonArray | JsonMap;
+export interface JsonMap {
+ [key: string]: AnyJson;
+}
+interface JsonArray extends Array {}
+
+interface IFrameImage {
+ renderToMetaTag(): Promise;
+}
+
+export class Frame {
+ private image: IFrameImage | undefined;
+ private buttons: IFrameButton[] = [];
+ /**
+ * This is frame state coming from Button no the global app state comming from FramesApp
+ */
+ private state: AnyJson | undefined = undefined;
+
+ getState() {
+ return this.state;
+ }
+
+ setState(state: AnyJson | undefined) {
+ this.state = state;
+ }
+
+ registerImage(
+ definitionOrURL: React.ReactElement | string | URL
+ ): IFrameImage {
+ if (
+ typeof definitionOrURL === "object" &&
+ !(definitionOrURL instanceof URL)
+ ) {
+ return (this.image = new FrameImage(definitionOrURL));
+ }
+
+ return (this.image = new FrameImageURL(new URL(definitionOrURL)));
+ }
+
+ registerPostButton(path: `/${string}`, label: string): FramePostButton {
+ if (this.buttons.length >= 4) {
+ throw new Error("Maximum number of buttons is 4");
+ }
+
+ const button = new FramePostButton(
+ path,
+ (this.buttons.length + 1) as AllowedButtonIndexes,
+ label
+ );
+
+ this.buttons.push(button);
+
+ return button;
+ }
+
+ registerExternalLinkButton(href: URL, label: string): FrameExternalButton {
+ if (this.buttons.length >= 4) {
+ throw new Error("Maximum number of buttons is 4");
+ }
+
+ const button = new FrameExternalButton(
+ href,
+ (this.buttons.length + 1) as AllowedButtonIndexes,
+ label
+ );
+
+ this.buttons.push(button);
+
+ return button;
+ }
+
+ async toHTML(app: FramesApp): Promise {
+ assert(this.image, "Frame image is not set");
+
+ return `
+
+
+
+
+ ${await this.image.renderToMetaTag()}
+
+
+
+
+ `.trim();
+ }
+}
+
+type AllowedButtonIndexes = 1 | 2 | 3 | 4;
+
+interface IFrameButton {
+ renderToMetaTag(): string;
+}
+
+class FrameExternalButton implements IFrameButton {
+ private href: URL | undefined;
+ private index: AllowedButtonIndexes | undefined;
+ private label: string | undefined;
+
+ constructor(href: URL, index: AllowedButtonIndexes, label: string) {
+ this.href = href;
+ this.index = index;
+ this.label = label;
+ }
+
+ renderToMetaTag() {
+ assert(this.href, "Button href is not set");
+ assert(this.index, "Button index is not set");
+ assert(this.label, "Button label is not set");
+
+ return `
+
+
+
+ `.trim();
+ }
+}
+
+class FramePostButton implements IFrameButton {
+ private path: `/${string}` | undefined;
+ private index: AllowedButtonIndexes | undefined;
+ private label: string | undefined;
+
+ constructor(path: `/${string}`, index: AllowedButtonIndexes, label: string) {
+ this.path = path;
+ this.index = index;
+ this.label = label;
+ }
+
+ renderToMetaTag(): string {
+ assert(this.path, "Button path is not set");
+ assert(this.index, "Button index is not set");
+ assert(this.label, "Button label is not set");
+
+ return `
+
+
+
+ `.trim();
+ }
+}
+
+export class FrameImageURL implements IFrameImage {
+ private url: URL;
+
+ constructor(url: URL) {
+ this.url = url;
+ }
+
+ async renderToMetaTag(): Promise {
+ return `
+
+
+ `.trim();
+ }
+}
+
+export class FrameImage implements IFrameImage {
+ private definition: React.ReactElement;
+ private options: ImageResponseOptions | undefined;
+
+ constructor(definition: React.ReactElement, options?: ImageResponseOptions) {
+ this.definition = definition;
+ this.options = options;
+ }
+
+ async renderToMetaTag(): Promise {
+ const imageResponse = new ImageResponse(
+ (
+
+
+ {this.definition}
+
+
+ ),
+ this.options
+ );
+ const base64 = Buffer.from(await imageResponse.arrayBuffer()).toString(
+ "base64"
+ );
+
+ return `
+
+
+ `.trim();
+ }
+}
+
+export class FramesApp {
+ private state: JsonMap = {};
+ private frames: Record<`/${string}`, Frame> = {};
+ private frameMessage: FrameActionDataParsedAndHubContext | null = null;
+
+ setInitialState(state: JsonMap) {
+ this.state = state;
+ return this;
+ }
+
+ getState() {
+ return this.state;
+ }
+
+ getFrameMessage() {
+ return this.frameMessage;
+ }
+
+ registerFrame(path: `/${string}`) {
+ if (this.frames[path]) {
+ throw new Error(`Frame with path ${path} already exists`);
+ }
+
+ return (this.frames[path] = new Frame());
+ }
+
+ async renderToResponse(req: Request) {
+ const path = new URL(req.url).pathname as `/${string}`;
+
+ // @TODO if the request method is POST try to decode the frame message if any
+ // and set the state to detected state from message
+
+ // this is just naive implementation to show the idea
+ if (!this.frames[path]) {
+ return new Response("Not Found", { status: 404 });
+ }
+
+ // extract frame message if POST request with JSON body
+ if (req.method === "POST") {
+ try {
+ // set as property, this is cleared after request
+ this.frameMessage = await getFrameMessage(await req.json());
+ } catch (e) {
+ return new Response("Internal Server Error", { status: 500 });
+ }
+ }
+
+ try {
+ const frame = this.frames[path]!;
+
+ try {
+ // extract frameState from searchParams and pass it to frame
+ const frameState = new URL(req.url).searchParams.get("_fs");
+ frame.setState(frameState ? JSON.parse(frameState) : undefined);
+ } catch {}
+
+ const html = await frame.toHTML(this);
+
+ return new Response(html, {
+ headers: {
+ "Content-Type": "text/html",
+ },
+ });
+ } catch (e) {
+ return new Response("Internal Server Error", { status: 500 });
+ } finally {
+ this.frameMessage = null;
+ }
+ }
+}
diff --git a/examples/framesjs-starter/app/examples/simplified-api/react-api.tsx b/examples/framesjs-starter/app/examples/simplified-api/react-api.tsx
new file mode 100644
index 000000000..8c00fb6c3
--- /dev/null
+++ b/examples/framesjs-starter/app/examples/simplified-api/react-api.tsx
@@ -0,0 +1,168 @@
+import { createContext, useContext } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import * as newApi from "./new-api";
+import { FrameActionDataParsedAndHubContext } from "frames.js";
+
+type FrameImageProps = {
+ children: React.ReactElement;
+};
+
+/**
+ * This component throws a promise so we don't need to use server components and it is possible to use it in any app (Next, Remix, Express)
+ * with our renderFrames function.
+ */
+export function FrameImage({ children }: FrameImageProps) {
+ const frame = useContext(FrameContext);
+
+ if (!frame) {
+ throw new Error("FrameImage must be used inside a Frame");
+ }
+
+ frame.registerImage(children);
+
+ return null;
+}
+
+type FrameLinkProps = {
+ path: `/${string}`;
+ label: string;
+ /**
+ * State send to next frame in case this button is clicked
+ */
+ state?: newApi.AnyJson;
+};
+
+export function FrameLink({ path, label }: FrameLinkProps) {
+ const frame = useContext(FrameContext);
+
+ if (!frame) {
+ throw new Error("FrameLink must be used inside a Frame");
+ }
+
+ frame.registerPostButton(path, label);
+
+ return null;
+}
+
+type FramePostRedirectButtonProps = {};
+
+export function FramePostRedirectButton(props: FramePostRedirectButtonProps) {
+ return <>>;
+}
+
+type FrameExternalLinkProps = {};
+
+export function FrameExternalLink(props: FrameExternalLinkProps) {
+ return <>>;
+}
+
+type FrameMintButtonProps = {};
+
+export function FrameMintButton(props: FrameMintButtonProps) {
+ return <>>;
+}
+
+type FramesAppProps = {
+ /**
+ * @default {}
+ */
+ initialState?: newApi.JsonMap;
+ children: React.ReactElement | React.ReactElement[];
+};
+
+export function FramesApp({ initialState = {} }: FramesAppProps) {
+ const app = useContext(FramesAppContext);
+
+ app.setInitialState(initialState);
+
+ return <>>;
+}
+
+const FrameContext = createContext(null as any);
+
+type AllowedFrameChildren =
+ | React.ReactComponentElement
+ | React.ReactComponentElement
+ | React.ReactComponentElement
+ | React.ReactComponentElement
+ | React.ReactComponentElement;
+
+type FrameRenderFunctionParams = {
+ app: newApi.FramesApp;
+ /**
+ * If undefined there was no state detected (either no button has been clicked or button is not setting the state)
+ */
+ frameState: newApi.AnyJson | undefined;
+ /**
+ * Frame message from current request (set only if POST request and is containing the message)
+ */
+ frameMessage: FrameActionDataParsedAndHubContext | null;
+};
+type FrameRenderFunction = (
+ params: FrameRenderFunctionParams
+) => AllowedFrameChildren | AllowedFrameChildren[];
+
+type FrameProps =
+ | {
+ index: true;
+ children:
+ | AllowedFrameChildren
+ | AllowedFrameChildren[]
+ | FrameRenderFunction;
+ }
+ | {
+ path: `/${string}`;
+ children:
+ | AllowedFrameChildren
+ | AllowedFrameChildren[]
+ | FrameRenderFunction;
+ };
+
+export function Frame(props: FrameProps) {
+ const app = useContext(FramesAppContext);
+ const parentFrame = useContext(FrameContext);
+
+ if (parentFrame) {
+ throw new Error("Frame cannot be nested");
+ }
+
+ let frame: newApi.Frame;
+
+ if ("index" in props) {
+ frame = app.registerFrame("/");
+ } else {
+ frame = app.registerFrame(props.path);
+ }
+
+ return (
+
+ {typeof props.children === "function"
+ ? props.children({
+ app,
+ frameState: frame.getState(),
+ frameMessage: app.getFrameMessage(),
+ })
+ : props.children}
+
+ );
+}
+
+const FramesAppContext = createContext({} as any);
+
+export async function renderFramesToResponse(
+ app: React.ReactComponentElement,
+ req: Request
+): Promise {
+ const _app = new newApi.FramesApp();
+
+ try {
+ renderToStaticMarkup(
+ {app}
+ );
+
+ return _app.renderToResponse(req);
+ } catch (e) {
+ console.error(e);
+ return new Response("Internal Server Error", { status: 500 });
+ }
+}
diff --git a/examples/framesjs-starter/package.json b/examples/framesjs-starter/package.json
index 347cdb7b4..e49895393 100644
--- a/examples/framesjs-starter/package.json
+++ b/examples/framesjs-starter/package.json
@@ -16,6 +16,7 @@
"@farcaster/core": "^0.14.3",
"@noble/ed25519": "^2.0.0",
"@vercel/kv": "^1.0.1",
+ "@vercel/og": "^0.6.2",
"@xmtp/frames-validator": "^0.5.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
@@ -43,4 +44,4 @@
"tailwindcss": "^3.3.0",
"typescript": "^5.3.3"
}
-}
\ No newline at end of file
+}