This guide is for developers who want to write code directly in the Vibe Coding Block, rather than prompting the AI. Use it when you need full control over your block’s markup, logic, and data.
The Basics
Block’s source code is a TypeScript file with a default-exported React component:
export default function Block() {
return (
<div className="container">
<div className="content">
<h1 className="text-4xl font-extrabold tracking-tight text-balance">
Hello, world!
</h1>
</div>
</div>
);
}
This only runs in the browser - you can fetch and mutate data from connected data sources, but you cannot run server-side code or use Node.js APIs.
Vibe coding block is configured to use Tailwind for styling and shadcn/ui for components, but you are free to import any public npm package as needed. In fact, any npm package import you add will automatically install it for you.
import { format } from "date-fns"; // will be resolved at compile time
export default function Block() {
const today = format(new Date(), "MMMM d, yyyy");
return <div>Today's date is {today}</div>;
}
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:
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
Currently the following components are available:
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.
import { TrendingUp, User, Settings } from "lucide-react";
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:
<div className="container">
<div className="content">
{/* your content here */}
</div>
</div>
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. The datasource.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:
import { datasource, useRecords, q } from "@/lib/datasource";
const ds = datasource.define({
orders: "ds_id_1",
customers: "ds_id_2",
});
const ordersSelect = q.select({ product: "field1", total: "field2" });
const customersSelect = q.select({ name: "field3", email: "field4" });
export default function Block() {
const { data: orders } = useRecords({ from: ds.orders, select: ordersSelect });
const { data: customers } = useRecords({ from: ds.customers, select: customersSelect });
// ...
}
With a single datasource you can skip 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.
import { q } from "@/lib/datasource";
const select = q.select({
title: "FIELD_ID1", // key = alias used in code, value = actual field ID
description: "FIELD_ID2",
createdAt: "FIELD_ID3",
});
So something like this is not allowed as it breaks static analysis:
const select = q.select({
[dynamicKey]: "FIELD_ID1", // ❌ dynamic keys not allowed
title: getFieldId(), // ❌ dynamic values not allowed
});
useRecords — fetch a list of records
import { useRecords, q } from "@/lib/datasource";
export default function Block() {
const {
data, // array of pages with shape { items: Record[]; total: number; offset: string | number | null }
status, // "pending" | "success" | "error"
error, // Error object if status is "error"
fetchNextPage, // function to fetch the next page of results
hasNextPage, // boolean indicating if there are more pages to fetch
isFetching, // boolean indicating if any page is currently being fetched
isFetchingNextPage, // boolean indicating if the next page is currently being fetched
refetch, // function to refetch the data (e.g. after a mutation)
isRefetching, // boolean indicating if a refetch is currently in progress
isRefetchError, // boolean indicating if the last refetch resulted in an error
} = useRecords({
select: q.select({
name: "FIELD_ID1",
email: "FIELD_ID2",
}),
count: 6, // records per page, default 6, max 100
// from: ds.orders, // datasource alias, required when the block has multiple datasources
// where: ..., // optional filter
// orderBy: ..., // optional sort
// enabled: ..., // optional boolean to defer loading (e.g. until component is visible)
});
if (status === "pending") return <div>Loading...</div>;
if (status === "error") return <div>Error loading data</div>;
const items = data.pages.flatMap(page => page.items);
return (
<div className="container py-8">
<div className="content space-y-4">
{items.map((item) => (
<div key={item.id}>
<p className="font-semibold">{item.fields.name}</p>
<p className="text-muted-foreground">{item.fields.email}</p>
</div>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetching}>
{isFetchingNextPage ? "Loading..." : "Load More"}
</button>
)}
</div>
</div>
);
}
useRecord — fetch a single record by ID
Use with useCurrentRecordId() to display details of the record currently shown in a list/detail context:
import { useRecord, useCurrentRecordId, q } from "@/lib/datasource";
export default function Block() {
const recordId = useCurrentRecordId(); // resolves the ID of the current record from page, can also be null
const { data, status } = useRecord({
select: q.select({
title: "FIELD_ID1",
description: "FIELD_ID2",
}),
recordId,
// from: ds.orders, // datasource alias, required when the block has multiple datasources
});
if (status === "pending") return <div>Loading...</div>;
if (status === "error") return <div>Error</div>;
if (!data) return <div>Not found</div>;
return (
<div className="container py-8">
<div className="content">
<h1>{data.fields.title}</h1>
<p>{data.fields.description}</p>
</div>
</div>
);
}
Filtering records
Use the q query builder for filters. Filters support up to 2 levels of nesting.
useRecords({
select,
where: q.and(
q.text("name").contains("Alice"),
q.number("age").gte(18),
q.or(
q.boolean("isActive").is(true),
q.text("notes").isNotEmpty()
)
),
});
Available filter methods:
| Builder | Methods |
|---|
q.text(field) | is, isNot, contains, startsWith, endsWith, isOneOf, isNoneOf, hasAllOf, isEmpty, isNotEmpty |
q.number(field) | is, isNot, gt, gte, lt, lte, between, isEmpty, isNotEmpty |
q.boolean(field) | is, isNot, isEmpty, isNotEmpty |
q.date(field) | is, isNot, gt, gte, lt, lte, between, isNotBetween, isEmpty, isNotEmpty |
q.array(field) | is, isOneOf, isNoneOf, hasAllOf, isEmpty, isNotEmpty |
q.and(...) | combine filters with AND |
q.or(...) | combine filters with OR |
Sorting records
useRecords({
select,
orderBy: q.desc("createdAt"), // or q.asc("createdAt")
});
// Multiple sort fields (tie-breakers):
useRecords({
select,
orderBy: [q.asc("lastName"), q.asc("firstName")],
});
useLinkedRecords — fetch options from a linked table
Use for dropdowns, comboboxes, or tag pickers where you need to show values from a related table:
import { q, useLinkedRecords } from "@/lib/datasource";
export default function Block() {
const {
data,
status,
error,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
refetch,
isRefetching,
isRefetchError,
} = useLinkedRecords({
select: q.select({ category: "$CATEGORY_FIELD_ID" }),
field: "category", // alias from select that holds the linked field
sortOrder: "ASC", // "ASC" | "DESC"
search: "", // optional search term
enabled: true, // set false to defer loading (e.g. until dropdown opens)
// from: ds.orders, // datasource alias, required when the block has multiple datasources
// count: ..., // optional page size, defaults to 100, max 1000
});
const options = data?.pages.flatMap(p => p.items) ?? [];
return (
<div>
{options.map(opt => (
<span key={opt.id}>{opt.title}</span>
))}
</div>
);
}
Each item only carries its 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 an enabled 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
import { useRecordCreate, q } from "@/lib/datasource";
import { toast } from "sonner";
export default function Block() {
const {
enabled, // boolean indicating if the user has permission to create records
mutate, // void function to create a record, accepts an object with shape from provided fields, in this case - `{ name: string; email: string }`
mutateAsync, // async version of mutate that returns a promise with the created record
status, // "idle" | "pending" | "success" | "error"
error, // Error object if status is "error"
reset, // function to reset the status and error state back to "idle" and null, useful for showing multiple create forms in a row
} = useRecordCreate({
fields: q.select({
name: "FIELD_ID1",
email: "FIELD_ID2",
}),
onSuccess: (newRecord) => toast.success("Created!"),
onError: (error) => toast.error(error.message),
// from: ds.orders, // datasource alias, required when the block has multiple datasources
});
return (
<div>
{enabled && (
<button
onClick={() => mutate({ name: "Jane", email: "jane@example.com" })}
disabled={status === "pending"}
>
{status === "pending" ? "Saving..." : "Add Record"}
</button>
)}
</div>
);
}
useRecordUpdate
import { useRecordUpdate, q } from "@/lib/datasource";
export default function Block() {
const { data, refetch } = useRecords({ ... });
const {
enabled,
mutate,
mutateAsync,
status,
error,
reset,
} = useRecordUpdate({
fields: q.select({ status: "FIELD_ID1" }),
onSuccess: async (updatedRecord) => {
// refetch the data first to ensure the UI shows the latest value before notifying the user.
await refetch();
toast.success("Updated!");
},
onError: (error) => toast.error(error.message),
// from: ds.orders, // datasource alias, required when the block has multiple datasources
});
const onUpdateClick = () => {
if (!enabled) return;
mutate({
recordId: "RECORD_ID", // ID of the record to update
fields: { status: "active" }, // updated field values
});
};
}
useRecordDelete
import { useRecordDelete } from "@/lib/datasource";
export default function Block() {
const {
enabled,
mutate,
mutateAsync,
status,
error,
reset,
} = useRecordDelete({
onSuccess: async ({ recordId }) => {
await refetch();
toast.success("Deleted!");
},
onError: (error) => toast.error(error.message),
// from: ds.orders, // datasource alias, required when the block has multiple datasources
});
const onDeleteClick = () => {
if (!enabled) return;
mutate("RECORD_ID"); // ID of the record to delete
};
}
Uploading Files
Use useUpload from @/lib/datasource to upload files and get back a URL to store in a record.
import { useUpload, useRecordCreate, q } from "@/lib/datasource";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useState } from "react";
const fields = q.select({
name: "$NAME_FIELD_ID",
attachment: "$ATTACHMENT_FIELD_ID",
});
export default function Block() {
const [name, setName] = useState("");
const [file, setFile] = useState<File | null>(null);
const { uploadAsync, isUploading } = useUpload();
const createRecord = useRecordCreate({
fields,
onSuccess: () => toast.success("Submitted!"),
});
const handleSubmit = async () => {
if (!file) return;
const [result] = await uploadAsync(file);
if (result.status === "completed") {
createRecord.mutate({
name,
attachment: { filename: result.file.name, url: result.url },
});
} else {
toast.error(result.error?.message ?? "Upload failed");
}
};
return (
<div className="container py-6">
<div className="content space-y-4 max-w-md">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
<Input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<Button onClick={handleSubmit} disabled={isUploading || !file}>
{isUploading ? "Uploading..." : "Submit"}
</Button>
</div>
</div>
);
}
For multiple files:
const results = await uploadAsync(Array.from(e.target.files));
const completed = results.filter(r => r.status === "completed");
Current User
Get info about the logged-in user with useCurrentUser from @/lib/user. Returns null if no user is logged in.
import { useCurrentUser } from "@/lib/user";
export default function Block() {
const user = useCurrentUser();
if (!user) return <div>Please log in to continue.</div>;
return (
<div className="container py-8">
<div className="content flex items-center gap-4">
{user.avatar && (
<img src={user.avatar} alt={user.fullName ?? ""} className="w-10 h-10 rounded-full" />
)}
<div>
<p className="font-semibold">{user.fullName}</p>
<p className="text-muted-foreground text-sm">{user.email}</p>
</div>
</div>
</div>
);
}
Available fields:
id: string | null (only present when user sync is enabled)
fullName: string | null
firstName: string | null
lastName: string | null
email: string | null
avatar: string | null
Custom user properties
Beyond the reserved fields above, any custom fields that exist on your user record are available under user.properties. Pass a properties map to alias each field to a readable name, the same way a select query works.
import { useCurrentUser } from "@/lib/user";
export default function Block() {
const user = useCurrentUser({
properties: {
stripeId: "FIELD_ID1",
plan: "FIELD_ID2",
},
});
if (!user) return <div>Please log in to continue.</div>;
return (
<div className="container py-8">
<div className="content">
<p className="font-semibold">Welcome, {user.fullName}!</p>
{user.properties.stripeId && <p className="text-muted-foreground text-sm">Stripe ID: {user.properties.stripeId}</p>}
{user.properties.plan && <p className="text-muted-foreground text-sm">Your plan: {user.properties.plan}</p>}
</div>
</div>
);
}
Metrics & Charts
useMetric — single aggregated value
Useful for KPI cards (total sales, average rating, etc.):
import { useMetric, q, metric } from "@/lib/datasource";
export default function Block() {
const { data, status } = useMetric({
select: q.select({ revenue: "$REVENUE_FIELD_ID" }),
metric: metric.sum("revenue"),
// from: ds.orders, // datasource alias, required when the block has multiple datasources
// where: q.date("createdAt").gte("2025-01-01"),
});
if (status === "pending") return <div>Loading...</div>;
return <div className="text-4xl font-bold">${data?.toFixed(2)}</div>;
}
Aggregations: metric.sum(field), metric.avg(field), metric.max(field), metric.min(field), metric.distinct(field), metric.count()
useChartData — grouped data for charts
import { useChartData, q, metric } from "@/lib/datasource";
import { LineChart, Line, XAxis, CartesianGrid } from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart";
const chartConfig = {
revenue: { label: "Revenue", color: "var(--chart-1)" },
} satisfies ChartConfig;
export default function Block() {
const { data, status } = useChartData({
select: q.select({
date: "$DATE_FIELD_ID",
revenue: "$REVENUE_FIELD_ID",
}),
orderBy: q.asc("date"),
metric: { revenue: metric.sum("revenue") },
groupBy: metric.groupBy("date", metric.bucket.month.long),
// from: ds.orders, // datasource alias, required when the block has multiple datasources
});
if (status === "pending") return <div>Loading...</div>;
return (
<div className="container py-8">
<div className="content">
<ChartContainer config={chartConfig}>
<LineChart data={data} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip content={<ChartTooltipContent />} />
<Line dataKey="revenue" stroke="var(--color-revenue)" strokeWidth={2} dot={false} />
</LineChart>
</ChartContainer>
</div>
</div>
);
}
Grouping buckets:
| Bucket | Format example |
|---|
metric.bucket.year | 2025 |
metric.bucket.month.iso | "2025-03" |
metric.bucket.month.long | "March 2025" |
metric.bucket.day.iso | "2025-03-15" |
metric.bucket.day.long | "Mar 15, 2025" |
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, 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.
import { useTextSetting } from "@/lib/editable-settings";
export default function Block() {
const title = useTextSetting({
name: "title", // unique identifier — changing this resets the value
label: "Title", // shown in the editor UI
initialValue: "Welcome", // starting value
required: false, // optional, default false
});
return <h1>{title}</h1>;
}
useImageSetting
Returns { src: string; alt: string }.
import { useImageSetting } from "@/lib/editable-settings";
export default function Block() {
const image = useImageSetting({
name: "hero-image",
label: "Hero Image",
initialValue: {
src: "https://images.unsplash.com/photo-...",
alt: "A hero image",
},
});
return <img src={image.src} alt={image.alt} className="w-full rounded-lg" />;
}
useVideoSetting
Returns { src: string }.
import { useVideoSetting } from "@/lib/editable-settings";
export default function Block() {
const video = useVideoSetting({
name: "intro-video",
label: "Intro Video",
initialValue: { src: "https://example.com/video.mp4" },
});
return <video src={video.src} controls className="w-full" />;
}
useVibeCodingBlockIconSetting
Returns { icon: string } where icon is a lucide-react icon name. Render it with the DynamicIcon component.
import { useVibeCodingBlockIconSetting } from "@/lib/editable-settings";
import { DynamicIcon } from "@/components/dynamic-icon";
export default function Block() {
const { icon } = useVibeCodingBlockIconSetting({
name: "feature-icon",
label: "Feature Icon",
initialValue: { icon: "trending-up" },
});
return <DynamicIcon name={icon} className="w-6 h-6" />;
}
useNavigationSetting
Returns { action: "OPEN_URL" | "OPEN_PAGE"; destination: string; openIn: "SELF" | "TAB" } | { action: "OPEN_CHAT" } | { action: "TRIGGER_CUSTOM_WORKFLOW" }.
import { NavigationAction } from "@/components/navigation-action";
import { Button } from "@/components/ui/button";
import { useNavigationSetting } from "@/lib/editable-settings";
export default function Block() {
const navigation = useNavigationSetting({
name: "cta-navigation",
label: "CTA Navigation",
initialValue: {
action: "OPEN_PAGE",
destination: "/pricing",
openIn: "TAB",
},
});
return (
<Button asChild>
<NavigationAction navigation={navigation}>
Click me!
</NavigationAction>
</Button>
);
}
The NavigationAction component also accepts an optional recordId?: string prop. When rendering record-specific links in a list/loop, pass the current record ID so it can be dynamically added as a URL parameter to the final URL ?recordId=<id>.
<NavigationAction navigation={detailsLink} recordId={item.id}>
View details
</NavigationAction>
useArraySetting
Returns an array of items with a consistent shape. Use for feature lists, team members, FAQs, testimonials, etc.
import { useArraySetting } from "@/lib/editable-settings";
import { DynamicIcon } from "@/components/dynamic-icon";
export default function Block() {
const features = useArraySetting({
name: "features",
label: "Features",
schema: {
title: { type: "text", label: "Title", initialValue: "Feature" },
description: { type: "text", label: "Description" },
icon: { type: "vibeCodingBlockIcon", label: "Icon" },
image: { type: "image", label: "Image" },
},
initialValue: [
{
title: "Fast",
description: "Blazing fast performance.",
icon: { icon: "zap" },
image: { src: "", alt: "" },
},
{
title: "Reliable",
description: "99.9% uptime guaranteed.",
icon: { icon: "shield" },
image: { src: "", alt: "" },
},
],
});
return (
<div className="container py-12">
<div className="content grid md:grid-cols-2 gap-6">
{features.map((feature, index) => (
<div key={index} className="flex gap-4">
<DynamicIcon name={feature.icon.icon} className="w-6 h-6 text-primary" />
<div>
<h3 className="font-semibold">{feature.title}</h3>
<p className="text-muted-foreground text-sm">{feature.description}</p>
</div>
</div>
))}
</div>
</div>
);
}
useBooleanSetting
Returns a boolean. Use for toggles, switches, show/hide elements, etc.
import { useBooleanSetting } from "@/lib/editable-settings";
export default function Block() {
const showHeader = useBooleanSetting({
name: "toggleHeader", // unique identifier — changing this resets the value
label: "Toggle header", // shown in the editor UI
initialValue: false, // starting value (default is true)
});
return <>{showHeader && <Header />}</>;
}
Schema field types: "text", "image", "video", "vibeCodingBlockIcon"
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
vibeCodingBlockIcon field as the first field in the schema
- Calling two settings hooks with the same
name is not allowed
Complete Example — Feature Showcase
A full-featured block combining editable settings, datasource records, and shadcn/ui:
import { useTextSetting, useArraySetting } from "@/lib/editable-settings";
import { useRecords, q } from "@/lib/datasource";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { DynamicIcon } from "@/components/dynamic-icon";
const select = q.select({
name: "$PRODUCT_NAME_FIELD",
category: "$CATEGORY_FIELD",
price: "$PRICE_FIELD",
});
export default function Block() {
// Editable settings for the section header
const heading = useTextSetting({
name: "heading",
label: "Section heading",
initialValue: "Our Products",
});
const subheading = useTextSetting({
name: "subheading",
label: "Section subheading",
initialValue: "Explore our latest offerings",
});
// Live records from datasource
const { data, status, hasNextPage, fetchNextPage, isFetching } = useRecords({
select,
count: 6,
orderBy: q.asc("name"),
});
const items = data?.pages.flatMap(p => p.items) ?? [];
return (
<div className="container py-12">
<div className="content">
<div className="text-center mb-10">
<h2 className="text-3xl font-heading font-bold">{heading}</h2>
<p className="text-muted-foreground mt-2">{subheading}</p>
</div>
{status === "pending" && (
<div className="flex justify-center py-12">
<div className="text-muted-foreground">Loading...</div>
</div>
)}
{status === "error" && (
<div className="text-destructive text-center">Failed to load products.</div>
)}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{items.map((item) => (
<Card key={item.id}>
<CardHeader>
<Badge variant="secondary" className="w-fit">
{item.fields.category?.label ?? "Uncategorized"}
</Badge>
<CardTitle className="mt-2">{item.fields.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
${(item.fields.price as number)?.toFixed(2)}
</p>
</CardContent>
</Card>
))}
</div>
{hasNextPage && (
<div className="flex justify-center mt-8">
<button
onClick={() => fetchNextPage()}
disabled={isFetching}
className="text-primary underline underline-offset-4 hover:no-underline"
>
{isFetching ? "Loading..." : "Load more"}
</button>
</div>
)}
</div>
</div>
);
}
Fetching field options
Use useFieldOptions 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:
import { useState } from "react";
import { q, useRecords, useFieldOptions } from "@/lib/datasource";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const select = q.select({
title: "$TITLE_FIELD_ID",
productStatus: "$PRODUCT_STATUS_FIELD_ID",
});
export default function Block() {
const [selectedProductStatus, setSelectedProductStatus] = useState<string | null>(null);
const { options } = useFieldOptions({ select, field: "productStatus" });
const { data, status } = useRecords({
select,
where: selectedProductStatus ? q.text("productStatus").is(selectedProductStatus) : undefined,
count: 10,
});
const items = data?.pages.flatMap((p) => p.items) ?? [];
return (
<div className="container py-6">
<div className="content space-y-4">
<Select
value={selectedProductStatus ?? "all"}
onValueChange={(val) => setSelectedProductStatus(val === "all" ? null : val)}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="Filter by product status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Product Statuses</SelectItem>
{options.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{status === "pending" && <div>Loading...</div>}
{items.map((item) => (
<div key={item.id}>
<div>{item.fields.title}</div>
<div>{item.fields.productStatus?.label || "Unknown"}</div>
</div>
))}
</div>
</div>
);
}
Fetching from a REST API
When using a REST API as a datasource, call it with useProxyFetch 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.
Caveats: the proxy currently only supports text payloads. Streams, FormData, and file uploads won’t work.
Reading data
import { useQuery } from "@tanstack/react-query";
import { useProxyFetch } from "@/lib/datasource";
export default function Block() {
const proxyFetch = useProxyFetch();
const { data, status, error } = useQuery({
queryKey: ["products"],
queryFn: async () => {
const res = await proxyFetch("https://api.example.com/products");
if (!res.ok) throw new Error("Failed to fetch products");
return res.json();
},
});
if (status === "pending") return <div>Loading...</div>;
if (status === "error") return <div>{error.message}</div>;
return (
<div className="container py-6">
<div className="content space-y-2">
{data.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
</div>
);
}
Mutating data
Wrap writes in useMutation and invalidate the affected queries on success so the UI refetches:
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useProxyFetch } from "@/lib/datasource";
import { toast } from "sonner";
function useCreateProduct() {
const proxyFetch = useProxyFetch();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { name: string; price: number }) => {
const res = await proxyFetch("https://api.example.com/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Failed to create product");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
toast.success("Created");
},
});
}
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:
import { useQuery } from "@tanstack/react-query";
import { datasource, useProxyFetch } from "@/lib/datasource";
const ds = datasource.define({
store: "ds_id_1",
});
function useProducts() {
const proxyFetch = useProxyFetch(ds.store);
return useQuery({
queryKey: ["products"],
queryFn: async () => {
const res = await proxyFetch("https://api.example.com/products");
if (!res.ok) throw new Error("Failed to fetch products");
return res.json();
},
});
}