Documentation Index
Fetch the complete documentation index at: https://softr-2b8a27e1-main-se-18622.mintlify.app/llms.txt
Use this file to discover all available pages before exploring further.
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.
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
// 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,
});
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)
// 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>
);
}
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),
});
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),
});
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),
});
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
email: string | null
avatar: string | null
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"),
// 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),
});
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>
);
}
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.
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>
);
}