The Basics
Block’s source code is a TypeScript file with a default-exported React component:shadcn/ui
shadcn/ui components are already pre-configured and follow your app’s theme out of the box. They’re available under the@/components/ui path, so you can import them like this:
accordion, alert, alert-dialog, aspect-ratio, avatar, badge, button, calendar, card, carousel, chart, checkbox, collapsible, command, context-menu, dialog, drawer, dropdown-menu, empty, hover-card, input, input-group, input-otp, item, kbd, label, menubar, native-select, navigation-menu, pagination, popover, progress, radio-group, resizable, scroll-area, select, separator, sheet, skeleton, slider, sonner, spinner, switch, table, tabs, textarea, toggle, toggle-group, tooltip
Check out the shadcn/ui docs for usage details and examples for each component.
Icons
Preferred icon pack is Lucide Icons, but you can opt for a different one.Styling
We follow the default shadcn/ui naming convention for background/foreground color pairs (e.g.bg-primary / text-primary-foreground) which maps to your app’s theme colors.
By default, block occupies full width of the page but special classes - container and content are available to constrain the width of content to match app’s max width settings to ensure visual consistency with other blocks:
Data from Your Datasource
Import data hooks from@/lib/datasource. Use these when you want to display records from your connected data source. All data fetching hooks follow a query builder pattern so you can be quite expressive with your queries.
One or many datasources
A block can connect to multiple datasources. When it does, every fetch or mutation needs to say which datasource it targets. Thedatasource.define utility gives you readable aliases for that. Declare it once at the top of the file, then pass the alias as from on each hook:
datasource.define and omit from. The hooks then default to that one datasource. As soon as a block has more than one, leaving out from throws an error, since the hook can’t tell which datasource the call belongs to.
Every record hook takes from the same way: useRecords, useRecord, useLinkedRecords, useFieldOptions, useMetric, useChartData, useRecordCreate, useRecordUpdate, and useRecordDelete. useUpload and useCurrentRecordId work at the app level and thus from is not applicable to them.
Defining a select query
Field mappings have to be static, we also use static analysis to determine which fields your block actually uses so we don’t overfetch and don’t accidentally expose potentially sensitive data.For **Airtable, Notion, and Google Sheets ** datasources do not use field IDs in this context — use the field name as the value instead (e.g.
title: "Name" rather than title: "fldXXXXXX").useRecords — fetch a list of records
useRecord — fetch a single record by ID
Use with useCurrentRecordId() to display details of the record currently shown in a list/detail context:
Filtering records
Use theq query builder for filters. Filters support up to 2 levels of nesting.
Sorting records
useLinkedRecords — fetch options from a linked table
Use for dropdowns, comboboxes, or tag pickers where you need to show values from a related table:
id and title (the linked table’s primary field). To read other fields off those records, connect the linked table as its own datasource and query it with useRecords. See One or many datasources.
Mutating Records
All mutation hooks expose anenabled boolean — always check it before rendering the mutation UI or calling the function. It reflects whether the current user has sufficient permissions. If called without checking enabled, the mutation will throw an error.
useRecordCreate
useRecordUpdate
useRecordDelete
Phone fields
When writing to aPHONE field, the value must be in unformatted international format: + followed by digits only (e.g. +12125550100). Some datasources (such as Monday.com) reject values that contain spaces, dashes, or parentheses. Strip all non-digit characters except the leading + before submitting:
Uploading Files
UseuseUpload from @/lib/datasource to upload files and get back a URL to store in a record.
Current User
Get info about the logged-in user withuseCurrentUser from @/lib/user. Returns null if no user is logged in.
id:string | null(only present when user sync is enabled)fullName:string | nullfirstName:string | nulllastName:string | nullemail:string | nullavatar:string | null
Custom user properties
Beyond the reserved fields above, any custom fields that exist on your user record are available underuser.properties. Pass a properties map to alias each field to a readable name, the same way a select query works.
Metrics & Charts
useMetric — single aggregated value
Useful for KPI cards (total sales, average rating, etc.):
metric.sum(field), metric.avg(field), metric.max(field), metric.min(field), metric.distinct(field), metric.count()
useChartData — grouped data for charts
Comments
Comments live on records. They’re set up once for the whole app in Settings → Comments. The hooks in@/lib/comments give you one thread per record — you lay it out, the app owns the data, the copy and the permissions.
Don’t build your own thread on top of a datasource field. Comments written that way are invisible to every other block in the app, and to the builder’s moderation tools.
useCommentsSettings
Every label, placeholder, empty-state message and the sort direction comes from here. A builder edits them in Settings → Comments and expects the change to show up right away, with no republish. So read them from the hook — don’t hardcode strings.
Don’t expose them as useTextSetting either. That’s a second source of truth, and it quietly overrides the app’s settings.
displayAs picks which settings you get. Settings → Comments has two separate sets of display options, one per layout. Pass "BLOCK" for a thread in the block’s own flow, "SIDE_PANEL" for a docked panel. Pass the wrong one and the builder’s edits look like they do nothing.
Keep the field names when you destructure. commentListTitle is the “Comment list title” setting, submitButtonLabel is “Submit button label”. Rename it to title and the link to the control the builder edits is gone.
null means render no comments UI at all. You get it when the app has no comment settings, when that table isn’t registered for comments, or when the viewer can’t see the thread. It’s not an error, and it’s not a reason to fall back to something of your own.
@/lib/comments builds the thread itself: useComments, useCommentCreate, useCommentDelete, useCommentReactions, useCommentComposer, useMentionSuggestions and useCommentSubscription.
Editable Settings
Editable settings let builders modify block content through the editor UI (Content → Settings tab) without touching code. Always use them for any text, images, icons, colors, or lists that might change between block instances. Import from@/lib/editable-settings.
useTextSetting
Returns a string. Use for titles, descriptions, button labels, URLs, etc.
useImageSetting
Returns { src: string; alt: string }.
useVideoSetting
Returns { src: string }.
useVibeCodingBlockIconSetting
Returns { icon: string } where icon is a lucide-react icon name. Render it with the DynamicIcon component.
useNavigationSetting
Returns one of:
NavigationAction or navigate().
initialValue sets the starting point, and takes a looser shape than what you get back:
- Link:
{ destination, action?, openIn?, modalSize?, modalType? }.actioncomes fromdestination— starts with/meansOPEN_PAGE, anything elseOPEN_URL.openIndefaults toTAB. - Chat or workflow:
{ action: "OPEN_CHAT" }or{ action: "TRIGGER_CUSTOM_WORKFLOW" }. The builder picks which action it points at, so there’s nothing else to set.
NavigationAction component also accepts optional recordId?: string and datasourceId?: string props. When rendering record-specific links/actions, pass both the current record ID and the datasource it belongs to. datasourceId is required whenever recordId is present — it tells the runtime which datasource the selected record comes from. Also, in the case of a link, it dynamically adds a URL parameter to the final URL ?recordId=<id>.
useNavigation
Some things aren’t links — a chart segment, a table row, a countdown, a form that just submitted. For those, call navigate() from useNavigation. It takes the same setting NavigationAction takes and opens it the same way, so the builder’s choice still applies.
Call it once per block. The setting is an argument, so one navigate handles every destination.
recordId and datasourceId as NavigationAction, for a destination that points at one record:
navigate() does, per setting:
recordId gets added to page and URL destinations as ?recordId=<id>. It’s skipped for #section, mailto:, tel: and sms: destinations — nowhere to put it.
If the setting points at an action the builder deleted or hid, navigate() does nothing. No error, no toast, so don’t bother guarding the call.
Use NavigationAction whenever a real link will do. An anchor gives you middle-click, “open in new tab” and the browser’s hover preview. A click handler loses all three. And don’t fall back to window.open or window.location — those hardcode the destination, so the builder can’t change it anymore.
useColorSetting
Returns a CSS color string. Use for a color the builder picks — title color, button background, etc. It shows up as a color picker in the Content tab.
initialValue is a theme color name or a hex ("#1A2B3C"). Default is "foreground". Theme names:
background, foreground, card, card-foreground, popover, popover-foreground, primary, primary-foreground, secondary, secondary-foreground, muted, muted-foreground, accent, accent-foreground, destructive, border, input, ring
style, not a Tailwind class — classes like text-[...] are built at compile time and won’t pick up the builder’s color.
Pick the initialValue that matches the element’s current look, so nothing changes until the builder edits it: text-foreground → "foreground", bg-primary → "primary".
useArraySetting
Returns an array of items with a consistent shape. Use for feature lists, team members, FAQs, testimonials, etc.
useBooleanSetting
Returns a boolean. Use for toggles, switches, show/hide elements, etc.
"text", "image", "video", "vibeCodingBlockIcon", "color"
A "color" field comes back as a CSS color, same as useColorSetting.
Constraints:
- Schema cannot contain nested arrays — for list-like text, use a
"text"field with a separator (e.g. comma) and split it in code - Do not put a
vibeCodingBlockIconfield as the first field in the schema - Calling two settings hooks with the same
nameis not allowed
Complete Example — Feature Showcase
A full-featured block combining editable settings, datasource records, and shadcn/ui:Fetching field options
UseuseFieldOptions to fetch available options for SELECT or multi-select fields. This is useful for building filters, dropdowns, badges, or any UI that needs to display the available choices from the datasource without hardcoding them. Like the other data hooks, it accepts a from alias (useFieldOptions({ from: ds.orders, select, field })), required when the block has multiple datasources.
Example to build a filter UI using useFieldOptions:
Fetching from a REST API
When using a REST API as a datasource, call it withuseProxyFetch from @/lib/datasource. The proxy attaches authentication for you, so don’t send tokens, API keys, or auth headers yourself.
useProxyFetch returns a function with the same signature as fetch. TanStack Query (or a similar data-fetching library) pairs nicely with it for caching and request state, and we strongly recommend it over fetching inside a useEffect.
Reading data
Mutating data
Wrap writes inuseMutation and invalidate the affected queries on success so the UI refetches:
Multiple datasources
When a block has more than one datasource,useProxyFetch needs to know which one to route the request to, so pass the datasource alias as its argument. With a single datasource you can call useProxyFetch() with no argument; once there’s more than one, leaving it out throws an error. Define the aliases with datasource.define, the same pattern the record hooks use: