Skip to content

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

bash
npx degit ArthurBlanchon/grist-widget-sdk/templates/grist-widget-template-vite my-widget
cd my-widget
pnpm install
pnpm dev

Open 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:

  1. Run pnpm build && pnpm preview (or deploy the dist/ folder to any static host — Cloudflare Pages, Netlify, GitHub Pages, etc.).
  2. In a Grist document, add a Custom Widget section, paste the public URL into the URL field, and press Save.
  3. 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-sdk and its peer dependencies pre-installed.
  • The host script tag for grist-plugin-api.js already wired in index.html.
  • A single-file App.tsx that 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.

bash
pnpm add grist-widget-sdk
# or
npm install grist-widget-sdk
# or
yarn add grist-widget-sdk

Peer dependencies:

text
react       >=18
react-dom   >=18

Load the Grist plugin API

The SDK expects the global grist object at runtime. Add the script in your app shell:

html
<script src="https://docs.getgrist.com/grist-plugin-api.js"></script>
html
<!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>
tsx
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).

tsx
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-row modes.

What useGrist() returns

GroupProperties
Statusstatus, isAvailable, isReady, error, reload()
Selectionrecord, records, mappedRecord, mode, mappings, columnMappingStatus, isNewRecord
Writes (records)table, getTable(id), actionStatus, actionError
Writes (schema)applyActions(actions)
ReadsfetchTable, fetchTableRows, fetchRow, fetchSelectedTable, fetchSelectedRecord, listTables, getDocName
Widget optionswidgetOptions, getWidgetOptions, setWidgetOption, patchWidgetOptions, clearWidgetOptions
LinkingsetCursorPosition, setLinkedRowSelection
Attachments / RESTgetAttachmentUrl, fetchAttachmentBlob, fetchAttachmentBase64, getAccessToken, fetchWithAuth
Section APIconfigure, refreshMappings, currentTableId
Themetheme ("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:

tsx
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:

tsx
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:

tsx
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:

tsx
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.
  • TestingrenderWithGrist and the emulator.

Released under the ISC License.