@fluid-app/sites
v0.1.152
Published
The member-sites browser SDK: fluid.auth, fluid.api, fluid.db, fluid.fn and fluid.cart on the site's own origin
Readme
@fluid-app/sites
The browser SDK for sites hosted on Fluid. It gives a page fluid.auth,
fluid.api, fluid.db, fluid.fn, fluid.cart, fluid.history and
fluid.storefront. Every call goes to the site's own origin, under /_fluid/,
so the page needs no API key and no CORS setup: Fluid knows who the visitor is
and which site they are on.
It also ships fluidSite() (@fluid-app/sites/vite), a Vite plugin that makes
a site findable by search engines, link previews and agents.
Full guides live at docs.fluid.app:
- Member sites overview: what a site is and where it opens
- Member sites SDK: every method in detail
- Site database:
db/schema.json, rules and limits - Publishing:
fluid sites publish
Install
npm install @fluid-app/sitesimport { fluid } from "@fluid-app/sites";Importing the package also sets window.fluid, merged into any window.fluid
already on the page.
No build step: the script tag
Every published site serves the same SDK at /_fluid/sdk/v1/sites.js, so a
plain HTML page can use it without npm or a bundler:
<script type="module">
import { fluid } from "/_fluid/sdk/v1/sites.js";
const me = await fluid.auth.me();
document.querySelector("#hello").textContent = me.viewer
? `Hi, ${me.viewer.name}`
: "Hi there";
</script>fluid sites dev serves the same file once the next fluid sites CLI release
ships. Until then, the script tag works only on a published site.
The SDK
fluid.auth: who is here
const me = await fluid.auth.me();
// me.viewer: { userId, name, ... } or null for a signed-out visitor
// me.site: { id, handle, name, visibility, companyName }
if (!me.viewer) fluid.auth.signIn({ returnTo: "/rsvp" });
await fluid.auth.signOut();fluid.api: the Fluid API as the visitor
fluid.api calls the member API (/api/member/v2026-10/...) as the signed-in
visitor, limited to the scopes the site was granted.
const orders = await fluid.api.get("/orders", { "page[limit]": "10" });
const res = await fluid.api.fetch("/orders?page[limit]=10");
if (res.status === 401) fluid.auth.signIn();get throws FluidRequestError (with status and code, such as 403
scope_not_granted) on a non-2xx answer. fetch returns the response as it is.
fluid.db: the site's own database
Tables and their access rules come from db/schema.json, published with the
build. Queries read like Supabase and resolve to { data, error, count }.
await fluid.db.from("rsvps").insert({ event_id: eventId, guests: 2 });
const { data, error } = await fluid.db
.from("rsvps")
.select("name,guests")
.eq("event_id", eventId)
.order("created_at", { ascending: false })
.limit(20);Every row also has id, created_by, created_at and updated_at.
fluid.fn: the site's server functions
const res = await fluid.fn.fetch("rsvp", {
method: "POST",
body: JSON.stringify({ eventId }),
});A function answers at /api/<name> and /api/<name>/<sub-path> on the
site's own origin, so fluid.fn.fetch("orders/123?full=1") calls
/api/orders/123?full=1 as the viewer. A plain fetch("/api/rsvp") reaches
the same function, without the viewer's token. A server or another site calls
an access: "key" function with Authorization: Bearer fsk_…, a site API
key. A bundle that declares functions may hold no file under /api/.
fluid.cart: the storefront's cart
On a site shown inside the storefront, the cart is the storefront's own cart. Arguments are the FairShare SDK's.
await fluid.cart.add([{ variant_id: 501, quantity: 1 }]);
const stop = fluid.cart.onChange((cart) => render(cart));
fluid.cart.open();
fluid.cart.checkout();A button with FairShare's attributes, such as data-fluid-add-to-cart, works
too, with no script. On a page with no storefront around it, cart calls reject.
The storefront draws FairShare's floating cart button and lead capture bubble
over the site's bottom corner. A site with its own cart button can hide them in
config/shell.json:
{ "fairshare": "hidden" }Only the bubbles are hidden. FairShare still loads, so fluid.cart,
fluid.cart.open(), checkout and the sharer's credit work as before.
fluid.history: Back and Forward for a single-page app
Inside the storefront, the address bar belongs to the storefront. Let your router follow it:
fluid.history.onGo((path, hash) => router.navigate(path + (hash ?? "")));A hash router needs nothing more: the route in the fragment (#/deals/42 or
#!/deals/42) shows in the storefront's address bar, a link to it opens that view, and Back,
Forward and a reload return to it. The SDK moves the frame to another route
with replaceState, then fires popstate and hashchange, as a fragment
navigation would.
The route shows in the storefront's address bar and history, so don't put a
credential in a hash route (#/oauth?access_token=…). A fragment that isn't a
hash route, such as #access_token=…, stays in the frame.
fluid.storefront: links that keep the sharer's credit
const shop = await fluid.storefront.url("/shop");
// "https://yoli.com/janedoe/shop"Local development: fluid sites dev
fluid sites dev runs the site on your machine with a database of its own, so
fluid.db works before you publish and never touches the live site's rows.
npm install -g @fluid-app/fluid-cli @fluid-app/fluid-cli-member-site
fluid sites dev # http://127.0.0.1:9500
fluid sites dev --viewer guest # browse as a signed-out visitor
fluid sites dev --reset-db # start from an empty database- It runs the folder's
devscript (or servesdist/when there is none) and answers/_fluid/dbfrom.fluid/dev.sqlite, with the same checks and table rules the live site applies. It adds.fluid/to.gitignore. Once the nextfluid sitesCLI release ships, it also serves/_fluid/sdk/v1/sites.js. - Saving
db/schema.jsonupdates the local database at once. A change that drops data waits for you to confirm it. - By default you are a stand-in owner,
local-owner, so rows you write carry thatcreated_by./_fluid/apianswers 503 locally for now.
SEO: @fluid-app/sites/vite
A site is a single-page app, so a crawler that does not run JavaScript sees an
empty page. fluidSite() runs after vite build and writes what a crawler
reads from config/meta.json:
// vite.config.ts
import react from "@vitejs/plugin-react";
import { fluidSite } from "@fluid-app/sites/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), fluidSite()],
});// config/meta.json
{
"default": {
"title": "Diamond Leaders",
"description": "Events and training for the Diamond Leaders team.",
"image": "/og.png"
},
"url": "https://shop.example.com/home/sites/diamond-leaders",
"twitter": { "site": "@fluid" },
"routes": ["/events", "/drafts"],
"routeMeta": {
"/events": { "title": "Events", "description": "What's on." },
"/drafts": { "noindex": true }
}
}What it writes into the build output:
- Head tags in
index.html:<title>,description,og:title,og:description,og:image,og:type,og:urlandtwitter:card(twitter:siteandtwitter:creatorwhen set).meta.json's title replaces the page's<title>; any other tag the page already has stays. With an absolute site address, each page also gets its own<link rel="canonical">; without one, the gateway'sLinkheader names it. - One
index.htmlper route (/events→events/index.html), with that route's head tags. The gateway serves a route's own file before the single-page-app fallback. A route's title, description and image come fromrouteMeta[path], thenpages[path], thendefault. Routes come fromroutes, plus any plainpath: "/…"literal insrc/router.ts(x)orsrc/routes.ts(x);meta.jsonwins. - Page content: with
prerender, each page's HTML goes inside the root element; without it, each page gets a<noscript>with its title and description. sitemap.xml(with a site address, below) andllms.txtlisting the home page and every route. Either is left alone whenpublic/ships its own.
A route that routeMeta marks noindex gets its HTML file, with
<meta name="robots" content="noindex">, and nothing else: it is left out of
sitemap.xml and llms.txt, and fluid sites publish drops it from the
manifest's routes, which the gateway's own sitemap.xml and llms.txt list.
A site address must be an absolute http(s) URL with no user name, password,
query or fragment (meta.json url must be https); any other siteUrl is
ignored. Without one, llms.txt and og:url hold paths under /, and no sitemap.xml is written: a sitemap needs
absolute addresses, and the plugin never writes a relative <loc>.
The sites gateway serves sitemap.xml and llms.txt only for an indexable
site: a live, public company site with indexing turned on in its SEO settings,
and no company or theme setting that blocks indexing. A new site starts with
indexing off. A member's own site, a branch and a private site are never
indexable. Any other site serves neither file, not even the bundle's own. An
indexable site serves the bundle's own files unchanged (set url for the
build's sitemap.xml); a standalone one with none gets them generated from the
manifest's meta.routes.
Every member of meta.json is optional, {} included. A title it names
replaces a page's own <title>, such as the template's: default.title on the
home page, and routeMeta[path].title (then pages[path], then default) on a
route's page; each other field falls back the same way. With no title, the page
keeps its own <title>, and llms.txt is named after the home page's. On the
storefront, the shell's head takes each field from routeMeta/pages for the
exact path, then the site's SEO settings (the admin or the API), then
default, so leave default out to let the SEO settings decide.
Other tags follow the page: the home page and any page the build wrote itself
keep their own description, Open Graph and Twitter tags. A route's page
built from the home page's index.html takes the route's own tags over the home page's, and never keeps the home
page's robots or canonical. Routes are one page
with or without a trailing slash, and a route that is a file of the build
(such as /feed.xml from public/) stays that file.
Options
| Option | Default | What it does |
| ----------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| siteUrl | meta.json url | The site's public address, which sitemap.xml, llms.txt, og:url and og:image start from |
| prerender | none | A module exporting render(path): string \| Promise<string>, built with Vite's SSR build |
| rootId | "root" | The id of the empty element prerendered HTML goes in |
| router | src/router.ts(x), then src/routes.ts(x) | The router file read for plain paths, or false for none |
| meta | config/meta.json | Where the site's meta is |
| media | on | The media step below; { manifest } names another manifest, false turns it off |
A prerender entry for a React site:
// src/prerender.tsx
import { renderToString } from "react-dom/server";
import { App } from "./App";
export function render(path: string): string {
return renderToString(<App path={path} />);
}fluidSite({ prerender: "./src/prerender.tsx" });A meta.json the contract refuses fails the build, with the field it names.
fluid sites lint warns a company site that has no title or description, or
no routes and no prerender entry.
Media in the Fluid DAM
Images, video, audio and large fonts belong in the company's Fluid DAM, not
the bundle: the DAM's CDN serves them, resizes images on request, and the
bundle stays small. Put them under public/media/ and reference them by their
path, as you would any public file:
<video src="/media/hero.mp4" autoplay muted loop playsinline></video>
<img src="/media/team.jpg?tr=w-800" alt="The team" />fluid sites media sync (an admin login; fluid deploy runs it before the
build) uploads each new or changed file to the DAM, and records its CDN
address and SHA-256 in config/media.json. Commit that file: a file whose
bytes are unchanged is never uploaded again.
After the build, fluidSite() points every reference to a recorded file at
its CDN address, keeping any query (?tr=w-800 is a DAM transformation), and
leaves the file out of dist. It then warns about each video or audio file,
and each image or font over 256 KiB, still in the build. Small icons and SVGs
stay in the bundle.
A member login cannot upload to the company's DAM yet, so a member's site keeps its media in the bundle, with that warning.
DAM media is public by URL, whatever the site's visibility. A CDN
address works for anyone who has it, without the site's audience check, and
keeps working after someone's access to a private site is revoked. Only a
file served from the bundle sits behind that check. Keep anything that must
stay private in the bundle (outside public/media/), or out of the site.
Errors
FluidRequestError: a/_fluid/*route answered with a status that is not 2xx. It hasstatus,codeandbody.NotSupportedError: afluid.dbmethod the database does not support.
