Getting started
The fastest path to a running Grist widget is the Vite template. The rest of this page covers manual install and the smallest possible widget for readers who want to see the parts.
TL;DR
npx degit ArthurBlanchon/grist-widget-sdk/templates/grist-widget-template-vite my-widget
cd my-widget
pnpm install
pnpm devOpen the printed URL — you see a placeholder telling you Grist is not available, because the dev server is running outside a Grist iframe.
To connect it to a real Grist document:
- Run
pnpm build && pnpm preview(or deploy thedist/folder to any static host — Cloudflare Pages, Netlify, GitHub Pages, etc.). - In a Grist document, add a Custom Widget section, paste the public URL into the URL field, and press Save.
- The widget renders inside Grist with full plugin-api access.
You now have a running widget. See Raw plugin API vs SDK for why you use this package instead of calling grist directly. Skip ahead to the Cookbook for ten end-to-end recipes, the Cheat sheet for a one-page API reference, or Troubleshooting if something is misbehaving.
What the template gives you
The template scaffolded above ships with:
- A Vite + React 19 + TypeScript app skeleton.
grist-widget-sdkand its peer dependencies pre-installed.- The host script tag for
grist-plugin-api.jsalready wired inindex.html. - A single-file
App.tsxthat demonstrates the recommended provider + boundary +useGrist()pattern. - Tailwind CSS preconfigured so the widget is theme-aware out of the box (light / dark / system).
- A test setup using vitest + the SDK emulator.
See Templates for the full catalogue.
Manual install
Skip this section if you used the template.
pnpm add grist-widget-sdk
# or
npm install grist-widget-sdk
# or
yarn add grist-widget-sdkPeer dependencies:
react >=18
react-dom >=18Load the Grist plugin API
The SDK expects the global grist object at runtime. Add the script in your app shell:
<script src="https://docs.getgrist.com/grist-plugin-api.js"></script><!doctype html>
<html>
<head>
<script src="https://docs.getgrist.com/grist-plugin-api.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>import Script from "next/script"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Script src="https://docs.getgrist.com/grist-plugin-api.js" />
{children}
</body>
</html>
)
}Hello world
hello-world in the playground is the canonical minimal widget. It is type-checked on every pnpm --filter playground build:widgets run (root pnpm test includes that step).
import { useGrist, type UseGristOptions } from "grist-widget-sdk"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "read table",
}
export function WidgetApp() {
const w = useGrist()
const rowKey =
w.record && typeof w.record.id === "number" ? String(w.record.id) : w.mode
if (w.mode === "empty") return <p>Select a row.</p>
if (w.mode === "new-row") return <p>New row flow</p>
return <p key={rowKey}>Selected row #{String(w.record!.id)}</p>
}In your own Vite app, add GristWidgetProvider + GristBoundary around WidgetApp (the template does this in main.tsx; the playground does it in widget.html).
That's everything you need to:
- Wait for Grist to finish its handshake.
- Render a friendly fallback when the page is opened outside Grist.
- Render an error UI if anything goes wrong, with a retry button.
- Subscribe to the currently selected row.
- Switch between
empty/row/new-rowmodes.
What useGrist() returns
| Group | Properties |
|---|---|
| Status | status, isAvailable, isReady, error, reload() |
| Selection | record, records, mappedRecord, mode, mappings, columnMappingStatus, isNewRecord |
| Writes (records) | table, getTable(id), actionStatus, actionError |
| Writes (schema) | applyActions(actions) |
| Reads | fetchTable, fetchTableRows, fetchRow, fetchSelectedTable, fetchSelectedRecord, listTables, getDocName |
| Widget options | widgetOptions, getWidgetOptions, setWidgetOption, patchWidgetOptions, clearWidgetOptions |
| Linking | setCursorPosition, setLinkedRowSelection |
| Attachments / REST | getAttachmentUrl, fetchAttachmentBlob, fetchAttachmentBase64, getAccessToken, fetchWithAuth |
| Section API | configure, refreshMappings, currentTableId |
| Theme | theme ("light" | "dark" | null) |
See the API reference for every field.
Asking for write access and declared columns
declared-columns shows GRIST_OPTIONS.columns and columnMappingStatus:
import {
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "full",
columns: [
{ name: "Title", type: "Text" },
{ name: "Done", type: "Bool" },
{ name: "Tags", type: "ChoiceList", allowMultiple: true, optional: true },
],
}
export const WIDGET_METADATA = {
title: "Declared columns",
description:
"Shows GRIST_OPTIONS column declarations and mappedRecord after the user maps columns.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist()
if (!w.columnMappingStatus.ok) {
return (
<p className="p-4 text-sm">
Map columns in the widget panel:{" "}
{w.columnMappingStatus.missing.join(", ")}
</p>
)
}
if (w.mode === "empty") return <p className="p-4 text-sm">Select a row.</p>
return (
<pre className="overflow-auto p-4 text-xs">
{JSON.stringify(w.mappedRecord, null, 2)}
</pre>
)
}In the widget configuration panel, the user maps these logical names to real columns. You consume them via w.mappedRecord and w.mapBack(...) on writes — see Column mapping.
Run a write
mark-done — single-row table.update:
import {
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
import { Button } from "@/components/ui/button"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "full",
columns: [{ name: "Done", type: "Bool" }],
}
export const WIDGET_METADATA = {
title: "Mark done",
description: "Single-row write via table.update.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist()
if (!w.columnMappingStatus.ok) {
return <p className="p-4 text-sm">Map the Done column.</p>
}
if (w.mode !== "row" || !w.record) {
return <p className="p-4 text-sm">Select a row.</p>
}
async function onClick() {
await w.table.update({
id: w.record!.id as number,
fields: w.mapBack({ Done: true }),
})
}
return (
<div className="p-4">
<Button type="button" onClick={() => void onClick()}>
Mark done
</Button>
</div>
)
}For bulk writes, pass an array; the SDK forwards directly to Grist. See bulk-mark-done:
import {
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
import { Button } from "@/components/ui/button"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "full",
columns: [{ name: "Done", type: "Bool" }],
}
export const WIDGET_METADATA = {
title: "Bulk mark done",
description: "Bulk table.update with an array of row patches.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist()
if (!w.columnMappingStatus.ok) {
return <p className="p-4 text-sm">Map the Done column.</p>
}
async function markAll() {
if (!w.records) return
const updates = w.records.map((row) => ({
id: row.id as number,
fields: w.mapBack({ Done: true }),
}))
await w.table.update(updates)
}
return (
<div className="p-4">
<Button type="button" onClick={() => void markAll()}>
Mark all rows done
</Button>
</div>
)
}For schema changes, see schema-migration:
import {
gristAddVisibleColumnAction,
gristRemoveColumnAction,
gristRenameColumnAction,
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
import { Button } from "@/components/ui/button"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "full",
}
export const WIDGET_METADATA = {
title: "Schema migration",
description:
"applyActions with gristAddVisibleColumnAction, gristRenameColumnAction, and gristRemoveColumnAction.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist()
async function runMigration() {
const tableId = w.currentTableId
if (!tableId) return
await w.applyActions(
[
gristAddVisibleColumnAction(tableId, "Priority", { type: "Choice" }),
gristAddVisibleColumnAction(tableId, "DueAt", { type: "Date" }),
gristRenameColumnAction(tableId, "DueAt", "DueDate"),
],
{ desc: "Add Priority + DueAt; rename DueAt → DueDate" },
)
}
async function rollback() {
const tableId = w.currentTableId
if (!tableId) return
await w.applyActions(
[
gristRemoveColumnAction(tableId, "Priority"),
gristRemoveColumnAction(tableId, "DueDate"),
],
{ desc: "Remove Priority + DueDate" },
)
}
return (
<div className="flex gap-2 p-4">
<Button type="button" onClick={() => void runMigration()}>
Run migration
</Button>
<Button type="button" variant="destructive" onClick={() => void rollback()}>
Rollback
</Button>
{w.actionStatus === "running" ? (
<p className="mt-2 text-xs text-muted-foreground">Applying…</p>
) : null}
{w.actionError ? (
<p className="mt-2 text-xs text-destructive">{w.actionError}</p>
) : null}
</div>
)
}Where to go next
- Cookbook — ten end-to-end recipes for the most common widget shapes.
- Cheat sheet — one-page API reference for daily use.
- Troubleshooting — symptoms and fixes for the most common errors.
- Templates — every scaffold this repo ships.
- Core concepts — mental model behind selection modes, mappings, and the ready handshake.
- Demos — three live widgets you can paste straight into a Grist document.
- Testing —
renderWithGristand the emulator.