Semiotic works in server-side rendering environments like Next.js App Router, Remix, Astro, and any framework that uses React Server Components. All interactive components include"use client" directives, so they automatically run on the client where browser APIs are available. For situations where you need chart output without a browser at all — static site generation, email rendering, OG image previews — Semiotic also provides a dedicated server rendering API.
Using Semiotic in Next.js App Router
Every Semiotic component that touches the browser (Frames, Charts, ResponsiveFrames, SparkFrames, tooltips, interaction layers) ships with a "use client" directive at the top of its module. This means you can import and render Semiotic components from your own client components without any special configuration:
JSX
"use client" import { LineChart } from "semiotic" export default function Dashboard() { return ( <LineChart size={[800, 400]} data={revenueData} xAccessor="month" yAccessor="revenue" /> ) }
Your component file needs "use client" at the top because it renders a Semiotic chart — which is an interactive, browser-dependent component. This is standard Next.js App Router practice for any component that uses hooks, event handlers, or browser APIs.
Bundler note: Turbopack subpath resolution
At time of writing, Turbopack (Next.js's default dev bundler in recent versions) intermittently fails to resolve Semiotic's sub-path exports — Module not found: Can't resolve 'semiotic/xy'from a Server Component, even though Node, webpack, esbuild, and Vite all resolve them correctly. The package's exportsmap is well-formed; the issue is in Turbopack's exports resolution path.
Until that's resolved upstream, opt out of Turbopack with the--webpack flag on both dev andbuild:
JSON
{ "scripts": { "dev": "next dev --webpack", "build": "next build --webpack", "start": "next start" } }
Webpack handles Semiotic's exports map with no special config. If you're testing against a local copy of Semiotic viafile: in package.json, also pass--install-links to npm install so the package is copied rather than symlinked — npm's default symlink behavior breaks resolution under both bundlers.
Importing from Server Components
You cannot import Semiotic charts directly in a Server Component (a file without "use client"). Instead, create a thin client wrapper and import that:
JSX
// app/dashboard/Chart.tsx — client component "use client" import { BarChart } from "semiotic" export default function Chart({ data }) { return ( <BarChart size={[600, 400]} data={data} oAccessor="category" rAccessor="value" /> ) }
JSX
// app/dashboard/page.tsx — server component import Chart from "./Chart" export default async function DashboardPage() { const data = await fetchMetrics() return ( <main> <h1>Dashboard</h1> <Chart data={data} /> </main> ) }
This is the standard pattern for using any client-side library in the App Router. Your server component handles data fetching and layout, then passes serializable data as props to the client component that renders the chart.
Tree-Shakeable Sub-Path Imports
When bundle size matters, import from Semiotic's sub-path exports to avoid pulling in frame types you don't use:
JSX
// Only includes StreamXYFrame and its dependencies import { StreamXYFrame } from "semiotic/xy" // Only includes StreamOrdinalFrame import { StreamOrdinalFrame } from "semiotic/ordinal" // Only includes StreamNetworkFrame import { StreamNetworkFrame } from "semiotic/network"
Each sub-path bundle includes the "use client" directive, so they work in App Router the same way as the main import.
Isomorphic Auto-Hydration
Every non-streaming chart HOC in Semiotic participates in React's hydration boundary directly. The same chart component that renders interactively on the client also renders server-side as inline SVG when called from a React Server Component — no separate placeholder, no next/dynamic scaffolding, no "use client" ceremony for the chart itself.
Covered HOCs:
- XY:
LineChart, AreaChart,StackedAreaChart, Scatterplot,ConnectedScatterplot, BubbleChart,Heatmap, ScatterplotMatrix,QuadrantChart, MultiAxisLineChart,CandlestickChart, MinimapChart,XYCustomChart. - Ordinal:
BarChart,StackedBarChart, GroupedBarChart,SwarmPlot, BoxPlot,Histogram, ViolinPlot,RidgelinePlot, DotPlot,PieChart, DonutChart,GaugeChart, FunnelChart,SwimlaneChart, LikertChart,OrdinalCustomChart. - Network:
ForceDirectedGraph,ChordDiagram, SankeyDiagram,TreeDiagram, Treemap,CirclePack, OrbitDiagram,NetworkCustomChart. - Geo:
ChoroplethMap,ProportionalSymbolMap, FlowMap,DistanceCartogram.
Streaming charts (RealtimeLineChart,RealtimeHistogram, RealtimeSwarmChart,RealtimeWaterfallChart, RealtimeHeatmap) deliberately stay canvas-only — they're designed for live push-driven data, not pre-rendered output. Render them as client components.
TSX
// app/dashboard/page.tsx — server component // No "use client" needed. The chart emits SVG server-side, hydrates to // canvas + interactivity on the client, with no hydration mismatch. import { LineChart } from "semiotic/xy" export default async function DashboardPage() { const data = await fetchRevenue() return ( <main> <h1>Revenue</h1> <LineChart data={data} xAccessor="month" yAccessor="revenue" width={800} height={400} /> </main> ) }
How it works: StreamXYFrame uses an internaluseHydration() hook that returns falseon the server and during the first client render after hydration, then flips to true after the first commit. While false, the frame's existing SSR-mode SVG branch fires; once true, the frame upgrades to canvas + interactivity in the same DOM subtree. React's reconciler handles the swap as a normal update. Server output and first-client-render output are byte-identical, so hydration succeeds without mismatch warnings.
Tradeoffs:
- SVG paint is slower than canvas past ~5k marks.For dense scatter or streaming charts, the SVG-first approach adds a measurable cost. Streaming and realtime charts opt out of the SVG layer (they're canvas-only by design — see below).
- First interaction waits on canvas mount. Hover and click handlers attach to the canvas, which exists only after hydration completes. In practice that's a single rAF frame from when the page becomes interactive.
- Theme CSS variables resolve via the canvas DOM context.On the server there's no canvas to read computed styles from, so
var(--semiotic-*) values fall back to whateverfallback is declared in the CSS or to the theme preset. Use explicit theme-prop values when you need tighter SSR/client color parity. - Responsive charts re-layout once on hydration.The server doesn't know the consumer's container dimensions, so charts with
responsiveWidth orresponsiveHeight render server-side at theirwidth / height defaults. On hydration, the ResizeObserver attaches and measures the actual container — if it differs from the default, the chart re-lays out once. Pinwidth / height explicitly when you can predict the layout, or accept the single-frame re-layout as the cost of doing responsive sizing under SSR.
Hydration parity is enforced by a regression test(StreamXYFrame.hydration.test.tsx) that callsrenderToString + hydrateRoot against the same component and asserts no React hydration mismatch warnings. If a future change makes the server output diverge from the first client render, the test fails before the regression ships.
Animations behave correctly across the boundary.When a chart with animate enabled is hydrated from SSR, the intro animation is skipped — the server already painted the chart in its final state, and re-animating from blank when the canvas takes over would look like a regression. Pure client mounts (no SSR) keep their intro animation because the SVG render is overwritten before the browser paints, so the canvas's first paint is the user's first sight of the chart. Subsequent data-change transitions animate normally in both modes.
Manual Placeholder Pattern (for streaming charts)
Streaming charts (RealtimeLineChart et al.) and any chart you've deliberately wrapped in { ssr: false }don't participate in auto-hydration. For these, pairnext/dynamic with semiotic/server'srenderChart as the placeholder. The server emits a static SVG that's part of the initial HTML; the client wrapper renders the same placeholder until it has mounted, then swaps in the interactive chart in the same slot. This is also the emergency fallback if you ever hit a hydration regression in an auto-hydrating chart — wrap it manually until the regression is fixed.
This is the cheap, working SSR story today. It's deliberately not rehydration — the placeholder SVG and the client canvas are produced by separate code paths, so the client mount swaps the DOM out rather than picking up where the server left off. That means fast initial paint, indexable static SVG, and zero hydration warnings, with the cost that the first interaction has to wait on client mount + the chart's initial layout pass.
The shape is two files: a server component that pre-renders the placeholder SVG and passes it down, and a client wrapper that gates on a mounted flag so the placeholder renders on the server (and on the first client render) and the interactive chart renders thereafter.
TSX
// app/dashboard/RevenueChart.tsx — client wrapper "use client" import { useEffect, useState } from "react" import dynamic from "next/dynamic" // Lazy-load the interactive chart on the client only. const InteractiveChart = dynamic( () => import("semiotic/xy").then((m) => m.LineChart), { ssr: false }, ) interface Props { chartProps: { data: Array<{ month: string; revenue: number }>; xAccessor: string; yAccessor: string; width: number; height: number } placeholderSvg: string } export default function RevenueChart({ chartProps, placeholderSvg }: Props) { // `mounted` is false on the server and during the first client // render — the SVG placeholder is what gets emitted in both cases, // so React's hydration sees identical markup. After mount, swap to // the interactive chart in the same slot. const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) }, []) if (!mounted) { return ( <div style={{ width: chartProps.width, height: chartProps.height }} dangerouslySetInnerHTML={{ __html: placeholderSvg }} /> ) } return <InteractiveChart {...chartProps} /> }
TSX
// app/dashboard/page.tsx — server component import { renderChart } from "semiotic/server" import RevenueChart from "./RevenueChart" export default async function DashboardPage() { const data = await fetchRevenue() const chartProps = { data, xAccessor: "month", yAccessor: "revenue", width: 800, height: 400 } // Server-render the static SVG once. The wrapper renders it inline // during SSR and during the first client render, then replaces it // with the interactive chart on mount. const placeholderSvg = renderChart("LineChart", chartProps) return ( <main> <h1>Revenue</h1> <RevenueChart chartProps={chartProps} placeholderSvg={placeholderSvg} /> </main> ) }
A few details worth knowing:
- The
mounted flag is what gives you in-place replacement. The server bundle and the first client render emit the same SVG markup (so hydration matches), and the post-effect render swaps the subtree to the interactive chart. Only one of the two is in the DOM at any time. - The placeholder
div uses the samewidth and height as the chart so the interactive mount doesn't trigger a layout shift. - Pass identical props to
renderChart andInteractiveChart. Mismatch and the chart "jumps" on swap. - For static / build-time pages this gives indexable chart SVG with no interactivity penalty. For dynamically-rendered pages the server pass runs on every request — fine for moderate traffic, but cache the SVG output if you're rendering the same chart many times.
This pattern is a one-piece fit: replace the server-rendered SVG with future Semiotic isomorphic-rehydration support (when it ships) by removing the placeholderSvgprop and the mounted gate. No data shape or other prop changes required.
Static SVG Rendering on the Server
Sometimes you need chart output with no browser, no React runtime, and no client JavaScript at all. Common use cases include:
- Static site generation — pre-render charts at build time for pages that don't need interactivity.
- OG / social preview images — generate an SVG chart, convert it to PNG with Sharp or Satori for social cards.
- Email — inline an SVG chart in an HTML email where JavaScript doesn't run.
- PDF reports — embed chart SVGs in server-generated PDF documents.
- API endpoints — return chart SVG from an API route for consumption by other services.
Semiotic provides a semiotic/server entry point that runs the full data pipeline on the server and returns a plain SVG string. It uses the same calculation code as the client components, so the output matches what you'd see in the browser — minus interactivity.
Setup
The server entry point ships with the main semioticpackage. No additional installation is needed. Just import fromsemiotic/server:
JS
import { renderToStaticSVG, renderXYToStaticSVG, renderOrdinalToStaticSVG, renderNetworkToStaticSVG, } from "semiotic/server"
Rendering an XY Chart
JS
import { renderXYToStaticSVG } from "semiotic/server" const svg = renderXYToStaticSVG({ size: [600, 400], lines: [ { label: "Revenue", coordinates: [ { month: 1, revenue: 120 }, { month: 2, revenue: 210 }, { month: 3, revenue: 180 }, { month: 4, revenue: 350 }, ], }, ], xAccessor: "month", yAccessor: "revenue", lineStyle: () => ({ stroke: "#ac58e5", strokeWidth: 2 }), axes: [ { orient: "left" }, { orient: "bottom" }, ], }) // svg is a string like: "<svg xmlns=\"http://www.w3.org/2000/svg\" ...>...</svg>" console.log(svg)
The returned string is a complete, self-contained SVG document that can be written to a file, embedded in HTML, or piped to an image converter.
Rendering an Ordinal Chart
JS
import { renderOrdinalToStaticSVG } from "semiotic/server" const svg = renderOrdinalToStaticSVG({ size: [500, 300], data: [ { region: "North", sales: 420 }, { region: "South", sales: 310 }, { region: "East", sales: 580 }, { region: "West", sales: 290 }, ], oAccessor: "region", rAccessor: "sales", type: "bar", style: () => ({ fill: "#E0488B" }), axes: [{ orient: "left" }], })
Rendering a Network Chart
JS
import { renderNetworkToStaticSVG } from "semiotic/server" const svg = renderNetworkToStaticSVG({ size: [500, 500], nodes: [ { id: "A" }, { id: "B" }, { id: "C" }, { id: "D" }, ], edges: [ { source: "A", target: "B" }, { source: "B", target: "C" }, { source: "C", target: "D" }, { source: "A", target: "D" }, ], networkType: { type: "force", iterations: 300 }, nodeStyle: () => ({ fill: "#ac58e5", stroke: "#fff" }), edgeStyle: () => ({ stroke: "#ccc" }), })
Generic Entry Point
If you're building a dynamic system that renders different chart types based on configuration, use the generic renderToStaticSVGfunction. It accepts a frame type string as the first argument:
JS
import { renderToStaticSVG } from "semiotic/server" // chartConfig.type is "xy" | "ordinal" | "network" const svg = renderToStaticSVG(chartConfig.type, chartConfig.props)
Render Evidence
renderChartWithEvidence returns the SVG plus a machine-readable account of what actually rendered, computed from the same scene graph the SVG converter walks: mark counts by scene type, resolved axis domains, an empty flag, category/node/edge counts, the annotation count, and the accessible name. A non-visual caller — an agent repair loop, a CI assertion, a report pipeline — checks evidence.empty or evidence.markCountinstead of parsing SVG, and a chart that silently rendered zero data marks becomes a detectable condition rather than a blank image. The MCP renderChart tool returns the same block alongside its SVG/PNG output.
JS
import { renderChartWithEvidence } from "semiotic/server" const { svg, evidence } = renderChartWithEvidence("BarChart", { data, categoryAccessor: "product", valueAccessor: "units", title: "Sales", }) evidence.markCount // 3 — data marks in the rendered scene evidence.markCountByType // { rect: 3 } evidence.empty // false — EMPTY_SCENE appears in warnings when true evidence.categories // ["Widget", "Gadget", "Sprocket"] evidence.yDomain // [0, 682] — the resolved scale domain evidence.ariaLabel // "Sales"
Next.js Integration Examples
Pre-Rendered Chart in a Page
Use renderToStaticSVG inside a Server Component to embed a chart that loads with zero client JavaScript:
JSX
// app/report/page.tsx — Server Component (no "use client") import { renderOrdinalToStaticSVG } from "semiotic/server" export default async function ReportPage() { const metrics = await db.getQuarterlyMetrics() const chartSvg = renderOrdinalToStaticSVG({ size: [600, 300], data: metrics, oAccessor: "quarter", rAccessor: "revenue", type: "bar", style: () => ({ fill: "#4e79a7" }), axes: [{ orient: "left", tickFormat: d => "$" + d }], }) return ( <main> <h1>Q4 Report</h1> <div dangerouslySetInnerHTML={{ __html: chartSvg }} /> </main> ) }
The chart renders during build (for static pages) or on each request (for dynamic pages) with no client-side hydration cost.
OG Image API Route
Generate social preview images from chart SVGs in an API route. Combine with a library like sharp or @vercel/og to convert the SVG to PNG:
TSX
// app/api/chart-image/route.ts import { renderXYToStaticSVG } from "semiotic/server" export async function GET(request: Request) { const { searchParams } = new URL(request.url) const metric = searchParams.get("metric") || "pageviews" const data = await fetchTimeSeries(metric) const svg = renderXYToStaticSVG({ size: [1200, 630], lines: [{ coordinates: data }], xAccessor: "date", yAccessor: "value", lineStyle: () => ({ stroke: "#4e79a7", strokeWidth: 3, fill: "none", }), margin: { top: 40, right: 40, bottom: 60, left: 80 }, }) return new Response(svg, { headers: { "Content-Type": "image/svg+xml" }, }) }
What's Included in Static Output
The server renderer runs the exact same data pipeline as the client components. The output SVG includes:
- All data marks — lines, areas, bars, points, nodes, edges, summaries, connectors
- Axes and tick marks
- Frame title
- SVG filter definitions (matte, clip paths)
What's Excluded
Static SVG rendering intentionally excludes browser-dependent features that have no meaning in a static context:
- Tooltips and hover behavior — no mouse events in static SVG.
- Interaction layer — brushing, clicking, voronoi hover regions.
- Canvas rendering — use SVG-mode props only; canvas marks are skipped.
- Annotations — annotation layout can depend on DOM measurement; planned for a future release.
- Responsive sizing — you must provide an explicit
size prop; StreamXYFrame responsive mode and friends are client-only. - Keyboard navigation — accessibility features that require focus management.
If you need interactivity, use the standard client components. The server renderer is designed for the "static snapshot" use case.
Props
The server rendering functions accept the same props as their corresponding Frame components. You can pass any prop thatStreamXYFrame, StreamOrdinalFrame, orStreamNetworkFrame accepts — the data pipeline is identical.
A few props behave differently or are ignored in server rendering:
size — required. There is no DOM to measure, so you must provide explicit dimensions.hoverAnnotation, customHoverBehavior,customClickBehavior — ignored (no interaction layer).canvasLines, canvasPoints,canvasNodes, etc. — ignored (no canvas element available).tooltipContent — ignored (no tooltip layer).
API Summary
TS
import { renderToStaticSVG, renderXYToStaticSVG, renderOrdinalToStaticSVG, renderNetworkToStaticSVG, } from "semiotic/server" // Generic — pass frame type as first argument renderToStaticSVG("xy", xyFrameProps) // => string renderToStaticSVG("ordinal", ordinalProps) // => string renderToStaticSVG("network", networkProps) // => string // Convenience — typed, no frame type argument needed renderXYToStaticSVG(xyFrameProps) // => string renderOrdinalToStaticSVG(ordinalProps) // => string renderNetworkToStaticSVG(networkProps) // => string
All functions are synchronous and return a complete SVG string.
SSR Output Gallery
See the SSR Gallery for live examples of every chart type rendered using renderToStaticSVG. Each chart includes the exact code that produced it, so you can copy and adapt for your own server rendering needs.