On a recent project, I was assigned a deceptively simple task: build custom Table and Chart components using our design system, sourced from a CSV file uploaded to the Sitecore Media Library. In the past, this would’ve been a straightforward implementation. But under our current SitecoreAI and Content SDK setup, built on NextJS App Router, I ran into something I didn’t expect: these components had to be built around a client-server boundary, not just within one.
A Mental Model
In Pages Router, I’d fetch data in getStaticProps or getServerSideProps and render it in the same component. The whole page hydrates on the client after the initial server render, one unit, one boundary.
App Router changes this. React Server Components execute on the server and never ship JS to the client, unless explicitly opted into 'use client'. The question shifts from “where do I fetch my data” to “which parts of my UI exist as client-side JS.”
RSCs offer real benefits: less code shipped to the browser, direct file-system access, lower latency on data fetching. Components needing interactivity, browser-only APIs, or hooks get marked 'use client'. Everything else defaults to a Server Component (react.dev).
One consequence: a Server Component can import a Client Component, but not the other way around. Once you cross into 'use client', you can’t cross back. So the best practice is pushing that boundary as close to the leaves as possible. Here’s what that looked like in practice.
The Table
First step: fetch the CSV from the Media Library via fetch() or axios. Content SDK’s <File> component only supports direct download, and we needed the parsed data to build the Table.
My first pass fetched, parsed, and rendered it all in one component.
// src/components/table/Table.tsximport React, { JSX } from 'react';import { ComponentProps } from 'lib/component-props';import { Table as DSTable, TableData, TableStyle } from ‘@testsite/design-system';export type TableFields = { CSVUpload: FileField;};// … more code hereexport type TableProps = ComponentProps & { fields: TableFields;};export const Table = async (props: TableProps): Promise<JSX.Element> => { const { fields } = props; const csvSrc = fields?.CSVUpload?.value?.src ?? ''; if (csvSrc) { // … fetch and parse CSV data here } return ( <DSTable tableData={parsedData} // … more props here /> );};export default Table;
It worked locally but threw a Minified React error #130 once deployed to a higher environment. The cause: our Design System Table is a pre-compiled, separately-built package in the monorepo, with 'use client' buried in its output. The dev build resolved it fine, but in CI, Next’s RSC bundler failed to detect that directive in the pre-built artifact, so instead of substituting a valid client reference, it returned undefined. That’s exactly what error #130 means: React expected a valid component and got undefined.
The fix: a <TableWrapper> client component. My Server Component fetches and parses the CSV, then passes the data as props into TableWrapper, which imports and renders the Design System Table itself.
// src/components/table/Table.tsximport React, { JSX } from 'react';import { ComponentProps } from 'lib/component-props';import { TableWrapper } from './TableWrapper';export type TableFields = { CSVUpload: FileField;};// … more code hereexport type TableProps = ComponentProps & { fields: TableFields;};export const Table = async (props: TableProps): Promise<JSX.Element> => { const { fields } = props; const csvSrc = fields?.CSVUpload?.value?.src ?? ''; if (csvSrc) { // … fetch and parse CSV data here } return ( <TableWrapper tableProps={{ tableData: parsedData ?? { columns: [], rows: [] } }} // … more props here /> );};export default Table;
// src/components/table/TableWrapper.tsx'use client';import { Table as DSTable, TableData, TableStyle } from ‘@testsite/design-system';type TableWrapperProps = { tableProps: TableProps;};export const TableWrapper = (props: TableWrapperProps) => { const { tableProps } = props; // … more code here return <DSTable {…tableProps} />;};export default TableWrapper;
This turns the Design System Table import from a server-to-client cross-boundary import (dependent on the bundler correctly detecting a directive it sometimes can’t see) into an ordinary client-to-client import that just works.
The Chart
The Chart hit a version of the same problem, by a different path. We were required to use Highcharts to render our charts which is a browser-only library. It assumes window and document exist the moment its modules are evaluated. NextJS renders the page on the server first, no browser, before it reaches the client. Wiring in the Chart threw an error right at module evaluation, not runtime, which was the tell this wasn’t a bug in my component logic.
This turned out to be a known, filed Highcharts issue specific to monorepo/pnpm setups like ours (highcharts/highcharts#23394). A maintainer confirmed the cause directly: Highcharts needs a browser the moment it’s imported.
// src/components/chart/ChartWrapper.tsx'use client';import { Chart as DSChart, ChartProps } from ‘@testsite/design-system';type ChartWrapperProps = { chartProps: ChartProps;};export const ChartWrapper = (props: ChartWrapperProps) => { const { chartProps } = props; // … more code here return <DSChart {…chartProps} />;};export default ChartWrapper;
My first assumption was that 'use client' alone would fix it. It didn’t. 'use client' doesn’t mean “never runs in Node.” NextJS still executes a Client Component once on the server to produce the initial HTML for hydration, so a top-level import 'highcharts/modules/accessibility' still gets evaluated server-side even inside a client file. The same GitHub reporter hit this wall: 'use client' got them partway, but disabling SSR entirely was what fully resolved it.
The Next-native way to disable SSR for a component is next/dynamic with ssr: false:
// src/components/chart/ChartWrapper.tsx'use client';import { ChartProps } from ‘@testsite/design-system';type ChartWrapperProps = { chartProps: ChartProps;};const ChartInner = dynamic(() => import(‘testsite/design-system’).then((mod) => mod.Chart), { ssr: false,});export const ChartWrapper = (props: ChartWrapperProps) => { const { chartProps } = props; // … more code here return <ChartInner {…chartProps} />;};export default ChartWrapper;
This guarantees Highcharts is only ever evaluated in the browser, after hydration, never during any server pass. Same shape as the Table, different library hitting the same wall.
Two details made this hold together rather than just paper over the symptom:
ssr: falseonly works from inside a Client Component, so ChartWrapper still needs'use client'at its own top.- CSV-fetching stays out of this file. A Server Component ancestor fetches and parses, then hands the data down as plain serializable props, keeping server-side data work separate from browser-only rendering.
What I Learned
- Push the boundary to the leaves. Isolate the piece that needs
'use client'instead of marking a whole page or feature client-side. - Wrap third-party or design-system components in a dedicated client shim rather than importing them directly into a Server Component. Both
TableWrapperandChartWrapperexist for this reason. 'use client'marks a boundary, not a guarantee. Next still executes Client Components once on the server for hydration. Libraries that assume a browser on import, like Highcharts, needssr: false, not just'use client'.- Keep data-fetching and browser-only rendering in separate files. Connect them with plain serializable props.
- Never trust a rendering param’s type. Sitecore rendering params always arrive as strings. Parse explicitly before use.
Neither component was conceptually hard: a table and a chart, both fed by a CSV, the kind of thing that used to be a same-day task. What cost time wasn’t the logic, it was the boundary. Both bugs passed clean locally and only surfaced once deployed, which meant every fix cycled through a QA round-trip before anyone could confirm it actually worked. For a “relatively simple” component, that’s an expensive way to find out the architecture was wrong.
Getting the boundary right at design time, deciding up front where server code stops and client code starts, is what turns that expensive back-and-forth into a non-event.

Leave a comment