React hooks
@civitai/blocks-react is the React-first way to build a Civitai App. Each hook wraps a slice of the message bridge so you never touch postMessage directly — you call a hook, get typed state back, and the host brokers the privileged work.
The signatures below are generated from the published package's type definitions; the examples come from its README.
Trust model
Every hook that reads private data or submits work is host-mediated: the host resolves the viewer from the block token and performs the privileged call on Civitai's side of the iframe boundary. Your app never holds a credential or calls a privileged API directly.
useBlockContext
useBlockContext(): Pick<BlockSnapshot, 'ready' | 'renderMode' | 'context' | 'token' | 'settings' | 'viewer' | 'theme' | 'blockId' | 'blockInstanceId' | 'appId'>The primary hook. Returns everything the host delivered in `BLOCK_INIT` plus a `ready` gate — fields are sentinel-empty before init, so gate your UI on `ready`.
const { ready, context, viewer, theme, settings, blockId, blockInstanceId, appId, token, renderMode } =
useBlockContext();useBlockResize
useBlockResize(ref: RefObject<HTMLElement | null>): voidAttach to your root element. Observes its height and posts `RESIZE_IFRAME` so the host sizes the iframe to fit. No-op on the inline transport (host DOM reflows naturally).
const rootRef = useRef<HTMLDivElement>(null);
useBlockResize(rootRef);useBlockToken
useBlockToken(): BlockToken & {
refresh: () => Promise<void>;
}Current block-scoped JWT, auto-refreshing ~2 min before expiry. Returns the token fields plus a `refresh()` for the 401-retry path.
const { raw, scopes, expiresAt, buzzBudget, refresh } = useBlockToken();
// after a 401: await refresh(); then retry the request once with the new `raw`.useHostOrigin
useHostOrigin(): string | undefinedThe validated host origin to direct-fetch the App Blocks HTTP API against — `undefined` until init. Use it as the base URL when you need to bypass the host bridge, always paired with the bearer token from `useBlockToken()`.
const host = useHostOrigin(); // e.g. "https://civitai.com" (undefined until BLOCK_INIT)
const { raw } = useBlockToken();
// Once `host` is set, fetch the API on that validated origin with the block token:
if (host) {
const res = await fetch(`${host}/api/v1/blocks/me`, {
headers: { authorization: `Bearer ${raw}` },
});
}useBlockSettings
useBlockSettings(): BlockSettingsShorthand for `useBlockContext().settings`. Read-only from the iframe — settings are *written* on the platform `/apps/installed` page, not via a bridge message.
const { publisherSettings, userSettings } = useBlockSettings();useBuzzWorkflow
useBuzzWorkflow(): UseBuzzWorkflowReturnThe generation flow: `estimate` → `submit` → `poll`, host-mediated. Returns `{ estimate, submit, poll, status, result, error }`.
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
const { estimate, submit, poll, status, result } = useBuzzWorkflow();
declare const modelId: number, modelVersionId: number, userPrompt: string;
const body: WorkflowBody = {
kind: 'textToImage',
modelId,
modelVersionId,
params: { prompt: userPrompt },
};
await estimate(body); // status 'estimating' → 'confirming' (cost in result.cost.total)
const snap = await submit(body); // status 'submitting' → 'polling'; returns a workflowId
await poll(snap.workflowId); // you loop this on a backoff until terminaluseBuzzPurchase
useBuzzPurchase(): {
openPurchaseModal: (suggestedAmount?: number) => Promise<{
purchased: boolean;
newBalance?: number;
}>;
}Open the Buzz purchase modal — the insufficient-budget recovery path.
const { openPurchaseModal } = useBuzzPurchase();
const { purchased, newBalance } = await openPurchaseModal(suggestedAmount);
if (purchased) { /* retry the generation */ }useBuzzBalance
useBuzzBalance(): UseBuzzBalanceThe signed-in viewer's per-pool Buzz balance (`{ blue, green, yellow }` — the domain-clamped pools a block may read; never the platform-internal `red`/`purple`). Host-mediated over `GET_BUZZ_BALANCE` → `BUZZ_BALANCE_RESULT`; same trust model as `useBuzzWorkflow`/`useBuzzPurchase` (the host resolves the viewer from the block token — the block never touches the balance API). Fetches on mount; `refetch` for on-demand refreshes.
const { balance, loading, error, refetch } = useBuzzBalance();
// `balance` is null until the first successful fetch. refetch() after a
// generation debits it. An anon viewer / missing scope / host failure → `error`.
if (!loading && balance) console.log(`Yellow: ${balance.yellow}`);useViewer
useViewer(): UseViewerThe signed-in viewer as an on-demand authoritative self-read (`{ id, username, status, buzzBudget }`) — distinct from `useBlockContext().viewer`, the coarse `BLOCK_INIT`-time snapshot. `status` is `'active' | 'muted'`; `username` (`string | null`) and `buzzBudget` (`number | null`) are present-but-nullable, so handle the null case. Host-mediated over `GET_VIEWER` → `VIEWER_RESULT` (the host resolves the viewer from the block token via `blocks.getMyViewer`); an anonymous / banned viewer comes back as `error`. Fetches on mount; `refetch` for on-demand refreshes.
const { viewer, loading, error, refetch } = useViewer();
// `viewer` is null until the first successful fetch. An anon / banned viewer,
// missing scope, or host failure → `error`. `username`/`buzzBudget` may be null.
if (!loading && viewer) console.log(`${viewer.username ?? 'anon'} · budget ${viewer.buzzBudget ?? 0}`);useBuzzTransactions
useBuzzTransactions(params?: BlockBuzzTransactionsParams): UseBuzzTransactionsThe signed-in viewer's Buzz-transaction ledger (a paged, host-projected read of the Buzz dashboard). Returns `{ transactions, cursor, loading, error, refetch }`; `transactions` rows are rehydrated so `date` is a `Date`. Pass the returned `cursor` back as `params.cursor` to page forward. Requires the `buzz:read:self` scope; host-mediated over `GET_BUZZ_TRANSACTIONS`.
const { transactions, cursor, loading, error } = useBuzzTransactions({ type: 'Tip', limit: 20 });
if (!loading && transactions) transactions.forEach((t) => console.log(t.type, t.amount, t.date));useBuzzAccounts
useBuzzAccounts(): UseBuzzAccountsThe viewer's all-pool Buzz balances — the three spendable pools **plus** the creator payout pools (`{ accountType, balance }[]`), a superset of `useBuzzBalance`. Returns `{ accounts, loading, error, refetch }`. Requires `buzz:read:self`; host-mediated over `GET_BUZZ_ACCOUNTS`.
const { accounts, loading, error } = useBuzzAccounts();
if (!loading && accounts) accounts.forEach((a) => console.log(a.accountType, a.balance));useDailyCompensation
useDailyCompensation(params: BlockDailyCompensationParams): UseDailyCompensationPer-modelVersion generation-compensation for the month containing `params.date` (Buzz totals + cash totals in pennies). Returns `{ resources, hasPublishedResources, loading, error, refetch }`. Requires `buzz:read:self`; host-mediated over `GET_DAILY_COMPENSATION`.
const { resources, hasPublishedResources } = useDailyCompensation({ date: '2026-07-01' });useWildcardPack
useWildcardPack(modelVersionId: number): UseWildcardPackImport a wildcard pack's parsed prompt lists by model version — the host resolves + fetches + unzips + parses it **in the user's own page session** (every download gate enforced), so the untrusted iframe never sees the bytes. Returns `{ pack, loading, error, refetch }`. On failure `error` is a `WildcardPackError` with a discriminated `code` (`not-found` / `forbidden` / `too-large` / `parse-failed` / `busy` — `busy` is retryable), not free text.
const { pack, loading, error, refetch } = useWildcardPack(modelVersionId);
// `error.code === 'busy'` is retryable — call refetch(); the other codes are terminal.
if (error instanceof WildcardPackError && error.code === 'busy') void refetch();
if (!loading && pack) console.log(Object.keys(pack.lists));useAppWorkflows
useAppWorkflows(params?: AppWorkflowsParams): UseAppWorkflowsThe calling app's **own** generator subqueue — the tag-scoped list of generations **this app** produced for the viewer (newest-first), plus a fail-closed `cancel`. The host self-binds the account off the block token and **forces** the per-app tag filter, so a block only ever sees the queue it produced — never the viewer's personal queue or another app's. Returns `{ workflows, cursor, loading, error, refetch, cancel }`; each `AppWorkflow` is `{ workflowId, status, images[], cost, createdAt }`. Pass the returned `cursor` back as `params.cursor` to page forward. Requires `ai:write:budgeted` (same trust boundary as submit); host-mediated over `QUERY_APP_WORKFLOWS` / `CANCEL_APP_WORKFLOW`. `cancel(workflowId)` sends `CANCEL_APP_WORKFLOW`, resolves once the host confirms the terminal state (which is optimistically spliced into `workflows` in place — no refetch round-trip), and rejects with the host's error on failure.
const { workflows, cursor, loading, error, refetch, cancel } = useAppWorkflows({ limit: 20 });
if (!loading && !error) {
workflows.forEach((w) => console.log(w.workflowId, w.status, w.images.length, w.cost));
}
async function onCancel(id: string) {
try {
await cancel(id); // optimistically flips the row to `canceled`
} catch (err) {
console.error('cancel failed', err);
}
}useAppStorage
useAppStorage(): UseAppStoragePer-(block instance, viewer) KV datastore, host-mediated. 64 KB per value, 50 MB + ~1M rows per app.
const storage = useAppStorage();
await storage.set('key', { any: 'json' }); // throws "PAYLOAD_TOO_LARGE" over a limit
const v = await storage.get<MyShape>('key'); // null if unset / anon
await storage.delete('key'); // idempotent
const { keys } = await storage.list({ prefix: 'note-' });
const quota = await storage.getQuota(); // { usedBytes, rowCount, limitBytes, limitRows }useSharedStorage
useSharedStorage(): UseSharedStorageApp-scoped, append-only, community-votable SHARED datastore (every viewer sees the same list). Sibling of `useAppStorage`; anonymous viewers get the read path and a hard reject on mutations.
const shared = useSharedStorage();
const { key } = await shared.append({ title: 'Add dark mode', body: 'please' });
const { items } = await shared.list({ limit: 20 }); // newest-first
const count = await shared.vote(key); // idempotent up-vote
await shared.unvote(key);
await shared.withdraw(key); // remove my own entryuseCheckpointPicker
useCheckpointPicker(): {
open: (opts: {
/**
* Ecosystem key (e.g. 'Flux1', 'SDXL'). Get it from
* `useBlockContext().context.checkpoint?.baseModel` — but for the
* picker filter the host will collapse to the ecosystem family, so
* any baseModel in the family works as a hint.
*/
baseModelGroup: string;
/** Currently-selected versionId so the picker can pre-highlight it. */
currentVersionId?: number;
}) => Promise<{
selected?: BlockCheckpointInfo;
}>;
persist: (versionId: number | null) => Promise<void>;
}Drive the platform Checkpoint picker + persist a viewer override.
const { open, persist } = useCheckpointPicker();
const { selected } = await open({ baseModelGroup: 'SDXL', currentVersionId });
if (selected) await persist(selected.versionId); // null clears the overrideuseResourcePicker
useResourcePicker(): {
open: (opts: {
/** Which resource type to pick. v1: `'Checkpoint' | 'LORA'` only — the
* host rejects any other type (the modal never opens). */
resourceType: BlockResourcePickerType;
/**
* Optional base-model family hint — an ecosystem key (e.g. 'Flux1', 'SDXL')
* OR a baseModel name (e.g. 'Flux.1 D'); the host collapses it to the
* ecosystem family. Use the chosen checkpoint's `baseModel` to constrain a
* LoRA pick to the same family. Omit for an unconstrained pick of the type.
*/
baseModelGroup?: string;
}) => Promise<BlockResourceInfo | null>;
}Drive the platform resource picker for page blocks — `'Checkpoint' | 'LORA'`. The viewer searches in host chrome; the block only ever sees the one resource it picked. DISCOVERY ONLY — the returned `versionId` is re-validated + re-priced server-side at estimate/submit.
const { open } = useResourcePicker();
const picked = await open({ resourceType: 'LORA', baseModelGroup: 'SDXL' });
if (picked) {
const versionId = picked.versionId; // feed into body.additionalResources
const weight = picked.strength; // recommended default weight (may be undefined)
}useImageUpload
useImageUpload(options: {
purpose: 'generationSource';
}): {
open: () => Promise<BlockGenerationSourceImageInfo | null>;
}Host-mediated image upload — the host opens its native upload modal and the iframe never handles the bytes. Resolves with a moderated image (or `null` on dismiss); pass `{ purpose: 'generationSource' }` for an unscanned img2img source or `{ asyncScan: true }` for the early-resolve + `scanStatus()` flow.
const { open } = useImageUpload();
const img = await open(); // BlockUploadedImageInfo | null
if (img) {
await submit({
kind: 'textToImage',
modelId,
modelVersionId,
sourceImage: { url: img.url, width: 1024, height: 1024 },
params: { prompt },
});
}useGenerationResources
useGenerationResources(): {
fetch: (versionIds: number[]) => Promise<BlockResourceInfo[]>;
}Rehydrate a saved set of generation resources by version id — WITHOUT re-opening the picker. Returns the same widened projection `useResourcePicker` yields (recommended weights, trigger words, clipSkip). DISCOVERY ONLY.
const { fetch } = useGenerationResources();
const resources = await fetch([691639, 666002]); // by saved versionIds
const first = resources[0]; // .versionId / .strength / .trainedWords / .clipSkipuseCivitaiNavigate
useCivitaiNavigate(): {
navigate: (path: string, target?: 'current' | 'new_tab') => void;
}Request a navigation within civitai.com (host-mediated; fire-and-forget).
const { navigate } = useCivitaiNavigate();
navigate('/models/12345', 'new_tab'); // 'new_tab' needs allow-popups* in the manifest sandboxuseBlockAnalytics
useBlockAnalytics(): {
track: (eventName: string, properties?: Record<string, unknown>) => void;
}Fire-and-forget event tracking into the host's analytics pipeline.
const { track } = useBlockAnalytics();
track('generate_clicked', { modelId });useRequestSignIn
useRequestSignIn(): {
requestSignIn: (payload?: {
returnUrl?: string;
}) => void;
}Ask the host to open its sign-in flow for an ANONYMOUS viewer (fire-and-forget). On sign-in the host re-inits the block with the now-authenticated viewer.
const { requestSignIn } = useRequestSignIn();
// e.g. onClick of a "Sign in to generate" button:
requestSignIn();useRequestConsent
useRequestConsent(): {
requestConsent: (payload?: {
scopes?: string[];
}) => void;
}Lazy consent: ask the host to open its consent UI when a LOGGED-IN viewer takes an action whose consent-gated scope the block token is missing (e.g. Generate needs `ai:write:budgeted` but the viewer hasn't granted it). Fire-and-forget — on grant the host pushes a new token; observe `useBlockToken().scopes` and retry.
const { requestConsent } = useRequestConsent();
requestConsent({ scopes: ['ai:write:budgeted', 'buzz:read:self'] });useDomainMaturity
useDomainMaturity(): DomainMaturityRead the surrounding color-domain's maturity ceiling (civitai #2670) so a block can hide/blur mature affordances on a SFW domain. **Fail-closed SFW** until `BLOCK_INIT` lands or against a host that predates the field.
const { isSfw, isLevelAllowed } = useDomainMaturity();
const showRSlider = isLevelAllowed(BrowsingLevel.R); // false on a SFW domainInstall
pnpm add @civitai/blocks-react @civitai/app-sdkSee the Quickstart for a full scaffold, and the message bridge reference for the protocol these hooks sit on.