From Turbofy MCP
Use when building or styling a UI section/component in a Turbofy app — navigation bars, hero banners, product grids, search boxes, forms, footers, cards, lists, filters, or any interactive page section. Triggers: 'build a navigation bar', 'create a product listing', 'style the hero', 'add a search box', 'make this section responsive', 'add dark mode', 'show loading/empty states', 'fetch data in this component', or when editing block-types/*/index.tsx. Covers React block components, translations/copies, navigation, data hooks, and UI/accessibility rules. For placing an existing section on a page, load turbofy-apps. For server-side data that feeds the section, load turbofy-dynamic-fields.
How this skill is triggered — by the user, by Claude, or both
Slash command
/turbofy:turbofy-blocksThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill covers the runtime React component of a Turbofy block type — the `block-types/<Name>/index.tsx` file inside a pulled app directory (`~/.turbofy/workspaces/<env>/<workspaceId>/apps/<appId>/`). The companion skills `turbofy-apps` (app workflow + data model) and `turbofy-dynamic-fields` (server-side `defaultConfig` / `defaultDynamicData` code) cover those areas.
This skill covers the runtime React component of a Turbofy block type — the block-types/<Name>/index.tsx file inside a pulled app directory (~/.turbofy/workspaces/<env>/<workspaceId>/apps/<appId>/). The companion skills turbofy-apps (app workflow + data model) and turbofy-dynamic-fields (server-side defaultConfig / defaultDynamicData code) cover those areas.
Load when the user wants to build or restyle a page section — e.g. "create a product grid", "add a sticky navbar", "make the form look better". Do not wait for them to mention index.tsx or block-type names.
export interface IBuildingBlockProps<TConfig = unknown> {
blockId: string;
locale: string;
config: TConfig;
dynamicData?: Record<string, unknown>;
searchParams: Record<string, string>;
params: Record<string, string>;
pageId: string;
slug: string[];
}
| Prop | Description |
|---|---|
blockId | Unique ID of this block instance. |
locale | Current language code (e.g. "en", "de"). Always read locale from this prop — never hardcode or derive it from the URL. |
config | Static block configuration (copies, links, layout). Copies live at config.copies — always read them from there. |
dynamicData | Optional server-resolved initial data snapshot. In SSR apps, use this for the initial render and use hooks for later interactions. Blocks that read it must handle undefined. |
searchParams | Current URL query parameters as key/value pairs. Always read query params from this prop — never parse window.location directly. |
params | Resolved entity IDs for dynamic slug segments (e.g. { articleId: "1234-..." }). Same values as $$args.params on the server side. |
slug | URL path segments after the locale prefix. |
pageId | ID of the page this block belongs to. |
Each block type lives in its own directory: block-types/{Name}/. The directory contains a record.ts manifest plus the optional runtime entry index.tsx (or index.ts) and any sibling files the block needs. Everything compiles to a single JS bundle for the iframe.
A block type can be sourceless — record.ts only, no index.tsx. This is valid and means the block has no runtime React component (e.g. a content-only or backend-driven block). Push silently skips compile/upload for sourceless block types.
block-types/{Name}/.record.ts — never imported by index.tsx.index.tsx (or index.ts). Sibling files within the directory may import each other relatively..ts, .tsx, .js, .jsx, .css, .json (excluding record.ts).On push, the directory is zipped (without record.ts) and uploaded as a .source.zip archive. On pull, archives are extracted back into the directory next to the freshly-decompiled record.ts.
block-types/{Name}/ may only import siblings within the same folder.record.ts from index.tsx — record.ts is build tooling, not runtime, and pulls in @graphapi-io/dsl-builders (which transitively pulls @graphapi-io/declarations) and is not bundleable.app-std-lib (React, zod, Tailwind, etc.).index.tsx or index.ts) must have a named export BuildingBlock.props.locale. Never create helper functions like getCurrentLocale(), getLocale(), detectLanguage(), or similar. Never derive locale from the URL, navigator.language, window.location.pathname, cookies, or any other source. The prop is the single source of truth.config.copies. Never hardcode strings inline (e.g. "Submit", "No results", "Back to home").
copies object inside config.copies.record.ts under the localizations field, keyed by language code (e.g. localizations: { en: { submit: "Submit" }, de: { submit: "Senden" } }). The dsl-builders auto-inject them into config.copies for the current locale at runtime — see the turbofy-apps skill's "Localization workflow".record.ts (use "" as a placeholder for missing translations). Turbofy_app_pull always seeds entries for every supported locale; never delete an entry just because it's empty.zod) — they cover the case where a key is missing from the locale dictionary.copies.title, copies.submitLabel, etc. Never create a translation helper function (t(), translate() or similar wrappers). Zod defaults handle missing keys — an extra abstraction is unnecessary.config.copies so translations can be added later without code changes.useTranslations hook.navigate from @/navigation for programmatic navigation — never window.location or raw <a> tags. Read current query parameters from props.searchParams, not from window.location.search.useLinks (client-side) and $$std.batchLink(...) (server-side) — these are the only ways to correctly resolve localized paths.dynamicData is optional. If a block uses dynamicData, it must render a loading skeleton when dynamicData is undefined. The skeleton must match the final component's footprint as closely as possible (same outer spacing, major grid/column structure, image aspect ratios, heading/text line widths, card counts where reasonable) so the block itself can be used as its Suspense fallback without causing layout jumps. Treat undefined as "loading"; treat null values inside dynamicData as "loaded but empty/not found."Example A — static page link (page slug config: { en: { slug: "/products" }, de: { slug: "/produkte" } }):
Resolve the link server-side in defaultConfig:
defaultConfig: `
const [productsPath] = $$std.batchLink([{ pageId: "${productsPage.id}" }]);
({ productsPath });
`,
copiesis auto-injected by the builder — don't add it yourself. See theturbofy-appsskill's "Auto-injectedcopies".
Then navigate in the component:
const { productsPath } = config;
// declarative
<Link to={productsPath}>{copies.productsLink}</Link>;
// imperative
navigate(productsPath);
Example B — dynamic page links for a list (page slug config: { en: { slug: "/products/[Product]" }, de: { slug: "/produkte/[Product]" }, paramsCollectionMap: { Product: { ofType: "abcdef" } } }):
Fetch the list client-side and resolve links with useLinks:
const { data: products } = useListTypes("abcdef");
const { result: productLinks } = useLinks(
(products ?? []).map((p) => ({
pageId: productPageId,
path: `/products/${p.id}`,
})),
);
// productLinks[i] is the resolved localized path for products[i]
Every block must be production-ready, pixel-perfect, responsive, and visually consistent. Follow these rules strictly. For common interactive primitives, prefer shadcn/ui over vanilla HTML-only implementations. Use native HTML as the semantic foundation, but default to shadcn components for authored UI unless there is a documented reason not to.
A clear type scale is the single biggest driver of visual quality. Follow a consistent hierarchy across all blocks:
| Role | Tailwind classes | Usage |
|---|---|---|
| Display / hero headline | text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight | One per block maximum. Landing heroes, splash sections. |
| Section heading (h2) | text-2xl md:text-3xl font-semibold tracking-tight | Primary heading inside a block. |
| Sub-heading (h3) | text-xl md:text-2xl font-semibold | Card titles, sub-sections. |
| Lead / intro paragraph | text-lg md:text-xl text-muted-foreground leading-relaxed | Short paragraph directly below a heading. |
| Body text | text-base leading-relaxed | Default prose, descriptions, list items. |
| Caption / helper | text-sm text-muted-foreground | Timestamps, labels, footnotes. |
| Overline / eyebrow | text-xs font-semibold uppercase tracking-widest text-primary | Category labels above headings. |
Rules:
leading-relaxed (1.625) or leading-7 on body text. Tight line heights hurt readability.max-w-prose (~65 characters) for comfortable reading.tracking-tight on headings ≥ text-2xl and tracking-normal or tracking-wide on small text.h1–h4) in order — never skip levels.Great design is mostly about what you leave empty. Use whitespace intentionally to group, separate, and guide the eye.
space-y-12 md:space-y-16 or equivalent margin. Tight sections look cramped.space-y-4), with larger gaps between unrelated groups.grid-cols-1 lg:grid-cols-2). Avoid centering everything — centered text works for short headings and CTAs, not for paragraphs.w-full) with inner content wrapped in max-w-7xl mx-auto.px-4 mobile, md:px-6 tablet, lg:px-8 desktop.16px and 40px.100vh, 100dvh, etc.) — blocks render inside iframes.grid-cols-*).w-full h-full).items-stretch on the grid container so cells auto-align.gap-6, gap-8).grid-cols-1 on mobile, distribute evenly (grid-cols-2, grid-cols-3, grid-cols-4) on larger screens.grid-cols-* must never exceed the number of elements shown.w-full h-full object-cover.aspect-video, aspect-square, etc.overflow-hidden when using aspect-* or rounded-* classes.Blocks should feel cohesive across an entire app, not like isolated islands with random colors.
primary theme color for CTAs and key interactive elements. Introduce at most one secondary accent (e.g. for badges, highlights). More than two accent colors creates visual noise.bg-background containing cards at bg-muted (or vice versa). Avoid flat, single-color layouts.bg-primary/5, bg-primary/10) rather than the full saturated color. Full saturation backgrounds make text hard to read and look dated.border-border or a neutral with low opacity (border-gray-200) — never primary or accent colors for structural dividers.text-foreground for primary text, text-muted-foreground for secondary text. Avoid raw gray values that might break under different themes.bg-background, text-foreground, bg-muted, text-muted-foreground, bg-card, border-border) rather than hardcoded colors like bg-white or text-gray-900. Semantic tokens adapt automatically when a dark theme is active.dark: variant (e.g. bg-white dark:bg-gray-900).shadow-lg card on a dark background looks broken. Use shadow-sm or shadow-none with a border on dark surfaces instead.#fff) text on dark backgrounds and pure black (#000) backgrounds — use gray-50/gray-100 for text and gray-900/gray-950 for surfaces to reduce eye strain.primary theme color as background with readable text on top (e.g. white text on a dark primary).primary color for borders and text, lower opacity or subtle tint.Link:<Button asChild>
<Link to={path}>{copies?.cta ?? "Learn more"}</Link>
</Button>
16px–40px.0px–32px (use rounded-lg / rounded-xl consistently).shadcn/ui accessibility patterns (focus states, ARIA roles).header, nav, main, section, footer).alt="". Informational images get descriptive alt text from copies.Subtle motion makes the difference between "functional" and "polished." Overdone motion makes it feel gimmicky.
transition-colors duration-200 to interactive elements (buttons, links, cards) for smooth hover/focus feedback.hover:shadow-md or hover:-translate-y-0.5 on clickable cards for a lift effect — pick one, not both.transition-all duration-300 ease-out.300ms. Anything slower feels sluggish.prefers-reduced-motion: wrap non-essential motion in motion-safe: (e.g. motion-safe:hover:-translate-y-0.5).A block that shows nothing while loading looks broken. Design for all data states:
animate-pulse on bg-muted rounded divs matching the dimensions of headings, text lines, and images.lucide-react for all icons.size-4 (16px) inline with text, size-5 (20px) in buttons, size-6 (24px) standalone or in nav.inline-flex items-center gap-2.text-muted-foreground for decorative/secondary icons, text-foreground or text-primary for actionable icons.aria-label.All
@/apihooks and$$stdhelpers require table IDs, not table names. For workspace schema tables, IDs are workspace-specific. If you've decompiled or initialized an app, the IDs should be in theschema.tsfile in each of the table builder options, or for new tables accessible withSomeTable.id. As a last resort, you can use theTurbofy_table_listMCP tool to find the correct ID. For system CMS tables (App/Page/BuildingBlock/BuildingBlockType/Localization/Image), IDs are stable and come fromCmsOfTypeEnum.*. Never hardcode a table name like"Product"where a table ID is expected.
| Tool | Where it runs | Reactive | Description |
|---|---|---|---|
config (dynamic field) | Server-side | No (resolved once at load) | Static configuration — copies, links, layout settings |
dynamicData (dynamic field) | Server-side | No (initial server-provided data snapshot) | Route/query-dependent initial data — collections, single records, aggregations, pagination |
useTypeQuery/useListTypes/useListTypesByParent | Client-side | Yes — automatically updates when data changes (e.g. after mutations via useCreateType) | Client-side data fetching with live updates |
useSearchTypes/searchTypes | Client-side | No (one-shot, re-fetches when query or fields change) | Full-text search across indexed fields on a table (requires workspace search to be enabled) |
useLinks/useTranslations/useFileDocuments | Client-side | No (one-shot, re-fetches when params change) | Resolving links, translations, and file documents for data fetched client-side |
useUploadFile | Client-side | Mutation | Uploading files (returns file document metadata) |
In each block-types/{Name}/record.ts, use defaultConfig for static server-side config and defaultDynamicData for route/query-dependent server-side data. Both are JavaScript code strings — see the turbofy-dynamic-fields skill for the full runtime model. Copies are auto-injected into defaultConfig (and into block-instance config) by the dsl-builders — your code only needs to return the rest of the configuration. The wrap is a hard lock: any copies key you write yourself is overridden by the runtime-resolved dictionary. Only set defaultConfig when you need extra static config beyond copies (links, layout settings).
// block-types/ProductDetail/record.ts — page slug: /[productId]
export const productDetailBlock = appBuilder.blockType({
id: "abc123",
name: "ProductDetail",
defaultDynamicData: `
const productId = $$args.params?.productId;
const product = $$std.getRecord('${ProductTable.id}', productId, { dynamicArgs: $$args });
({ product });
`,
localizations: {
en: { title: "Product details", notFound: "Product not found" },
de: { title: "", notFound: "" }, // empty placeholders — translate later
},
});
Per-instance overrides use config / dynamicData on appBuilder.block().
Override — replace the parent's dynamicData entirely:
appBuilder.block({
type: productDetailBlock,
dynamicData: `
const productId = $$args.params?.productId;
const product = $$std.getRecord('${ProductTable.id}', productId, {
fields: ['id', 'name', 'price', 'imageUrl'],
dynamicArgs: $$args,
});
({ product });
`,
});
Extend — inherit the parent's dynamicData and add extra fields using blockDynamicDataCode():
appBuilder.block({
type: productDetailBlock,
dynamicData: `
const parent = ${blockDynamicDataCode(BlockTypeTable.id)};
const reviews = $$std.listRecords('${ReviewTable.id}', { limit: 5 })
.filter(r => r.productId === parent?.product?.id);
({ ...parent, reviews });
`,
});
The right data-fetching strategy depends on whether the app behaves like a SPA (single-page application — behind a login, internal tool, dashboard) or an SSR app (server-rendered pages with optional client-side interactivity afterwards).
configconfig for the static part of the page: copies, links, layout settings, and other data that depends on $$args.lang.config as server-side setup for the initial render. Do not rely on route params there.copies is auto-injected:
defaultConfig: `({ columns: 3 });`;
dynamicDatadynamicData for server-side data that depends on $$args.params and $$args.searchParams.dynamicData is the right place for the initial route/query-dependent payload.config: use for static copies, links, and layout only.dynamicData: do not use.useTypeQuery, useListTypes, useListTypesByParent, useSearchTypes, and mutation hooks for all dynamic data.useLinks/useTranslations/useFileDocuments: use when links, translations, or file metadata depend on data fetched client-side.useUploadFile: use for file uploads.config: use for static data — copies, links, layout settings. Resolve links via $$std.batchLink(...) and translations via $$std.batchTranslate(...) server-side when you know them upfront.dynamicData: use for the initial route/query-dependent payload — single records, collections, aggregations, pagination cursors, and similar data derived from $$args.params / $$args.searchParams.useTypeQuery, useListTypes, useListTypesByParent, useSearchTypes, and mutation hooks for interactive behavior (search, filters, load more, form submissions).useLinks/useTranslations/useFileDocuments resolve links, translations, and file documents client-side. Use them when the underlying records were fetched client-side, or when an SSR app needs additional client-side resolution after the initial render. If you know the values upfront, prefer $$std.batchLink/$$std.batchTranslate in config/dynamicData instead — those resolve server-side and are faster.
import {
useLinks,
useTranslations,
useFileDocuments,
useListTypes,
useSearchTypes,
} from "@/api";
// Resolve links for records fetched client-side
const { data: products } = useListTypes(productTableId);
const { isPending: linksPending, result: links } = useLinks(
(products ?? []).map((p) => ({
pageId: productPageId,
path: `/products/${p.id}`,
})),
);
// Resolve translations client-side
const { isPending: translationsPending, result: translations } =
useTranslations([
{ id: "home-page", path: "title" },
{ id: "home-page", path: "subtitle" },
]);
// Resolve file documents client-side (pass file document IDs)
const fileDocIds = (products ?? [])
.map((p) => p.fileDocId as string)
.filter(Boolean);
const { isPending: fileDocsPending, result: fileDocuments } =
useFileDocuments(fileDocIds);
// fileDocuments: Array<{ id, name, key, mimeType, url, meta? } | null>
Arguments are deeply memoized — passing the same values will not trigger redundant requests.
useSearchTypes/searchTypes run a full-text search against a table's indexed search fields (GET /rest/search/:ofType). The workspace must have search enabled and fields configured for the target table (see data explorer search settings). There is no $$std.searchTypes server-side helper — use the client hook (or searchTypes() for imperative one-shot calls).
typeId — table ID to search (not the table name).query — search string. Must be non-empty (after trim) for a request to fire.fields — array of field names to search within. Must be non-empty for a request to fire.options.dynamicArgs (optional) — passed through to nested dynamic field evaluation (e.g. { lang: props.locale }).options.limit (optional) — max results (default 100).Return shape: { items: IGenericItem[]; total: number; error?: string }.
Hook return shape: { isPending: boolean; data: ISearchTypesResult | null; error: IApiError | null }. When search is disabled (query empty or fields empty), the hook returns { items: [], total: 0 } without an API call.
import { useSearchTypes } from "@/api";
// Reactive search — re-fetches when query or fields change
const { isPending, data, error } = useSearchTypes(
productTableId,
searchQuery,
["name", "description"],
{ dynamicArgs: { lang: locale }, limit: 50 },
);
const products = data?.items ?? [];
const total = data?.total ?? 0;
import { searchTypes } from "@/api";
// Imperative one-shot (e.g. on form submit)
const result = await searchTypes(productTableId, "widget", ["name", "sku"], {
dynamicArgs: { lang: locale },
});
useUploadFile uploads a file (uploadFile → S3 PUT) and returns the resulting file document metadata.
import { useUploadFile } from "@/api";
const {
mutateAsync: uploadFile,
isPending,
error,
data,
reset,
} = useUploadFile();
// Upload a file from a file input
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const result = await uploadFile({
file,
key: file.name, // storage key (will be normalized)
accessControl: "PUBLIC", // optional, defaults to "PUBLIC"
meta: { width: 800, height: 600 }, // optional metadata
});
// result: { id, name, key, mimeType, url }
};
configUse config for the static server-side part of the page:
$$args.lang — current languageDo not rely on route-dependent values such as $$args.params, $$args.slug, or $$args.searchParams in config.
dynamicDataUse dynamicData for route/query-dependent server-side data:
$$args.lang — current language$$args.slug — URL path segments as array (after the lang prefix)$$args.params — resolved entity IDs for dynamic slug segments (e.g. { articleId: "1234-..." })$$args.searchParams — current URL query parametersExtracting record IDs from slug segments: Use $$args.params (preferred) or fall back to parsing slug segments.
// Preferred: use resolved params (entity IDs already resolved from slugs)
const productId = $$args.params?.productId;
The same data is available via IBuildingBlockProps:
props.locale — same as $$args.langprops.slug — same as $$args.slugprops.params — same as $$args.paramsprops.searchParams — current URL query parameters (e.g. { page: "2", q: "shoes" })Always read these from props — never parse window.location directly.
block-types/ProductDetail/record.ts:
export const productDetailBlock = appBuilder.blockType({
name: "ProductDetail",
defaultDynamicData: `
const productId = $$args.params?.productId;
const product = $$std.getRecord('${ProductTable.id}', productId, { dynamicArgs: $$args });
({ product });
`,
});
block-types/ProductDetail/index.tsx:
import type { IBuildingBlockProps } from "../lib/types";
interface IConfig {
copies?: { title?: string; notFound?: string };
}
interface IDynamicData {
product: { id: string; name: string; price: number } | null;
}
const ProductDetailSkeleton = ({ title }: { title?: string }) => (
<section className="w-full bg-background px-4 py-10 md:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
<p className="text-sm font-medium text-muted-foreground">
{title}
</p>
<div className="mt-4 h-12 max-w-xl animate-pulse rounded bg-muted" />
<div className="mt-6 h-6 max-w-2xl animate-pulse rounded bg-muted" />
</div>
</section>
);
export const BuildingBlock = ({
config,
blockId,
locale,
dynamicData,
}: IBuildingBlockProps<IConfig>) => {
const data = dynamicData as IDynamicData | undefined;
const copies = config?.copies;
if (!data) {
return <ProductDetailSkeleton title={copies?.title} />;
}
if (!data?.product)
return <div>{copies?.notFound ?? "Product not found"}</div>;
return <h1>{data.product.name}</h1>;
};
block-types/ProductList/record.ts:
export const productListBlock = appBuilder.blockType({
name: "ProductList",
defaultDynamicData: `
const cursor = $$args.searchParams?.cursor;
const result = $$std.listRecords('${ProductTable.id}', { limit: 10, cursor }, true);
const currentPath = "/" + $$args.lang + "/" + $$args.slug.join("/");
const nextPageHref = result.nextToken
? currentPath + "?cursor=" + encodeURIComponent(result.nextToken)
: null;
({ products: result.items, nextPageHref });
`,
});
block-types/ProductList/index.tsx:
import { Link } from "@/navigation";
import type { IBuildingBlockProps } from "@/lib/types";
interface IConfig {
copies?: { title?: string };
}
interface IDynamicData {
products: Array<{ id: string; name: string }>;
nextPageHref?: string | null;
}
export const BuildingBlock = ({
config,
dynamicData,
}: IBuildingBlockProps<IConfig>) => {
const data = dynamicData as IDynamicData | undefined;
const copies = config?.copies;
if (!data) {
return (
<div>
<h1>{copies?.title ?? "Products"}</h1>
<ul>
{[0, 1, 2].map((item) => (
<li key={item} className="my-2 h-5 animate-pulse rounded bg-muted" />
))}
</ul>
</div>
);
}
return (
<div>
<h1>{copies?.title ?? "Products"}</h1>
<ul>
{data?.products?.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
{data?.nextPageHref && <Link to={data.nextPageHref}>Next page</Link>}
</div>
);
};
If the list needs client-side pagination, filtering, or refetching after the initial render, switch that interaction to useListTypes / useListTypesByParent rather than trying to refresh dynamicData.
Use useSearchTypes when the user types into a search box and you need results from the workspace search index — not for simple field filters (those belong in useListTypes with client-side filtering, or in dynamicData with $$std.listRecords).
block-types/ProductSearch/index.tsx:
import { useState } from "react";
import { useSearchTypes } from "@/api";
import type { IBuildingBlockProps } from "@/lib/types";
interface IConfig {
productTableId: string;
searchFields: string[];
copies?: { placeholder?: string; noResults?: string };
}
export const BuildingBlock = ({
config,
locale,
}: IBuildingBlockProps<IConfig>) => {
const [query, setQuery] = useState("");
const copies = config?.copies;
const { isPending, data } = useSearchTypes(
config.productTableId,
query,
config.searchFields,
{ dynamicArgs: { lang: locale }, limit: 20 },
);
const items = data?.items ?? [];
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={copies?.placeholder ?? "Search products"}
/>
{isPending && <div className="h-5 animate-pulse rounded bg-muted" />}
{!isPending && query.trim() && items.length === 0 && (
<p>{copies?.noResults ?? "No results"}</p>
)}
<ul>
{items.map((p) => (
<li key={p.id as string}>{p.name as string}</li>
))}
</ul>
</div>
);
};
Store searchFields in defaultConfig (static) or pass them from workspace schema knowledge. The field names must match those configured as searchable in the workspace.
block-types/ProductPage/record.ts:
export const productPageBlock = appBuilder.blockType({
name: "ProductPage",
defaultDynamicData: `
const productId = $$args.params?.productId;
const product = $$std.getRecord('${ProductTable.id}', productId, { dynamicArgs: $$args });
const manufacturer = $$std.getRecord('${ManufacturerTable.id}', product?.manufacturerId, { dynamicArgs: $$args });
const relatedProducts = $$std.listRecords('${ProductTable.id}', { limit: 5 })
.filter((p) => p.manufacturerId === product?.manufacturerId && p.id !== productId);
({ product, manufacturer, relatedProducts });
`,
});
block-types/ProductPage/index.tsx:
import type { IBuildingBlockProps } from "../lib/types";
interface IConfig {
copies?: { byLabel?: string; relatedTitle?: string };
}
interface IDynamicData {
product: { id: string; name: string; manufacturerId: string };
manufacturer: { id: string; name: string };
relatedProducts: Array<{ id: string; name: string }>;
}
export const BuildingBlock = ({
config,
blockId,
locale,
dynamicData,
}: IBuildingBlockProps<IConfig>) => {
const data = dynamicData as IDynamicData | undefined;
const copies = config?.copies;
if (!data) {
return (
<div>
<div className="h-10 max-w-lg animate-pulse rounded bg-muted" />
<div className="mt-4 h-6 max-w-sm animate-pulse rounded bg-muted" />
<div className="mt-8 space-y-3">
{[0, 1, 2].map((item) => (
<div key={item} className="h-5 animate-pulse rounded bg-muted" />
))}
</div>
</div>
);
}
if (!data?.product) return <div>Not found</div>;
return (
<div>
<h1>{data.product.name}</h1>
<p>
{copies?.byLabel ?? "By"} {data.manufacturer.name}
</p>
<h2>{copies?.relatedTitle ?? "Related"}</h2>
<ul>
{data.relatedProducts.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
};
block-types/QuickLookup/index.tsx:
import { useTypeQuery } from "@/api";
import type { IBuildingBlockProps } from "../lib/types";
interface IConfig {
productTableId: string;
productId: string;
}
export const BuildingBlock = ({
config,
locale,
}: IBuildingBlockProps<IConfig>) => {
const { data: product, isLoading } = useTypeQuery(
config.productTableId,
config.productId,
);
if (isLoading) return <div>Loading...</div>;
if (!product) return <div>Not found</div>;
return <h1>{product.name as string}</h1>;
};
No defaultDynamicData needed — the block fetches client-side. The query hooks are reactive: if another component creates/updates/deletes a record via useCreateType/useUpdateType/useDeleteType, all active useTypeQuery/useListTypes/useListTypesByParent subscriptions automatically receive the updated data. Use this pattern for SPA blocks and for post-load interactions in SSR apps.
Note: refetch() and fetchNextPage() on these hooks are fire-and-forget (() => void) — the updated data arrives reactively via the subscription, not as a return value.
useWsSubscription subscribes to real-time INSERT/UPDATE/DELETE events for a given table. Use it for imperative side-effects (toasts, cursor tracking, custom animations) and for real-time data sync in scenarios like chat, live feeds, or collaborative UIs where new records should appear immediately.
import { useWsSubscription } from "@/api";
import type { IWsEvent } from "@/api";
export const BuildingBlock = ({
config,
locale,
}: IBuildingBlockProps<IConfig>) => {
useWsSubscription(
{ ofType: config.productTableId, operations: ["INSERT", "UPDATE"] },
(event: IWsEvent) => {
// event.operation: "INSERT" | "UPDATE" | "DELETE"
// event.record: the affected record
console.log("Real-time event:", event.operation, event.record);
},
);
return <div>Listening for changes...</div>;
};
ofType is a table ID (not a table name) — same as all other hooks.operations (optional) filters which event types to receive. Omit to receive all three.WS_SUBSCRIBE message to the parent, which manages a shared WebSocket connection per workspace. Events are forwarded back as WS_EVENT messages.config static — use it for copies, layout, and links that depend on $$args.lang, not for route/query-dependent record lookups.dynamicData for the initial route/query-dependent payload — this avoids client round-trips on first render.useSearchTypes), filters, pagination, and other post-render interactions, switch to client hooks.config is only for upfront static copies/links.$$std.batchLink(...) and $$std.batchTranslate(...) in config/dynamicData. Use useLinks/useTranslations/useFileDocuments only when resolving for data fetched client-side.$$std.batchGetRecordsByInputs(...) over many $$std.getRecord(...) calls.config or dynamicData. If the underlying records are fetched client-side, resolve links with useLinks.$$std.batchLink(...) for page links — it resolves Page.localizedConfig.canonicalPath for many pages in one call.defaultConfig. In SSR apps, data-dependent links for the initial render can go in defaultDynamicData. For client-fetched data, use useLinks.@/navigation in source code — never raw <a> tags or window.location.$$std.batchLink(entries)$$std.batchLink(...) is the supported way to resolve page links. It fetches the target Page records and returns each page's resolved localizedConfig.canonicalPath.
Requirements:
$$args.lang must be present (e.g. "en")cmsOfTypes.page must be configured (must be a type ID; for system CMS pages use CmsOfTypeEnum.Page)Signature:
entries: Array<string | { pageId: string; path?: string; dynamicArgs?: object }>
{ pageId: entry }path is converted to slug segments and merged into dynamicArgs if dynamicArgs.slug is not providedArray<string | null> aligned to entries order// block-types/Navigation/record.ts
export const navBlock = appBuilder.blockType({
name: "Navigation",
defaultConfig: `
const [aboutPath, pricingPath] = $$std.batchLink([
{ pageId: "${aboutPage.id}" },
{ pageId: "${pricingPage.id}" },
]);
({ aboutPath, pricingPath });
`,
});
// block-types/ProductDetail/record.ts — productPage slug: /products/[productId]
export const productDetailBlock = appBuilder.blockType({
name: "ProductDetail",
defaultDynamicData: `
const productId = $$args.params?.productId;
const [productPath] = $$std.batchLink([
{ pageId: "${productPage.id}", path: \`/products/\${productId}\` },
]);
({ productPath });
`,
});
// block-types/ProductList/record.ts
export const productListBlock = appBuilder.blockType({
name: "ProductList",
defaultDynamicData: `
const products = $$std.listRecords('${ProductTable.id}', { limit: 50, dynamicArgs: $$args }) || [];
const paths = $$std.batchLink(products.map((p) => ({
pageId: "${productPage.id}",
path: \`/products/\${p.id}\`,
})));
const items = products.map((p, i) => ({ ...p, path: paths?.[i] ?? null }));
({ products: items });
`,
});
@/navigation in source codeAlways use Link and navigate from @/navigation — never raw <a> tags or window.location.
import { Link } from "@/navigation"; // declarative
import { navigate } from "@/navigation"; // imperative (e.g. after form submit)
Read locale, slug, and searchParams from IBuildingBlockProps — never parse window.location directly.
export const BuildingBlock = ({
config,
blockId,
locale,
slug,
searchParams,
dynamicData,
}: IBuildingBlockProps<IConfig>) => {
const data = dynamicData as IDynamicData | undefined;
const copies = config?.copies;
const handleNavigate = () => {
navigate(`/${locale}/products/${data?.product?.id}`);
};
return (
<div>
{config.aboutPath && (
<Link to={config.aboutPath}>{copies?.about ?? "About"}</Link>
)}
{data?.relatedProductPath && (
<Link to={data.relatedProductPath}>
{copies?.related ?? "Related product"}
</Link>
)}
<button onClick={handleNavigate}>{copies?.viewProduct ?? "View"}</button>
</div>
);
};
.tsx file or a directory with an index.tsx entry point. Files within a multi-file block directory can import each other, but blocks cannot import files outside their own block boundary or from other blocks.props.locale — never hardcode it, derive it from the URL, or use window.navigator.language. Never create getCurrentLocale(), getLocale(), or similar helper functions.config.copies — the dsl-builders auto-inject the resolved dictionary into config.copies. Provide zod-validated defaults for every copy key. Never hardcode user-visible strings inline — every label, heading, placeholder, button text, and error message must come from config.copies. Access keys directly (copies.title), never create t() / translate() / useTranslation() wrapper functions.navigate from @/navigation for programmatic navigation — never window.location or raw <a> tags. Read query parameters from props.searchParams.props.locale and props.slug (e.g. /${locale}/products/${id}). For server-resolved page links, use $$std.batchLink(...).Turbofy_app_push automatically compiles and typechecks blocks — you cannot skip this step.id in the DSL file are treated as new (create). Blocks with an id that no longer appear are deleted.$$self.someField, ensure the fetch includes it via dynamicArgs.fields.Turbofy_table_list.agent-browser skill.turbofy-platform — platform orientation, workspaces & environments, MCP tool surface, core rules, schema workflow, data-builder DSL.turbofy-apps — Apps CMS data model, Turbofy_app_* workflow, file layout, localization workflow, macros.turbofy-dynamic-fields — full $$std API reference, runtime model, reserved dynamicArgs keys, debugging checklist for dynamic-field code.npx claudepluginhub graphapi-io/turbofy-ai-plugin --plugin turbofyCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.