Making Plate.js Static Render Fast - From 30 Seconds to 300ms
May 13, 2026The blog on bondarchuk.me runs on timeli.sh - specifically on its blog app, which stores posts as Plate.js JSON and renders them server-side on public pages using PlateStatic, the read-only renderer that ships with @udecode/plate. For short posts this works fine. For longer posts - like the payment integration write-up with multiple code blocks, tables, and several thousand words - the initial page load was taking around 28-32 seconds. During that time, one CPU core was pegged and the Node.js process was blocked from serving anything else.
This post is the story of two attempts to fix it. The first brought the time down to around 7.5-8.5 seconds with a visual shimmer to mask the wait. The second brought it down to around 280-320ms and removed the shimmer entirely.
What PlateStatic actually does, and why it's slow for large documents
PlateStatic renders a Plate document by walking the node tree and dispatching each node to its registered static element component - a React component per node type (paragraph, heading, code block, link, bold leaf, etc.). For a long post with hundreds of nodes, that means a very large React component tree: every paragraph, every heading, every code line, every text span with a mark becomes a React element.
The expensive part is not initialization - it is the React render itself. PlateStatic produces a deeply nested tree and React has to reconcile the whole thing in one synchronous pass. On the server, Next.js renders this tree to an HTML string. For a document with 80-100 top-level blocks, each containing multiple inline nodes, the total node count can reach into the thousands. React walks every one of them, calls every function component, resolves every className, and serializes the result to HTML before the response can be sent.
Adding performance.now() timing around the page component confirmed this:
// apps/web/src/app/[[...slug]]/page.tsxconst start = performance.now();try { // ... render page} finally { logger.debug( { duration: `${performance.now() - start}ms` }, "Page rendering completed", );}The logs showed the entire render budget going to the blog post content block. Data fetching was fast. The bottleneck was PlateStatic working through a massive component tree synchronously.
Iteration 1: split the document into smaller React trees
The key insight is that React can render multiple smaller trees faster than one large tree of the same total size, because the overhead is distributed and each subtree's commit stays bounded. If a document with 100 top-level blocks takes 29 seconds as one PlateStatic instance, splitting it into twenty chunks of 5 blocks each gives React twenty smaller trees to work through.
The chunking utility
// packages/rte/src/components/editor/chunk-plate-value.tsimport type { Value } from "@udecode/plate";/** * Split a document at top-level block boundaries. * Each slice remains a valid Value that can be passed to PlateStatic. */export function chunkPlateValueByTopLevelBlocks( value: Value, maxBlocksPerChunk: number,): Value[] { if (maxBlocksPerChunk < 1 || value.length <= maxBlocksPerChunk) { return [value]; } const out: Value[] = []; for (let i = 0; i < value.length; i += maxBlocksPerChunk) { out.push(value.slice(i, i + maxBlocksPerChunk) as Value); } return out;}The utility slices only at top-level block boundaries, never mid-block. Inline nodes (bold text, links, code spans) are always children of a block and always stay in the same chunk as their parent. Each slice is a valid Plate document on its own.
Wiring chunking into PlateStaticEditor
The PlateStaticEditor component got a chunkTopLevelBlocks prop. When set, it splits the value, creates one editor per slice, and renders them in sequence inside a wrapper div:
// packages/rte/src/components/editor/plate-static-editor.tsxexport const PlateStaticEditor: React.FC<PlateStaticEditorProps> = ({ value, style, className, id, variant, chunkTopLevelBlocks, ...rest}) => { const slices = React.useMemo((): Value[] => { const v = (value ?? []) as Value; const size = chunkTopLevelBlocks != null && chunkTopLevelBlocks > 0 ? chunkTopLevelBlocks : 0; if (!size) return [v]; return chunkPlateValueByTopLevelBlocks(v, size); }, [value, chunkTopLevelBlocks]); const editors = React.useMemo( () => slices.map((slice) => createPlateStaticEditor(slice)), [slices], ); const isChunked = editors.length > 1; if (!isChunked) { return ( <PlateStatic editor={editors[0]!} components={PLATE_STATIC_EDITOR_COMPONENTS} style={style} className={cn(editorVariants({ variant }), className)} id={id} {...rest} /> ); } return ( <div className={cn(className)} style={style} id={id} {...rest}> {editors.map((editor, index) => ( <PlateStatic key={index} editor={editor} components={PLATE_STATIC_EDITOR_COMPONENTS} className={editorVariants({ variant })} /> ))} </div> );};Also, the component map was extracted to a module-level constant rather than being recreated inline on every render - a small secondary improvement that avoids React treating the components object as a new reference on each pass:
const PLATE_STATIC_EDITOR_COMPONENTS = { [BaseAudioPlugin.key]: MediaAudioElementStatic, [BaseBlockquotePlugin.key]: BlockquoteElementStatic, [BaseBoldPlugin.key]: withProps(SlateLeaf, { as: "strong" }), [BaseCodeBlockPlugin.key]: CodeBlockElementStatic, // ... all element types} as const;The blog post content reader passed chunkTopLevelBlocks={5} for full-length posts:
// packages/app-store/src/apps/blog/blocks/post-content/reader.tsx<PlateStaticEditor value={displayContent} chunkTopLevelBlocks={!isEditor && !showShort ? 5 : undefined}/>The shimmer
Chunking dropped the average render time from around 29 seconds down to around 7.8-8.2 seconds. Better, but still far too slow for a page load. The partial fix was to wrap the blog post container in a Suspense boundary with a skeleton shimmer, so the page shell renders and streams immediately while the post content catches up:
// packages/app-store/src/apps/blog/blocks/post-container/reader.tsximport { Skeleton } from "@timelish/ui";import { Suspense } from "react";return ( <Suspense fallback={<Skeleton className="w-full h-full min-h-96" />}> <BlogPostContainerServerWrapper props={props} style={style} blockBase={block.base} restProps={rest} appId={appId} args={args} /> </Suspense>);The shimmer gave the visitor something to look at while the server worked through the tree. The page navigation felt responsive; the content filled in after 7.5-8.5 seconds depending on post length.
But the core problem remained. Chunking distributes the work but does not reduce it. A 100-block post with 5 blocks per chunk still renders 100 blocks through PlateStatic, just in 20 batches. The total React reconciliation work was roughly the same. The improvement came from smaller individual tree commits, not from doing less overall.
Iteration 2: a custom fast renderer
The right fix was to not use PlateStatic at all for read-only blog rendering.
PlateStatic is designed for correctness and full feature parity with the interactive editor. It uses the same plugin-driven component dispatch that powers the editor: each node type is resolved through the plugin registry, each component receives editor context, each leaf is processed through the marks pipeline. All of that machinery exists to support plugin extensibility and editor-side use cases. For a read-only blog post, none of it is needed.
What a read-only renderer actually needs is a recursive function that walks a JSON tree and returns React elements. That is a much simpler problem.
The second commit introduces PlateStaticFastRenderer: a custom renderer that walks the Plate value tree directly, maps each node type to its output, and produces a React tree with no plugin system, no editor context, and no Slate or Plate involvement at all.
The approach
The Plate JSON format is consistent: element nodes have a type string and a children array; leaf nodes have text content and optional boolean mark flags (bold, italic, code, etc.). A fast renderer just needs a lookup table from node type to a render function, and a leaf renderer that applies marks:
// packages/rte/src/components/editor/plate-static-fast-renderer.tsx (simplified)function renderLeaf(node: TText): React.ReactNode { let content: React.ReactNode = node.text || "\u200B"; if (node.bold) content = <strong>{content}</strong>; if (node.italic) content = <em>{content}</em>; if (node.underline) content = <u>{content}</u>; if (node.strikethrough) content = <del>{content}</del>; if (node.code) content = <code>{content}</code>; if (node.subscript) content = <sub>{content}</sub>; if (node.superscript) content = <sup>{content}</sup>; if (node.kbd) content = <kbd>{content}</kbd>; return content;}function renderNode(node: TNode, index: number): React.ReactNode { // Leaf node if (Text.isText(node)) { return ( <React.Fragment key={index}> {renderLeaf(node as TText)} </React.Fragment> ); } const element = node as TElement; const children = element.children?.map((child, i) => renderNode(child, i)); switch (element.type) { case "p": return <p key={index}>{children}</p>; case "h1": return <h1 key={index}>{children}</h1>; case "h2": return <h2 key={index}>{children}</h2>; case "blockquote": return <blockquote key={index}>{children}</blockquote>; case "code_block": return ( <pre key={index}> <code className={`language-${element.lang ?? "text"}`}> {children} </code> </pre> ); case "a": return ( <a key={index} href={element.url as string} target="_blank" rel="noopener noreferrer"> {children} </a> ); case "table": return <table key={index}><tbody>{children}</tbody></table>; case "img": return ( <img key={index} src={element.url as string} alt={(element.alt as string) ?? ""} /> ); // ... all other element types default: return <div key={index}>{children}</div>; }}export const PlateStaticFastRenderer = React.forwardRef< HTMLDivElement, { value: Value; className?: string; style?: React.CSSProperties; variant?: string }>(({ value, className, style, variant, ...rest }, ref) => { return ( <div ref={ref} className={cn(editorVariants({ variant }), className)} style={style} {...rest} > {(value ?? []).map((node, i) => renderNode(node, i))} </div> );});The full plate-static-fast-renderer.tsx handles every element type the blog editor supports: all heading levels, code blocks with Prism syntax highlighting, tables, images, toggles, columns, mentions, date elements, equations, horizontal rules, and all text marks. For code blocks, the Prism tokenization logic was extracted into a separate prism-decorate-code-line.tsx utility that runs the Prism tokenizer and maps tokens directly to <span> elements, bypassing Plate's decorate API entirely.
The key difference from PlateStatic is not that the output is simpler - it produces essentially the same HTML - but that the path to get there is much shorter. There is no plugin registry lookup per node, no editor context propagation, no Slate model validation, no component wrapping. The render function calls itself recursively on each node and returns a React element. That is the entire pipeline.
Wiring in the renderMode prop
The PlateStaticEditor component now has a renderMode prop that switches between the original Plate-based path and the fast renderer. The old chunking logic moves into PlateStaticEditorPlate and the fast renderer into PlateStaticEditorFast. The top-level PlateStaticEditor dispatches between them:
export const PlateStaticEditor = React.forwardRef< HTMLDivElement, PlateStaticEditorProps>(({ renderMode = "fast", chunkTopLevelBlocks, ...props }, ref) => { if (renderMode === "fast") { return <PlateStaticEditorFast ref={ref} {...props} />; } return ( <PlateStaticEditorPlate ref={ref} {...props} chunkTopLevelBlocks={chunkTopLevelBlocks} /> );});The default is "fast". Every existing call site that passes no renderMode automatically gets the fast renderer. The chunkTopLevelBlocks prop is silently ignored in fast mode - the fast renderer processes the whole document in a single pass and doesn't need chunking.
Removing the shimmer
With the fast renderer in place, average render time dropped to around 280-320ms. That is well inside Next.js's streaming budget where the content can be inlined into the initial HTML response before the browser's first paint. The Suspense boundary and Skeleton shimmer were removed:
// Before (iteration 1)<Suspense fallback={<Skeleton className="w-full h-full min-h-96" />}> <BlogPostContainerServerWrapper ... /></Suspense>// After (iteration 2)<BlogPostContainerServerWrapper ... />The shimmer code is kept as a comment in case a future block type genuinely needs async resolution. For the blog post itself, the content now arrives with the page.
The numbers
State | Avg render time | Notes |
|---|---|---|
Original PlateStatic | ~29 sec | Single large React tree; full PlateStatic component dispatch for entire document |
Chunked (5 blocks/chunk) | ~7.9 sec | Smaller React subtrees per chunk; Suspense shimmer added to mask wait |
Fast renderer | ~300ms | Direct recursive render; no plugin dispatch, no Slate context, no chunking needed |
The factor-of-25 improvement between chunked and fast reflects the difference between reducing React tree size and eliminating the component dispatch overhead entirely. Chunking distributes the same work into smaller pieces. The fast renderer does fundamentally less work per node.
What the fast renderer doesn't do
The fast renderer trades correctness for speed in a few narrow areas that matter in practice:
No plugin-driven component dispatch. PlateStatic resolves each node type through the plugin registry, which means custom plugins with custom element types automatically get their static components used. The fast renderer's node-type-to-render-function map is static code. Adding a new element type to the editor requires also adding a case to the fast renderer. This is acceptable in a controlled monorepo where both are maintained together.
No Slate editor context. Some PlateStatic static components use editor context for things like resolving mention data or looking up toggle state. The fast renderer passes no editor context. Those element types need their data encoded directly in the node rather than resolved at render time - which is already how the stored documents work, since they are serialized at save time.
Prism integration is manual. For code blocks, PlateStatic uses the code block plugin's decoration API, which integrates with Slate's decorate system to apply Prism token classes. The fast renderer calls the Prism tokenizer directly in prism-decorate-code-line.tsx and maps tokens to spans manually. The visual output is identical, but it is a separate code path.
The "plate" render mode remains available via the renderMode prop for cases where plugin parity matters - for example, inside the admin editor where a static preview is shown alongside the interactive editor and needs to exactly match its output. For public blog rendering, "fast" is the default and the right choice.
The commits
The chunking approach and Suspense shimmer landed in commit 20ea92b. The fast renderer, the renderMode prop, and the shimmer removal landed in commit 9fef881. Both are in packages/rte/src/components/editor/ in the timeli.sh repository.