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):

  1. Don't read package source. Props are in app/packages/quanta/ai/AGENTS.md; SDK/hooks are in app/packages/fnf/ai/AGENTS.md + .../fnf-react/ai/AGENTS.md (mirrored by references/fnf-sdk.md + references/fnf-react.md). Reading src/ is the single biggest time sink. If a detail is truly missing, make the reasonable call and let bun run typecheck catch a mistake.
  2. fnf runs server-side only against https://fnf.internal — never from the browser, never with tokens in app code (references/auth.md, rule 3 in app-flow.md).
  3. Always dark, no app header. data-theme="default-dark" is pinned; the Higgsfield host provides the global header/account chrome. Container is mx-auto w-full max-w-7xl (references/quanta-design.md).
  4. 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. gptImage2gpt_image_2, nanoBanana2nano_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)

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.