Skip to content
Flare 0.8.0: money, percent, rating, timezone, currency and seven more field types.See them all

The dashboard

Every Flare app ships a dashboard at /dashboard. It’s the whole signed-in area — your resources, the account pages, security and observability — and it’s plain source in your app under app/dashboard/ and components/dashboard/, built from shadcn/ui primitives. Nothing lives in an opaque package.

Generating a resource is all it takes for it to appear:

Terminal window
npx flare gen resource Contact --fields 'name:string, email:string!, status:enum(lead,customer)?'
npx flare seed:resource Contact --count 500

That writes the list, create and edit pages, and the sidebar picks the resource up from its descriptor.

Route
/dashboard Stats for every resource, what’s in the database, and what’s left to secure your account
/dashboard/<resource> The table: search, filters, sorting, selection, import and export
/dashboard/account Profile, password, two-factor, passkeys, connected accounts, signed-in devices
/dashboard/observability Traffic, errors, database size and the audit log
/dashboard/security Written by flare gen security: detectors, bans and events

Who sees what comes from the policies: the sidebar lists only the resources this person may read, and the sections that manage the app itself are for ADMIN_ROLES (lib/dashboard.ts).

Every page opens the same way, so they all line up:

<PageHeader
title="Contacts"
description="Everyone you're talking to."
crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Contacts" }]}
actions={<Button>New contact</Button>}
/>

Above each table, <ResourceStats resource={contact} /> shows the total, how many arrived this week and which way that’s moving, then a split by the resource’s own status field — its first filterable enum. <StatCards> takes any stats you want on a page of your own:

<StatCards
stats={[
{ label: "Open deals", value: 42, change: 12.5, icon: HandshakeIcon },
{ label: "Overdue", value: 3, goodDirection: "down", hint: "needs chasing" },
]}
/>

<ResourceTable resource searchParams> is a server component with all of its state in the URL — page, sort, q, filter[field] — so a view can be linked, bookmarked and shared.

  • Columns — every field with list !== false, plus Created.
  • Cells — enums render as a <Badge> toned by statusTone() (paid → success, pending → warning, cancelled → danger); belongsTo shows the related record’s title; emails, phones and URLs are links; numbers are right-aligned and tabular-nums.
  • Toolbar — search, plus a filter per enum and boolean field.
  • Selection — tick rows to export or delete them together. Selection is per page on purpose: a “select all 40,000” checkbox reads as harmless right up until it isn’t, so to act on more than a page, filter down to what you mean first.
  • Empty and error states — clear the filters, or create the first record; an invalid URL parameter falls back to its default with a notice rather than erroring.

The Export button writes a CSV of everything matching the current search, filters and sort, not just the page on screen — the server reads it through the same store the table uses, so the file holds exactly what that person is allowed to see. It stops at 50,000 rows and says so.

Import CSV reads a file in the browser, matches its headers to the resource’s fields (you can correct any of them), previews the first rows, then sends them in batches of 500. Each row goes through the same validation as the form and the API, so a bad row is reported with its line number and the rest still import. When some fail, “Download failed rows” gives you a CSV of just those, with a column saying why.

Records are created and edited in a sheet over the list, so you keep the list, its filters and your place in it. A form is a column of fields, and a panel down the side has room for them without squeezing them into a box. Long forms read better on their own page — switch in lib/site.ts:

export const site = {
dashboard: {
forms: "sheet", // or "page"
},
};

Both use the same <ResourceForm>: one input per stored field from the descriptor, labels above (never floating), one column on narrow screens and two on wide, long text spanning both, errors inline under the field and never toast-only. It validates with the descriptor’s Zod schemas in the browser first (focusing the first invalid field), then a server action validates again through the same store the REST API uses — including unique-constraint conflicts, which land back on the field that caused them.

More than five stored fields and the form splits into steps, with a progress bar and Back/Next. Each step is validated before you can leave it, so the first problem appears while you’re still on the field that caused it rather than after the whole form is filled in. Submitting from the last step validates everything and jumps back to whichever step holds a problem.

Five is the line where a form starts reading as a chore. Fields are cut in declaration order, so the order in the descriptor is the order people meet them.

A unique string field with no format — a SKU, a slug, a reference — gets a Generate button beside the input. It builds a code from the record’s title field (“Brushed steel desk lamp” gives BRU-4821, or brushed-steel-desk-lamp for a slug), and whatever it suggests can be typed over. Email and URL fields are unique too, and inventing one would be nonsense, so they don’t get it.

int and float inputs are shown grouped and stored plain: type 2000 into a price and it reads 2,000, while the form, the validators and the API all still see 2000.

components/dashboard/fields/: text, email, URL, textarea, numbers, switch, select (with a “None” option when the field is optional), radio group, multi-select, colour, slug, a searchable country picker, a phone input with dial codes, date and datetime pickers (values stay in UTC, so a date-only value never shifts a day across timezones), a relation picker (searches the target resource’s REST API, stores the id, shows its title), and the file upload widget.

/dashboard/observability answers “how is this app doing?” in one page:

  • Traffic, errors and CPU time from Cloudflare’s GraphQL Analytics API. It needs a token, and the page says so until it has one:

    Terminal window
    npx wrangler secret put CLOUDFLARE_ACCOUNT_ID
    npx wrangler secret put CLOUDFLARE_API_TOKEN # Account Analytics: Read
  • Database — how many records each resource holds.

  • Audit log — every create, edit, delete, import and bulk delete made through the dashboard, with who did it and when. It records only the fields that changed, never whole records: an audit trail that copies every row is a second database of the same data, and somewhere for secrets to leak into.

Write your own entries with the same helper:

import { recordAudit } from "@/lib/audit";
await recordAudit({ action: "update", resource: "Invoice", recordId: id, changes: { status: { from: "draft", to: "sent" } } });

The header’s toggle writes a flare-theme cookie (light, dark or system). A small inline script in the root layout reads it before first paint, so there’s no flash of the wrong theme. The root layout deliberately doesn’t read cookies() on the server — that would make every page beneath it dynamic (see Caching) — while the dashboard layout does, only to give the toggle its initial state. The whole dashboard follows the app’s theme.

Every dashboard component is copied into your app rather than imported from a package, so changing how a cell renders, or adding a column the descriptor doesn’t know about, is an ordinary edit to components/dashboard/resource-table.tsx. If a file still carries a // generated:start marker, edits inside that block are reported as drift by flare sync-types — that’s the check doing its job, not a reason to avoid the edit.

The repo’s style guide has the visual language every one of these components follows.