From airtable
Build custom React-based interface extensions for Airtable using @airtable/blocks/interface/ui SDK for apps, visualizations, and interactive tools beyond Interface Designer.
npx claudepluginhub ebbe-method/airtable-skills --plugin airtableThis skill uses the workspace's default tool permissions.
Build custom interface extensions (formerly "Blocks") for Airtable using React and TypeScript.
Assists with Airtable schema design, table and field creation, API interactions, scripting, interfaces, and automations. Use for bases, tables, fields, scripts, or automations.
Builds and reviews Wix CLI app extensions: dashboard pages, modals, plugins, menu plugins, custom element widgets, Editor React components, site plugins, embedded scripts, backend APIs, events, service plugins, data collections, and App Market readiness.
Integrates Power Pages Web API into frontend code sites for Dataverse tables, implementing API clients, CRUD operations, permissions setup, and deployment.
Share bugs, ideas, or general feedback.
Build custom interface extensions (formerly "Blocks") for Airtable using React and TypeScript.
Note: This skill is for developers building custom React apps. For no-code interfaces, use the main /airtable skill and Interface Designer.
This skill covers Interface Extensions, which use the
@airtable/blocks/interface/uiSDK. This is NOT the legacy Blocks SDK (@airtable/blocks/ui). The two have different import paths, different hooks, different entry point signatures, and interface extensions have no access to Airtable's built-in UI components (Box,Button,Input,TablePicker, etc.). Use plain HTML elements with Tailwind CSS for all UI.
Before building extensions, ensure you have:
npm install -g @airtable/blocks-cli
block init my-extension
cd my-extension
This creates a new extension project with:
my-extension/
├── frontend/
│ └── index.js # Main React component
├── package.json
└── block.json # Extension config
block run
Opens extension in development mode. Changes hot-reload.
block release
Packages and uploads to Airtable.
Extensions run in a sandboxed iframe inside Airtable. They can:
They cannot:
import { initializeBlock } from '@airtable/blocks/interface/ui';
import React from 'react';
function MyExtension() {
return <div>Hello, Airtable!</div>;
}
initializeBlock({ interface: () => <MyExtension /> });
For detailed API documentation:
import { useRecords, useBase } from '@airtable/blocks/interface/ui';
function RecordList() {
const base = useBase();
const table = base.getTableByIdIfExists('tblXXXXXXXXXX');
const records = useRecords(table);
const nameField = table?.getFieldIfExists('fldXXXXXXXXXX');
return (
<ul>
{records.map(record => (
<li key={record.id}>
{nameField ? record.getCellValueAsString(nameField) : record.name}
</li>
))}
</ul>
);
}
import { useBase } from '@airtable/blocks/interface/ui';
function UpdateButton({ recordId }: { recordId: string }) {
const base = useBase();
const table = base.getTableByIdIfExists('tblXXXXXXXXXX');
const handleClick = async () => {
if (!table) return;
if (table.hasPermissionToUpdateRecord(recordId, { 'fldXXXXXXXXXX': undefined })) {
await table.updateRecordAsync(recordId, {
'fldXXXXXXXXXX': { name: 'Complete' },
});
}
};
return <button onClick={handleClick}>Mark Complete</button>;
}
Interface Extensions do not include built-in UI components like Input, Button, or Box. Use plain HTML elements with Tailwind CSS for styling:
import { useState } from 'react';
function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
return (
<div className="flex gap-2">
<input
className="border rounded px-3 py-1.5 flex-1 dark:bg-gray-800 dark:text-white dark:border-gray-600"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<button
className="px-4 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600"
onClick={() => onSearch(query)}
>
Search
</button>
</div>
);
}
Interface Extensions use useCustomProperties instead of useGlobalConfig. Custom properties let interface builders configure your extension without editing code:
import { useCustomProperties, useBase } from '@airtable/blocks/interface/ui';
import { FieldType } from '@airtable/blocks/interface/models';
function getCustomProperties(base: Base) {
const table = base.tables[0];
return [
{
key: 'statusField',
label: 'Status Field',
type: 'field' as const,
table,
shouldFieldBeAllowed: (field: {id: string; config: {type: string}}) =>
field.config.type === FieldType.SINGLE_SELECT,
defaultValue: table?.fields.find(f => f.type === FieldType.SINGLE_SELECT),
},
];
}
function MyExtension() {
const { customPropertyValueByKey, errorState } = useCustomProperties(getCustomProperties);
const statusField = customPropertyValueByKey.statusField;
// Use statusField...
}
Build an extension when:
Use Interface Designer when:
Deploy to a single base. Only users of that base can use it.
Share across workspace bases. Requires admin approval.
Public distribution. Requires Airtable review.
// Console.log works - check browser devtools
console.log('Debug:', record.getCellValue('Name'));
// For async issues
try {
await table.updateRecordAsync(recordId, fields);
} catch (error) {
console.error('Update failed:', error);
}
Records are reactive - useRecords updates automatically when data changes. Don't manually poll.
Field access - Use field IDs, not field names. Field names can change; IDs are stable. Use table.getFieldIfExists(fieldId) and always check for null.
Permissions - Extension can only do what the current user can do. Check permissions before operations.
Custom properties - Use useCustomProperties for builder-configurable settings, not useGlobalConfig.
No server-side - The extension runtime is client-only. For server operations, use Airtable Automations or external webhooks.
Interface Extensions let you embed custom React apps directly into Airtable interfaces. Unlike regular Blocks SDK extensions (which live in a base-level sidebar), interface extensions are embedded as components within Interface Designer pages.
Plan requirement: Team, Business, or Enterprise.
| Feature | Blocks SDK | Interface Extensions SDK |
|---|---|---|
| Import path | @airtable/blocks/ui | @airtable/blocks/interface/ui |
| Where it runs | Base sidebar | Inside an interface page |
| Data access | All tables/fields in base | Only tables/fields enabled in interface Data panel |
| Multiple tables | Full base access | No native multi-table; use Web API as workaround |
| Package.json | @airtable/blocks | @airtable/blocks: interface-alpha |
// Interface Extensions SDK uses different import path!
import { initializeBlock, useBase, useRecords } from '@airtable/blocks/interface/ui';
// NOT this (old Blocks SDK):
// import { useBase, useRecords } from '@airtable/blocks/ui';
This is the #1 cause of "missing table" and "missing field" errors. Interface extensions can ONLY access tables and fields that are explicitly added to the interface's Data panel. This is different from the Blocks SDK, where your code can see the entire base.
Before writing any code or running block run, verify this configuration:
getTableByName or getTableById)The golden rule: If your code touches it, the interface must expose it.
Add this to your extension to see exactly what data is available:
function DataDiagnostic() {
const base = useBase();
return (
<div className="p-4">
<h3 className="text-sm font-bold mb-2">Available Data Sources</h3>
{base.tables.map(table => (
<div key={table.id} className="mt-2">
<p className="font-bold">{table.name} ({table.id})</p>
<div className="ml-4">
{table.fields.map(field => (
<p key={field.id} className="text-xs text-gray-500">
{field.name} ({field.id}) — {field.type}
</p>
))}
</div>
</div>
))}
</div>
);
}
If a table or field you expect is missing from this output, it hasn't been enabled in the interface Data panel.
Problem: Error: Field 'fldXXX' does not exist in table 'TableName' or your code can't find a table that definitely exists in the base.
Cause: Interface Extensions can only see tables and fields that are explicitly enabled in the interface's Data panel. This is the most common development gotcha.
Solution:
Important: Even if a field exists in the base, your extension can't access it unless it's enabled in the interface Data settings. This applies to ALL field access — reading, writing, and even checking if a field exists.
Pro tip: When adding new fields to your code, always add them to the interface Data panel FIRST, then update your code. Otherwise block run will crash with a confusing error.
Problem: You can only run your development block in the original base where it was created.
Cause: The dev server (block run) is tied to the specific base ID stored in .block/remote.json.
Solution:
.block/remote.json to verify the base ID matches where you're running itProblem: Interface Extensions SDK doesn't natively support accessing multiple tables.
Workaround: Use the Airtable Web API to read from other tables:
// Use fetch with the Airtable REST API for secondary tables.
// Store credentials (e.g. API token) via custom properties (string type),
// not hardcoded in source code.
const fetchLinkedRecords = async (baseId: string, tableId: string, apiToken: string) => {
const response = await fetch(
`https://api.airtable.com/v0/${baseId}/${tableId}`,
{ headers: { Authorization: `Bearer ${apiToken}` } }
);
return response.json();
};
block run) GuideThe block run command starts a local dev server that hot-reloads your extension code inside Airtable. It's powerful but can be fragile.
# Start the dev server
block run
# The server runs on localhost (usually port 9000)
# Airtable loads your code from this local server
After running block run:
block run CrashesThe dev server can crash during hot-reload, especially with:
Recovery steps:
Ctrl+C to kill the crashed server (if it's hung, Ctrl+C twice)block run againPrevention tips:
node_modules and reinstall:
rm -rf node_modules && npm install && block run
Chrome's strict security can block the local dev server:
Problem: "Connection error. Please check if your local block is running" — but the server IS running.
Cause: Chrome's CORS policy blocks localhost connections from Airtable's domain.
Solutions (try in order):
chrome://flags → search for "localhost" → enable "Allow invalid certificates for resources loaded from localhost"--disable-web-security using a separate profile:
# macOS
open -na "Google Chrome" --args --disable-web-security --user-data-dir=/tmp/chrome-dev
Only use this for development. Never browse normally with web security disabled.For quick prototyping, you can ask Airtable's Omni AI to generate a basic custom interface extension, then click "Edit Source" to modify the code directly — bypassing the CLI setup entirely. Good for:
Field names can change. Field IDs are stable. Always use IDs in production code:
// constants.js - Store field IDs
export const FIELD_IDS = {
EVENTS: {
NAME: 'fldb43P9bv3dqKPVO',
STATUS: 'fldyXuvmxMH8av5J9',
},
};
// helpers.js - Use IDs in data access
export function mapRecordToEvent(record) {
return {
id: record.id,
name: record.getCellValueAsString(FIELD_IDS.EVENTS.NAME),
status: record.getCellValue(FIELD_IDS.EVENTS.STATUS)?.name ?? 'Upcoming',
};
}
# Publish with a release comment
echo "Description of changes" | npx block release
# Or interactively
npx block release
# Then enter your comment when prompted
After publishing, users see the released version. Stop the dev server to test the published version yourself.
If you version your extension in git, you can set up a Claude Code hook to automatically run block release every time you push — so your Airtable extension stays in sync with your repo.
1. Create the hook script at ~/.claude/hooks/post-push-block-release.sh:
#!/bin/bash
# Post-Push Block Release Hook
# After a successful git push in an Airtable extension repo,
# automatically run `npx block release` to publish to Airtable.
# Set this to your extension repo path
EXTENSION_REPO="$HOME/path/to/your-extension"
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/"command"[[:space:]]*:[[:space:]]*"//' | sed 's/"$//')
# Only trigger on git push commands
if [[ "$COMMAND" != *"git push"* ]]; then
exit 0
fi
# Check if the push was in the extension repo
if [[ "$COMMAND" != *"$EXTENSION_REPO"* ]]; then
TOOL_CWD=$(echo "$INPUT" | grep -o '"cwd"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/"cwd"[[:space:]]*:[[:space:]]*"//' | sed 's/"$//')
if [[ "$TOOL_CWD" != "$EXTENSION_REPO"* ]]; then
exit 0
fi
fi
# Skip release if the push failed
OUTPUT=$(echo "$INPUT" | grep -o '"output"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1)
if echo "$OUTPUT" | grep -qiE '(rejected|failed|fatal|error|denied)'; then
echo "Git push failed. Skipping block release." >&2
exit 0
fi
echo "Running block release after git push..." >&2
cd "$EXTENSION_REPO"
RELEASE_OUTPUT=$(npx block release 2>&1)
if [ $? -eq 0 ]; then
echo "Block release successful! Extension published to Airtable."
else
echo "Block release failed. Run 'npx block release' manually."
echo "$RELEASE_OUTPUT" >&2
fi
exit 0
2. Make it executable:
chmod +x ~/.claude/hooks/post-push-block-release.sh
3. Add the hook to ~/.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/post-push-block-release.sh",
"timeout": 60
}
]
}
]
}
}
Now every git push in your extension repo will automatically publish to Airtable — one workflow, both destinations.