From fusebase-flow
Guides building Fusebase Apps with React, Vite, Tailwind, and shadcn/ui. Covers scaffolding, multi-user state, project structure, and Vite config.
How this skill is triggered — by the user, by Claude, or both
Slash command
/fusebase-flow:app-dev-practicesThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Run `fusebase scaffold --template spa --dir apps/<name>` before writing any app files. This generates the canonical React + Vite + Tailwind v4 + shadcn/ui project structure. For apps needing a backend, also run `fusebase scaffold --template backend --dir apps/<name>`. Customize from there — never recreate boilerplate by hand.
Run fusebase scaffold --template spa --dir apps/<name> before writing any app files. This generates the canonical React + Vite + Tailwind v4 + shadcn/ui project structure. For apps needing a backend, also run fusebase scaffold --template backend --dir apps/<name>. Customize from there — never recreate boilerplate by hand.
Every app is multi-user. Multiple users access the same deployed app simultaneously, each with their own identity (app token). Never design for a single user.
Rules:
Per-user state (OAuth tokens, preferences, selections) must be stored per-user — use httpOnly cookies (set by the server, scoped to the user's browser) or dashboard rows keyed by user ID
Shared env vars / Fusebase secrets are global — they are the same for all users. Never use them for per-user credentials or settings
In-memory backend variables are shared across all requests from all users — never store per-user data in module-level variables
When integrating third-party APIs with OAuth, each user must go through their own auth flow and get their own tokens
Stable partition keys only: when storing per-user rows, the partition key must come from a stable identity (userId, orgUserId, email if immutable), not from session/token artifacts.
Never use token-derived keys (e.g. ft:*, JWT fragments, rotating session IDs) as persistent user_key/partition keys — they change between sessions and split one user's data into multiple buckets.
Recommended format: normalize once in backend (user:<userId>) and reuse the same key for both read and write paths.
Ask yourself: "If two users open this app at the same time, will they interfere with each other?" If yes, the design is wrong.
Apps are React/Vite apps in apps/:
apps/
my-app/
package.json
vite.config.ts
src/
App.tsx
main.tsx
Use existing apps in apps/ as reference when building new ones.
fusebase dev start writes debug logs to <app-dir>/logs/. Tell Vite's file watcher to ignore this directory so it doesn't trigger unnecessary reloads:
// vite.config.ts
export default defineConfig({
server: {
watch: {
ignored: ['**/logs/**'],
},
},
// ...rest of config
});
Always include this in every app's vite.config.ts.
css.postcssDo NOT add a css.postcss block in vite.config.ts. An inline css config — even with an empty plugins array — overrides the external postcss.config.js entirely, silently disabling Tailwind and all other PostCSS plugins.
// ❌ BROKEN — overrides postcss.config.js, Tailwind never runs
export default defineConfig({
css: {
postcss: {
plugins: [],
},
},
});
// ✅ CORRECT — no css.postcss block; Vite picks up postcss.config.js automatically
export default defineConfig({
plugins: [react()],
// ...rest of config
});
PostCSS plugins (@tailwindcss/postcss, autoprefixer) belong in postcss.config.js only.
Apps may optionally include a backend/ subfolder for a backend API (REST + WebSockets). Do not add a backend unless the app genuinely needs backend logic — most apps work fine with the Dashboard SDK alone. See skill app-backend for when and how to add one. The backend is served at /api.
If your app needs to integrate with another Fusebase app in the same organization, do not treat that app as an external opaque service by default.
Use this workflow:
searchAppApiOperationslistAppApiOperationsgetAppApiOperationcallAppApi or direct runtime probing only if behavior still needs live verification.Avoid these dead ends as a first move:
If a published app API exists, it is the primary cross-app integration surface.
For security-sensitive operations, prefer contract-level app API policy:
x-fusebase-allowed-callers restricts the caller identity, e.g. client:<clientId>.x-fusebase-required-permissions restricts caller capability and must use the app API namespace app_api.<namespace>.<capability>.<action>, e.g. app_api.client_portal.provision.write.isolated_store.read for app-to-app operation authorization.<% if (it.flags?.includes("portal-specific-apps")) { %>
Apps run as the main window. The platform provides an app token via window.FBS_FEATURE_TOKEN (with fbsfeaturetoken cookie fallback when needed).
<% } else { %>
Apps run as the main window. The platform sets a fbsfeaturetoken cookie automatically.
<% } %>
Startup flow:
<% if (it.flags?.includes("portal-specific-apps")) { %>
window.FBS_FEATURE_TOKEN first; if missing, fall back to fbsfeaturetoken cookie
<% } else { %>fbsfeaturetoken cookie first, fall back to window.FBS_FEATURE_TOKEN if the cookie is absent
<% } %>x-app-feature-token for direct SDK / Fusebase proxy calls/api/*), rely on the same-origin cookie and make backend handlers read x-app-feature-token or fallback to fbsfeaturetokenAll apps MUST handle token expiration (AppTokenValidationError / 401). See skill handling-authentication-errors for the implementation pattern.
<% if (it.flags?.includes("portal-specific-apps")) { %> Fetch auth context:
type AuthContextResponse = {
user?: {
id: number
email: string
}
org?: {
globalId: string
}
runtimeContext?: {
portalId?: string
workspaceId?: string
}
}
const response = await fetch('https://app-api.{FUSEBASE_HOST}/v4/api/auth/context', {
headers: { 'x-app-feature-token': appToken },
})
const authContext: AuthContextResponse = response.ok ? await response.json() : {}
const user = authContext.user ?? null
// authenticated: { id: 4124, email: "[email protected]" }
// anonymous visitor on a public app: null (user field is missing)
// portal/workspace context (if available): authContext.runtimeContext
Important for public apps:
/auth/context returns no useruser means "not authenticated", not "session expired"/auth/context should not throw just because the visitor is anonymous
<% } else { %>
Fetch current user:const response = await fetch('https://app-api.{FUSEBASE_HOST}/v4/api/users/me', {
headers: { 'x-app-feature-token': appToken },
})
const user = response.ok ? await response.json() : null
// authenticated: { id: 4124, email: "[email protected]" }
// anonymous visitor on a public app: null
Important for public apps:
/users/me returns 401user: null, not as "session expired"
<% } %>AppTokenValidationError flowsSee skill handling-authentication-errors for the exact 401 handling rules.
Use standard browser navigation (React Router, etc.) since apps run as the main window. For routing setup, see skill app-routing.
Use shadcn/ui. For design and UX guidance (layout, tokens, components, accessibility), see skill app-ui-design.
cd apps/my-app
npm run build
If npm run build fails because vite/typescript are not found, npm may be running in production mode (NODE_ENV=production — common in VS Code / Claude Code). Fix:
npm install --include=dev
From the repo root, npm run typecheck runs tsc for each app (see root package.json). It catches strict TypeScript issues that ESLint does not, including the same failures as tsc inside fusebase deploy’s build. Claude Code Stop hooks run it after lint.
After creating an app, register it via fusebase app create from the project root:
fusebase app create --name <name> --subdomain <subdomain> --path <path> --dev-command <command> --build-command <command> --output-dir <dir>
Execute this command automatically after writing the app code — do not ask the user to run it manually.
Use --access to control who can access the app. Principals are comma-separated:
# Public (visitor) access
fusebase app create --name <name> --access=visitor
fusebase app update <appId> --access=visitor
# Org role access (guest, client, member, manager, owner)
fusebase app update <appId> --access=orgRole:member
fusebase app update <appId> --access=orgRole:member,orgRole:client
# Combine visitor and org roles
fusebase app update <appId> --access=visitor,orgRole:member
Use --permissions with fusebase app create when the app is first registered. Only use fusebase app update --permissions when changing permissions on an already-registered app. Use MCP to discover dashboard/view IDs. See skill fusebase-cli for permission format and examples.
fusebase app list
Lists all apps with their deployed URLs. Use this to get actual URLs — do NOT hardcode or guess them.
Always use the full subdomain URL (read FUSEBASE_APP_HOST from .env, e.g. https://my-app.{FUSEBASE_APP_HOST}/), never relative paths. Each app is served from its own subdomain root — see skill app-routing.
Use standard browser navigation (<a>, window.location) with full app URLs obtained from fusebase app list.
npx claudepluginhub fusebase-dev/fusebase-flow --plugin fusebase-flowCreates, edits, and verifies skills using a test-driven development approach with pressure scenarios and subagents.