App quickstart — the working critical path
Read this FIRST when building a type: "app" product. It is the copy-paste
spine every generation app shares: auth → SDK client → submit/poll → render, plus
the handful of Quanta components you'll reach for. Everything here is real,
correct, and enough to build from — you do not need to open
app/packages/{quanta,fnf,fnf-react}/src/** to confirm any of it. For the full
detail behind each step, the deeper references are cross-linked; for component
props, app/packages/quanta/ai/AGENTS.md has the per-component reference.
Golden rules (violating these is the usual cause of a broken or slow build):
- Don't read package source. Props are in
app/packages/quanta/ai/AGENTS.md; SDK/hooks are inapp/packages/fnf/ai/AGENTS.md+.../fnf-react/ai/AGENTS.md(mirrored byreferences/fnf-sdk.md+references/fnf-react.md). Readingsrc/is the single biggest time sink. If a detail is truly missing, make the reasonable call and letbun run typecheckcatch a mistake. - fnf runs server-side only against
https://fnf.internal— never from the browser, never with tokens in app code (references/auth.md, rule 3 inapp-flow.md). - Always dark, no app header.
data-theme="default-dark"is pinned; the Higgsfield host provides the global header/account chrome. Container ismx-auto w-full max-w-7xl(references/quanta-design.md). - Real backend + real D1, never a mock. (rule 3a in
app-flow.md.)
1. Auth — the /api/user proxy + login/logout
Every app is authenticated. Full contract in references/auth.md; the three
pieces you always write:
// app/src/routes/api/user.ts — browser-safe proxy; preserve status + body 1:1
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/api/user')({
server: {
handlers: {
GET: async () => {
const upstream = await fetch('https://fnf.internal/user')
const body = await upstream.text()
return new Response(body, {
status: upstream.status, // a 401 must stay a 401
headers: {
'content-type': upstream.headers.get('content-type') ?? 'application/json',
'cache-control': 'no-store',
},
})
},
},
},
})
// Browser: read the current user; null = signed out (don't crash the page)
export async function fetchCurrentUser() {
const res = await fetch('/api/user', { credentials: 'include' })
if (res.status === 401) return null
if (!res.ok) throw new Error('Failed to load user')
return res.json()
}
// Login / logout are browser NAVIGATION, not SDK calls:
const login = (ret = location.pathname + location.search) =>
{ location.href = `/__auth/login?return=${encodeURIComponent(ret)}` }
const logout = (ret = '/') =>
{ location.href = `/__auth/logout?return=${encodeURIComponent(ret)}` }
A signed-out surface renders a sign-in action that calls login(); a server-side
re-check guards every SDK operation. Do not invent an email/password form.
2. Create the SDK clients (server-side)
One adapter satisfies jobs + media + profile. Import the adapter from
@higgsfield/fnf/workflow-platform (NOT /adapters — that subpath is gone):
import { createJobClient } from '@higgsfield/fnf/client'
import { createMediaClient } from '@higgsfield/fnf/media'
import { createProfileClient } from '@higgsfield/fnf/profile'
import { createWorkflowPlatformAdapter } from '@higgsfield/fnf/workflow-platform'
import { gptImage2 } from '@higgsfield/fnf/jobs' // register ONLY the jobs you use
const adapter = createWorkflowPlatformAdapter({ baseUrl: 'https://fnf.internal' })
const jobs = createJobClient({ adapter, jobs: [gptImage2] })
const media = createMediaClient({ mediaAdapter: adapter })
const profile = createProfileClient({ profileAdapter: adapter })
The jobs: [...] registry is what gives model/settings their TypeScript
narrowing. Catalog of models in app/packages/fnf/ai/AGENTS.md ("Current SDK
catalog") — e.g. gptImage2→gpt_image_2, nanoBanana2→nano_banana_2,
seedance2_0, kling3_0.
3. Submit (with the confirmation gate) → poll → read the URL
The confirm gate is passed to the adapter factory; it runs once per submit,
after validation, before any network call. Rejecting is a user choice, not a
failure (typed confirmation_rejected).
const adapter = createWorkflowPlatformAdapter({
baseUrl: 'https://fnf.internal',
confirm: async ({ jobSetType }) => {
// UI host opens its cost-preview modal here and resolves; reject to cancel.
if (!(await openConfirmModal(jobSetType))) throw new Error('declined')
},
})
const { generations } = await jobs.submit({
model: 'gpt_image_2',
prompt: { instruction: userPrompt },
settings: { aspectRatio: '1:1', quality: 'high', resolution: '2k', batchSize: 1 },
})
const [done] = await jobs.wait(generations, {
signal,
onProgress: g => console.log(g.status),
})
Prefer safeSubmit across a client/server or iframe boundary and branch on
error.code (survives JSON; instanceof does not):
const r = await jobs.safeSubmit(input)
if (!r.ok) {
if (r.error.code === 'out_of_credits') return showBillingUI()
if (r.error.code === 'confirmation_rejected') return // declined — not an error
throw r.error
}
Common codes: out_of_credits, rate_limit, prompt_nsfw, ip_detected,
job_failed, timeout, validation, confirmation_rejected
(app/packages/fnf/ai/AGENTS.md, "Errors and boundaries").
4. Render the result
generation.results is a single object { rawUrl, minUrl?, thumbnailUrl? },
present only once completed. Do NOT hand-roll URL selection — use the selectors:
import { getPreviewUrl, getRawUrl, getJobPhase } from '@higgsfield/fnf/client'
getPreviewUrl(done) // grids/cards — precedence minUrl → thumbnailUrl → rawUrl
getRawUrl(done) // full quality
getJobPhase(done) // 'progress' | 'completed' | 'failed' — drives state UI
In-app, compose result cards from app/src/lib/higgsfield-generation-results.ts
(selectGenerationMedia(generation) returns a resolved image | video | empty
union). A completed generation with no rawUrl must show an explicit
"preview unavailable" state with refresh — never a blank card.
Carry media geometry across the server/client boundary as part of the flat DTO:
prefer result width/height when available, otherwise retain the canonical
submitted aspectRatio. Use that geometry for both the pending skeleton and the
ready card. Preview optimization may change encoding, never composition: preserve
the whole result and do not hardcode square, 16:9, or object-cover for a
generation gallery.
5. React wiring (fnf-react hooks)
useGenerationRun(client, opts?)— drives one submission end-to-end. Returns{ status, generations, isRunning, error, start(input), abort(), reset() };startnever rejects (errors land inerror).clientmust be referentially stable. Best for the "generate" button flow.- Live read of one job:
useQuery(generationQueryOptions(jobClient, jobId, { scopeKey }))— polls every 5s while non-terminal, stops when terminal. (There is nouseGenerationhook.) useAttachments(media, opts?)— upload controller for input images/media:{ items, refs, isUploading, add(files, { role }), settled(), remove(key) }.await controller.settled()yields submit-readyMediaRef[].
6. Media upload — binary never goes through JSON
Never pass File/Blob/ArrayBuffer/base64 through createServerFn input or a
JSON body. Use multipart to an app-local route:
browser File → POST multipart /api/media/upload → media.upload(...) → MediaRef
MediaRef + prompt/settings → submit
Server reads await file.arrayBuffer(), calls media.upload({ source: bytes, … }),
returns only the MediaRef. Detail in references/fnf-sdk.md ("Media recipes").
7. Server functions + bindings
App data operations run through TanStack server functions or /api/* routes:
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
export const generateMeme = createServerFn({ method: 'POST' })
.inputValidator(z.object({ prompt: z.string().min(1) }))
.handler(async ({ data }) => {
// re-check auth, build the server-side adapter, submit, persist to D1…
})
Cloudflare bindings (D1 DB, R2 STORAGE, KV KV) are read server-side via
app/src/lib/bindings.server.ts (import { env } from "cloudflare:workers"), and
only exist if declared in app/app.manifest.json ("db": true, etc.). Guard
before use. Runtime detail in references/runtime-and-infra.md.
Return flat, fully-typed DTOs — never a raw SDK object. TanStack Start
compile-time-checks that every server-function return value is serializable, and
SDK types like Generation carry input.settings: Record<string, unknown> —
unknown fails that check and breaks the build. Map the SDK object to a small
DTO with only the fields the client needs ({ id, status, previewUrl, … }),
typed explicitly. Same rule for anything you hand back across the server/client
boundary: no unknown, no class instances, plain JSON-shaped data.
import type React types. In module files there is no React UMD global,
so bare React.ReactNode / React.CSSProperties fail to compile — write
import type { ReactNode, CSSProperties } from "react" and use them unqualified.
8. The Quanta components you'll actually use
All dark, composed — never restyled. Props reference:
app/packages/quanta/ai/AGENTS.md ("Component API reference"). The core set:
import { Button } from '@higgsfield/quanta/button'
import { Textarea } from '@higgsfield/quanta/textarea'
import { Loader } from '@higgsfield/quanta/loader'
import { toast } from '@higgsfield/quanta/sonner'
// The generation CTA is ALWAYS marketingPrimary with the credit cost inside:
<Button variant="marketingPrimary">
Generate <SparklesIcon /> {credits}
</Button>
// Prompt field: helper/error props are `description`/`error` (NOT helperText/errorText)
<Textarea label="Prompt" description="Describe your meme" value={v} onChange={e => setV(e.target.value)} />
// Mixed-ratio result tile: keep the submitted/result frame and the whole media.
// Put this app-local component in app/src/components/; it uses Quanta tokens.
const cssRatio = (ratio: string) => {
const [width, height] = ratio.split(':').map(Number)
return width > 0 && height > 0 ? `${width} / ${height}` : '1 / 1'
}
<div
className="grid w-full place-items-center overflow-hidden rounded-lg bg-q-background-secondary"
style={{ aspectRatio: cssRatio(result.aspectRatio) }}
>
{phase === 'completed' && result.previewUrl
? <img
className="block h-full w-full object-contain"
src={result.previewUrl}
alt={prompt}
/>
: <Loader variant="stars" />}
</div>
Reach for: Button (actions/CTA), Textarea/Input (prompt/fields),
Select/Dropdown (options), Slider/Switch/Chip (settings), Media
(canonical-ratio media), an app-local ratio-preserving frame for mixed generation
results, Card/Glass (surfaces), Grid/VirtualGrid (feeds),
Loader/Progress (busy), Modal/Vault (dialogs/sheets), toast (notices).
For a gap, build a small component from Quanta primitives in
app/src/components/ — never a third-party UI library, never restyle Quanta.
Then follow app-flow.md steps 6–8: deploy → cover + metadata (generate the
cover per references/app-cover.md) → publish (automatically if the user opted in
at intake, else when asked), then suggest the contest.