@nkootstra/block-kit renders Slack Block Kit JSON in React, pixel-for-pixel the way Slack does. Pass it the blocks your app sends to chat.postMessage or views.open and it draws the message, modal or Home tab, with working buttons, menus, date pickers and inputs.
Documentation: https://docs.block-kit.dev
To try it without installing anything, paste your JSON into the Block Kit playground: it renders the message, modal or Home tab with this package and shows the payload each click sends. No Slack sign-in.
The package needs React 18 or later.
bun add @nkootstra/block-kit @fontsource/lato @fontsource/roboto-monoImport the stylesheet and fonts once, near the root of your app, then render a message:
import "@nkootstra/block-kit/styles.css";
import "@fontsource/lato/400.css";
import "@fontsource/lato/700.css";
import { BlockKitProvider, Message } from "@nkootstra/block-kit";
export function App() {
return (
<BlockKitProvider>
<Message
blocks={[
{ type: "section", text: { type: "mrkdwn", text: "*Hello* from Block Kit :wave:" } },
]}
/>
</BlockKitProvider>
);
}The installation guide lists the remaining font weights and the entry points, and the quickstart wires up an interactive approval message.
| Import | What it contains |
|---|---|
@nkootstra/block-kit |
Components, the provider, hooks and payload builders |
@nkootstra/block-kit/styles.css |
The stylesheet |
@nkootstra/block-kit/mrkdwn |
The mrkdwn parser, without React |
@nkootstra/block-kit/transport |
Browser-safe: sends interactions to your app, and resolver lookups to your server |
@nkootstra/block-kit/server |
Server only, development tooling: signs and relays interactions, and answers resolver lookups with the bot token |
@nkootstra/block-kit/web-api |
User, channel, usergroup and profile resolvers backed by the Slack Web API |
This is a Bun workspace run with Turborepo.
| Path | What it is |
|---|---|
packages/block-kit |
The published package |
apps/docs |
The documentation site, built with Blume |
apps/site |
The landing page for block-kit.dev, built with Astro |
apps/playground |
The playground at play.block-kit.dev, and the visual harness's render page |
fixtures |
Block Kit Builder payloads with Slack's reference renders |
tools/visual |
The harness that pixel-compares every fixture against its Slack reference |
tools/react-18 |
Runs the package's tests and type-check against React 18 |
tools/deploy |
Wrangler for the deploy and preview jobs, outside the workspaces (below) |
examples |
Runnable apps that keep the Slack bot token on the server (Next.js, Vite + Hono) |
bun install
bun run build
bun run testbun run lint, bun run format:check and bun run type-check run the remaining checks CI runs. bun run compare renders every fixture in the playground and reports how far each one drifts from Slack; see tools/visual.
CONTRIBUTING.md covers the rules a pull request has to follow.
To work on the landing page, build the package once and start Astro's dev server:
bunx turbo run build --filter=@nkootstra/block-kit
cd apps/site && bun run devIts live demos render with the package, and its theme toggle sets data-theme on <html>, which the package's stylesheet follows too. The "Check it against Slack" images in apps/site/src/assets/proof are the message/approval output of bun run compare -- message/approval --scale=2; the page quotes how many pixels differ, so recount it when they're recaptured. Its social card, /og.png, is drawn at build time by src/pages/og.png.ts (Satori and resvg) from the same headline and description as the page, in src/lib/site.ts; open it in the dev server to preview it.
To work on the docs, build the package once and start Blume's dev server:
bunx turbo run build --filter=@nkootstra/block-kit
cd apps/docs && bun run devThe docs are a static site on Cloudflare Workers Static Assets, served at docs.block-kit.dev, with the _headers file Blume writes for the raw Markdown endpoints. A small Worker, apps/docs/worker/index.ts, runs first on page routes, the .md copies and the docs API to negotiate with agents: Accept: text/markdown gets a page's Markdown copy, a missing page or .md URL gets /404.md with a 404 when Markdown was asked for, and JSON requests and the docs API get the /404.json problem document. Build assets and the other raw files skip the Worker. The Worker also handles the banner in blume.config.ts that says the docs moved: it keeps it only on block-kit.kootstra.io, linked to the same page on docs.block-kit.dev, and removes it everywhere else.
The changelog at /changelog comes from the GitHub releases: Blume's githubReleases() source in blume.config.ts turns each release into a page when the docs build. CI passes its token as GITHUB_TOKEN, because GitHub rate-limits anonymous requests. Without the token, a failed request falls back to Blume's cache, or to an empty changelog and a warning when there is none. After publishing, the Release workflow runs CI on main again, so the docs rebuild with the new release.
The Worker also serves the docs' MCP server at /mcp. Blume only generates its MCP server on a server build, and its Cloudflare server build is too large to deploy (haydenbleasel/blume#322). So after blume build, apps/docs/scripts/mcp.ts uses Blume's own builders to do two things:
- Write the snapshot the server answers from to
dist/mcp-data.json, which.assetsignorekeeps out of the public files. - Add the server to the discovery files:
/.well-known/mcp.json,/.well-known/mcp/server-card.json,llms.txt,agent-readability.json, the API and AI catalogs, and the site skill Blume generates at/skill.md, with the skills index and its digests.
The Worker bundles the snapshot and answers with Blume's MCP handler, so a deploy needs the dependencies installed.
Every page, the home page included, carries a machine-readable date: Blume puts dateModified in each page's JSON-LD (a WebPage node on the home page) and wraps the "Last updated on" date in <time datetime>, so search engines don't date a page from a <time> inside a preview.
Blume's page script gives every .prose pre a "Copy code" button when the page loads, including the <pre> a preview renders for a preformatted block. Previews are React islands that hydrate later, so the extra button made React discard their server HTML (error #418 on /blocks/rich-text). patches/blume@2.2.0.patch makes the script skip a <pre> inside an island, and apps/docs/components/blume-copy-buttons.test.ts fails if an upgrade drops it. Regenerate the patch with bun patch blume when upgrading Blume.
The docs deploy with releases, never on a plain push to main, so they don't describe a package npm doesn't have yet:
- After publishing, the Release workflow runs
.github/workflows/ci.ymlon the newvX.Y.Ztag withdeploy. Thecheckjob builds the whole workspace, docs included (its changelog now shows the release), and uploadsapps/docs/distas an artifact. - Once every check passes, the
deploy-docsjob asksscripts/deploy-guard.tswhether to deploy: only the latest release's tag does, so re-running an older release's CI can't replace newer docs. It then downloads that build, installs the dependencies the Worker bundles and Wrangler, and runswrangler deployinapps/docs.
For a docs-only fix between releases, run Actions β CI β Run workflow on main with deploy checked. The guard deploys only main's head, and refuses when packages/block-kit changed since the latest release: release those changes instead, which deploys the docs too.
block-kit.dev is the static astro build of apps/site on Workers Static Assets (apps/site/wrangler.jsonc). A small Worker, apps/site/worker/index.ts, runs first on page routes for agents, as the docs' does: Accept: text/markdown on the home page gets /index.md, and a missing page answers 404 with /404.md to Markdown requests and an RFC 9457 problem document (/404.json) to JSON ones. All three are built from src/lib/markdown.ts. Browsers get the HTML, and every page response varies on Accept. Its live demos render with the package, so it deploys with releases too, by the same deploy CI run and the same guard, in the deploy-site job. It uses the same Cloudflare secrets as the docs.
play.block-kit.dev is the static vite build of apps/playground on Workers Static Assets (apps/playground/wrangler.jsonc), with no Worker script. It renders with the package, so it deploys with releases too, by the same deploy CI run and the same guard, in the deploy-playground job, with the same Cloudflare secrets. The build ships only the curated examples in src/examples.ts and offers no "Deliver to your app": the interaction relay that signs and forwards interactions exists only in the dev server. The build also renders the default example into index.html (src/prerender.tsx), so the page paints before its JavaScript arrives and hydrates when it does; the editor and the example picker's combobox load when they're first needed.
A pull request that affects the docs gets a preview on a workers.dev URL when a maintainer comments /preview on it (.github/workflows/docs-preview.yml):
- CI builds the docs when the change affects them and uploads the build as
docs-preview. That run has no secrets, also for pull requests from forks. - When CI passes, the workflow updates its comment on the pull request: the build is ready, and a maintainer can comment
/preview. - A comment that is exactly
/previewstarts the deploy. The workflow checks the commenter's repository role with the collaborators API and accepts only admin, maintain or write (scripts/preview-command.ts); the comment'sauthor_associationisn't enough, since it says how someone relates to the repository, not whether they may push. Anyone else, and any bot, gets a reply that only maintainers can deploy previews. It reacts π when it starts, π once deployed and π when it refuses or fails. - It deploys the pull request's current head, using the build of CI's successful run for that commit, as the Workers Preview
pr-<number>withwrangler preview, and updates the comment with the URL and commit. If CI hasn't finished yet, it replies to comment again once it's green. A new push needs a new/preview. - Closing the pull request deletes the Preview.
The deploy takes only the built files from the pull request; the Worker and wrangler.jsonc come from main, so a pull request can't change what runs with the Cloudflare token. Changes to the Worker therefore show up in previews once they merge. Previews set ROBOTS (previews.vars in wrangler.jsonc), which makes the Worker send X-Robots-Tag: noindex.
apps/docs/wrangler.jsonc names the Worker block-kit-docs, serves dist/, answers unknown paths with the built 404.html, and attaches the custom domains: docs.block-kit.dev, and the old block-kit.kootstra.io, which serves the same site until its visitors are moved over. deployment.site in apps/docs/blume.config.ts holds https://docs.block-kit.dev, because Blume builds canonical links, the sitemap and Open Graph images from it, so pages on the old domain name the new one as canonical. Change both if the domain moves.
-
Make sure the
block-kit.devandkootstra.iozones are on the Cloudflare account you deploy to. The first deploy creates thedocs.block-kit.devandblock-kit.kootstra.ioDNS records and certificates itself. -
Create an API token under My Profile β API Tokens from the Edit Cloudflare Workers template. Limit Account Resources to your account and Zone Resources to
block-kit.devandkootstra.io. -
Add two repository secrets under Settings β Secrets and variables β Actions:
Secret Value CLOUDFLARE_API_TOKENThe token from step 2 CLOUDFLARE_ACCOUNT_IDThe account ID shown on the account's Workers & Pages overview -
Previews use the same repository secrets and need no environment: the
/previewcomment check decides who can deploy them.
Wrangler lives in tools/deploy, a package outside the Bun workspaces with its own bun.lock, so a
root bun install doesn't fetch it: only the deploy and preview jobs install it. Its version is
pinned exactly in tools/deploy/package.json; to take a newer one, change it there and run
bun install in tools/deploy.
To deploy by hand from a local build instead, install it, log in once with
tools/deploy/node_modules/.bin/wrangler login, then:
(cd tools/deploy && bun install)
bunx turbo run build --filter=@nkootstra/block-kit-docs
cd apps/docs && ../../tools/deploy/node_modules/.bin/wrangler deployA pull request whose changes alter how a fixture renders gets a comment with before, after and the changes, in light and dark. Nothing is posted or updated when no rendering changed.
- The
render-diffjob in.github/workflows/visual.ymlrenders every fixture with the base branch's build and the pull request's, and uploads the renders that differ as therender-diffartifact. It has no secrets, also for pull requests from forks. .github/workflows/visual-preview.ymlruns main's code onworkflow_run. It refuses an artifact with anything but the PNG renders its manifest lists, uploads the renders the comment shows (at most 40) to R2 and keeps one comment up to date. Like everyworkflow_runworkflow, it only runs once it is onmain.
Renders replace a payload's images with placeholders, so a pull request can't publish other images
through a fixture. Emoji still show: the library draws them from a fixed set. tools/visual covers running it locally.
- Create the
block-kit-visualR2 bucket, attach the custom domaincdn.block-kit.devand add a lifecycle rule that deletes objects under thepr-prefix after 30 days. The bucket also holds the sample images undersamples/, which fixtures and docs link to and which must stay (fixtures/assets/samples). - Create an API token with Workers R2 Storage Bucket Item Write and Read, limited to that bucket. R2's S3 API authenticates with it: the access key ID is the token's ID, the secret is the SHA-256 hash of its value.
- Add
R2_ACCESS_KEY_IDandR2_SECRET_ACCESS_KEYas repository secrets. The workflow also usesCLOUDFLARE_ACCOUNT_ID.