Server rendering
Using @nkootstra/block-kit with SSR and React Server Components, and avoiding hydration mismatches.
Every component in @nkootstra/block-kit renders from props alone, with no window, no document, no
browser-only API, so renderToString/renderToStaticMarkup and React Server Components both work
out of the box.
The client boundary is already drawn for you
The package’s main entry (@nkootstra/block-kit, everything including <Message>, <Modal>,
<BlockKitProvider>, <HomeTab>, <Mrkdwn>) ships its own "use client" directive, so you can import
these components straight into a Next.js App Router Server Component with no wrapper of your own:
// app/page.tsx (a Server Component)
import { Message } from "@nkootstra/block-kit";
export default function Page({ blocks }: { blocks: AnyBlock[] }) {
return <Message blocks={blocks} />;
}
The /mrkdwn, /server, /transport and /web-api entries carry no directive and stay importable
from server-only code (a route handler, a server action, createInteractionRelay) since none of them
touch the DOM.
There’s one thing the package can’t do for you: handlers are plain functions, and functions can’t
cross from a Server Component into a Client Component as props. <BlockKitProvider onAction={...}>
being a Client Component itself doesn’t help if the function you pass it is defined in a Server
Component’s module: that still fails to serialize. Define the provider and its handlers together in a
component of your own marked "use client":
// components/slack-message.tsx
"use client";
import { BlockKitProvider, Message } from "@nkootstra/block-kit";
import type { AnyBlock } from "@nkootstra/block-kit";
export function SlackMessage({ blocks }: { blocks: AnyBlock[] }) {
return (
<BlockKitProvider onAction={(action) => console.log(action)}>
<Message blocks={blocks} />
</BlockKitProvider>
);
}
A <Message> with no <BlockKitProvider> above it, and no interactive elements in its blocks, needs
none of this. Render it directly from a Server Component, as above.
Hydration-stable timestamps
<Message> shows a relative timestamp (“2:30 PM”, “Yesterday at…”) next to the sender’s name. Pass
ts explicitly whenever the message is rendered on the server:
<Message ts="1719493800.000200" blocks={blocks} />
Without an explicit ts, <Message> stamps the current time itself the moment it mounts. On the
server that’s the time of the render; on the client, hydration re-runs the same useState initializer
a moment later: a different value, which React flags as a hydration mismatch. Always pass a real ts
(the message’s own Slack timestamp) for anything rendered server-side; reserve the no-ts fallback for
client-only, ephemeral previews.
Pass timeZone alongside it, either directly or via <BlockKitProvider timeZone="...">, so the
formatted time matches between server and client instead of depending on the server machine’s local
zone:
<BlockKitProvider timeZone="America/New_York">
<Message ts="1719493800.000200" blocks={blocks} />
</BlockKitProvider>
The same applies to any <!date^...> mrkdwn token and to formatSlackDate calls. See
mrkdwn.
Theme before hydration
If you use <html data-theme="dark"> (see Theming) driven by something only known
on the client (a cookie your server can also read is fine), set it in a blocking inline script so the
attribute is already correct on the very first paint, avoiding a light/dark flash:
<script dangerouslySetInnerHTML={{ __html: `
document.documentElement.dataset.theme = localStorage.getItem("theme") ?? "";
` }} />
If your dark/light choice comes from a cookie instead, set data-theme directly on the server-rendered
<html> tag and skip the script. That’s simpler and has no flash to begin with.
Next.js App Router example
// app/messages/[ts]/page.tsx
import { Message } from "@nkootstra/block-kit";
import { getMessage } from "@/lib/slack";
export default async function MessagePage({ params }: { params: { ts: string } }) {
const message = await getMessage(params.ts);
return <Message ts={message.ts} timeZone="America/New_York" blocks={message.blocks} />;
}
// components/interactive-message.tsx
"use client";
import { BlockKitProvider, Message } from "@nkootstra/block-kit";
import { httpTransport } from "@nkootstra/block-kit/transport";
import type { AnyBlock } from "@nkootstra/block-kit";
const transport = httpTransport({ url: "/api/slack/relay" });
export function InteractiveMessage({ ts, blocks }: { ts: string; blocks: AnyBlock[] }) {
return (
<BlockKitProvider timeZone="America/New_York" {...transport}>
<Message ts={ts} blocks={blocks} />
</BlockKitProvider>
);
}
The page itself stays a Server Component fetching data; only the piece that needs onAction and friends
opts into the client, per Connecting to your app.