Third-party packages
A Pyxle project has two dependency sets: Python packages (used by your
@server loaders, @action mutations, and pages/api/*.py endpoints) and Node
packages (used by your React/JSX components). Add to whichever side you need.
Python packages (pip)
Add the package to requirements.txt and install:
echo "pydantic>=2" >> requirements.txt
pip install -r requirements.txtImport it in the Python section of a .pyxl file (above the first JS
import) or in a pages/api/*.py module:
from pydantic import BaseModelNode packages (npm)
Install with npm and import it in the JSX section of a .pyxl file:
npm install zustandimport { create } from 'zustand';Vite bundles it for the browser and Pyxle's SSR runtime bundles it for the
server, so most packages "just work" on both. Prefer packages with SSR
support (they render the same markup on the server and client) — a
browser-only package should be used inside a useEffect, an event handler, or a
<ClientOnly> boundary. See Client Components.
Run both installers at once with pyxle install.
CommonJS packages and SSR
The server render bundles your page as an ES module, with React provided by the
runtime rather than bundled in. Pyxle resolves dependencies ESM-first (it
prefers a package's module/ESM entry over its CommonJS main), so libraries
that ship both — including lucide-react and most of the shadcn/ui ecosystem —
render on the server with no extra configuration.
A package that ships only CommonJS and calls require('react') internally
can't be linked into the ES-module server bundle. If one does, the server
render fails with an actionable error that names the package's require(...)
and your page file, and suggests the fix: use a version that ships an ES module,
or render that part of the page client-only with <ClientOnly> so it never runs
during the server render.
The import alias
pyxle init sets up an import alias (default @/*) in jsconfig.json, and
Pyxle wires the same alias into the Vite config and the SSR runtime. So
@/lib/format resolves to lib/format.js from anywhere, on both server and
client:
import { formatDate } from '@/lib/format';Charts, and other libraries that measure the DOM
A @server loader returns a dict; a third-party React component renders it.
Nothing sits in between — no API route, no fetch, no serializer:
@server
async def load(request):
days = await asyncio.to_thread(summarize, LOG) # plain Python
return {"days": days}Install Recharts as
recharts@^2.15. Version 3.x brings in a CommonJS-only dependency that callsrequire('react'), so a page importing it fails to server-render with the error described under CommonJS packages and SSR above. Pin 2.x, or keep 3.x and render the chart inside a<ClientOnly>boundary.
import { ComposedChart, Bar, Line, XAxis, YAxis } from 'recharts';
export default function Latency({ data }) {
return (
<ComposedChart id="latency-chart" width={880} height={340} data={data.days}>
<XAxis dataKey="day" interval={2} />
<YAxis interval={0} />
<Bar dataKey="requests" isAnimationActive={false} />
<Line dataKey="p95" isAnimationActive={false} />
</ComposedChart>
);
}That renders to real SVG on the server — the plotted path, the bars and the
axis labels are all in the HTML before any JavaScript runs — and it is a live
React tree once hydrated. A complete, runnable version is in
examples/charts.
Why the extra props
Charting libraries are the one category where SSR needs care, because they work out their layout by measuring the DOM — and during a server render there is no DOM to measure. The server and the browser can then produce different markup, and React treats that as a hydration mismatch.
What a mismatch costs you depends on what disagreed, and the two cases are worth telling apart before you go looking for one:
- An attribute — a coordinate, a
width, anhref. React keeps the server's value and says so: "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up." Nothing is re-rendered and the page is fully interactive; you are simply left with a value the client never agreed to, and a later re-render will not necessarily correct it, because React is comparing against what it thinks it already rendered. This is the quiet one. - Text or structure — a label the browser wraps onto a second line, an element one side emits and the other does not. React cannot patch either in place, and it distinguishes them: "Hydration failed because the server rendered text didn't match the client" when the two sides disagree about a string, and "Hydration failed because the server rendered HTML didn't match the client" when one side emits an element the other does not — which is the kind the entry animations below produce. Both continue "As a result this tree will be regenerated on the client." Either way React discards the server HTML for the whole root and re-renders it in the browser. The page ends up interactive, but the server render is thrown away — you pay for SSR and the visitor gets a client render anyway.
Only that second kind — text or structure — is reported outside a development build, and then only as a minified error code. See Verifying it hydrates below.
The fix for both is the same shape — give the library the number instead of letting it measure:
| Measures the DOM | Symptom | Fix |
|---|---|---|
| Axis tick layout | Server keeps every label and leaves the end tick where the scale put it; the browser measures, drops labels that will not fit and nudges the end tick inwards | Give every axis a numeric interval (see below) |
| Default axis tick text | A tick label with a space in it wraps in the browser whenever its measured word widths exceed the axis width — one <tspan> per line, two for a two-word label; the server measures nothing, so it emits exactly one |
Keep labels to one short line, or supply a custom tick renderer |
| Entry animations | Extra wrapper element on the client only | isAnimationActive={false} |
Two other things Recharts measures are not mismatches, because the
measurement lands after mount rather than during hydration.
<ResponsiveContainer> renders an empty <div> on the server and the chart
appears only once JS has run — see
Staying responsive without ResponsiveContainer.
<Legend> feeds its own measured height back into the plot area, which comes out
26px shorter in the browser than on the server; render the legend as your own
markup if you want the server's layout to be the final one.
The tick row catches people out because a vertical axis looks like it has nothing
to thin. Recharts runs one tick pass for every axis, and interval decides which
one: a number means "take these ticks as they are", anything else — including
the preserveEnd default — means "measure the labels first". Measuring is what
the server cannot do, so it always takes the first path. Leave a <YAxis> on its
default and the server leaves the end tick where the scale put it — against the
chart's top margin, so y=5 with Recharts' own defaults — while the browser
pulls it in to y=12.796875, and React names the mismatch in the console on
every development load. It is an attribute mismatch, so what ships is the
server's coordinate, on a chart that is otherwise working perfectly.
interval={0} keeps every tick and pins both sides to the same path.
That second number is worth a look, because it says what "measuring" means here.
Recharts sizes a label in getStringSize (recharts/util/DOMUtils): it appends
a hidden <span id="recharts_measurement_span"> to document.body, sets the
text on it and reads getBoundingClientRect(). The span takes its font from the
axis, which CartesianAxis only reads in componentDidMount — so on the render
that hydrates it has none to take and inherits the page's, giving a line box as
tall as the body's line-height. The end tick is then placed half a label below
the top of the chart's viewBox. With a 16px/1.6 body that box is 25.59375px
and the tick lands at 12.796875. getBBox() on the rendered <text> reports
something else entirely — a different element, in the SVG, at whatever
font-size the tick draws with — so halving that will never give you the
coordinate. The element the layout measured is not the one you are looking at.
The same span also decides where a tick label wraps, so a label can be measured
in one font and painted in another.
Separately, watch for module-level counters. Recharts names its <clipPath>
from one, and an SSR worker serves many requests, so the counter keeps climbing
(recharts1-clip, recharts6-clip, …) while the browser always starts from one.
Passing a stable id to the chart pins it. Any library that keeps mutable
module-global state behaves this way under SSR.
None of this is specific to Pyxle — it applies to any framework that renders React on the server.
Verifying it hydrates
Run pyxle dev, open the browser console, and read all of it. React reports a
mismatch after its own DevTools notice, not before it, so a page that is broken
and a page that is clean look identical at the top of the console. Filter the
console for hydrat instead — that one word separates the three cases:
- Clean — no match. What is left is
[vite] connecting...,[vite] connected., the DevTools notice, and whatever your own page logs. Judge by the absence of a match, not by the console being empty — your app's own warnings are not hydration failures. Clear them anyway, so the console stays readable: a missingpublic/favicon.icologs a 404 on every load, and noise you have taught yourself to scroll past is noise you will scroll past again. - An attribute mismatch —
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. - A text mismatch — an uncaught
Error: Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. - A structure mismatch — an uncaught
Error: Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client.textandHTMLare the only words that differ between these two, and the second is the one an entry animation produces.
All three messages continue with the component path and a +/- diff of the two
renders, which is what tells you the prop to pin and the component to pin it on.
That is the only test that finds both kinds. A production build is not a substitute:
- An attribute mismatch is completely silent in production. Nothing is logged, the page is interactive, and it behaves identically to a page with no mismatch at all.
- Comparing the production DOM against the server HTML does not find one either. React kept the server's value, so the two agree because of the bug.
- A text or structure mismatch does surface in production, as a thrown
minified React error —
#418for the wrapped-label case above — carrying no detail. That tells you a mismatch exists, not where.
So verify in pyxle dev, then treat the production build as a build, not a
check.
One symptom this check is not for: a page that renders correctly and responds to nothing. Neither mismatch does that. Both leave the page interactive — the text/structure one re-renders the whole root in the browser and you still end up with a working tree, just without the server render you paid for. A page that is genuinely inert never ran its JavaScript at all, which is a different problem with a different cause; see Which browsers the dev server trusts.
Staying responsive without ResponsiveContainer
<ResponsiveContainer> is the one you will miss. Replace it with a hook that
renders at a fixed width on the server and measures once mounted. The fallback
width is used for the server render and the first client render, so the two
agree and hydration stays clean:
export function useMeasuredWidth(fallback) {
const ref = useRef(null);
const [width, setWidth] = useState(fallback);
useEffect(() => {
const node = ref.current;
if (!node || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(([entry]) => {
setWidth(Math.round(entry.contentRect.width));
});
observer.observe(node);
return () => observer.disconnect();
}, []);
return [ref, width];
}const [chartBox, width] = useMeasuredWidth(880);
<div ref={chartBox}>
<ComposedChart width={width} height={340} data={data.days}>{/* … */}</ComposedChart>
</div>Anything derived from width — a tick interval, say — is safe too, because at
hydration time width is the fallback on both sides and only changes afterwards.
When a library cannot render on the server at all
Some libraries touch window or document at module scope and will never
server-render. Those belong in a <ClientOnly> boundary, which skips them during
SSR and mounts them in the browser — you lose the server-rendered markup for that
subtree, but nothing breaks. See Client Components.
shadcn/ui
shadcn/ui is a collection of components you copy into your project (not an installed dependency). Enable it when you scaffold:
pyxle init my-app --shadcn # implies Tailwind
# or answer "y" to the shadcn prompt in interactive initThe scaffold pre-configures everything shadcn needs — components.json,
jsconfig.json (the @ alias), lib/utils.js, and a Tailwind v4 stylesheet
with the shadcn theme tokens — so you don't need to run shadcn init. Add
components directly:
cd my-app
npm install
npx shadcn@latest add buttonThis drops components/ui/button.jsx into your project (JavaScript, not
TypeScript). Import it via the alias and use it in any page:
// pages/index.pyxl (JSX section)
import { Button } from '@/components/ui/button';
export default function Home() {
return <Button>Click me</Button>;
}pyxle build bundles it for production and it renders server-side like any
other component.
Verified flow. The steps above were verified end to end on Node 22 with
shadcn@latest: scaffold with--shadcn→npm install→npx shadcn@latest add button→ import via@/components/ui/button→pyxle build+pyxle serve. Because the scaffold ships a readycomponents.json, runningshadcn initis unnecessary — it would only offer to overwrite the config the scaffold already wrote.
Adding shadcn to an existing project
If you scaffolded without it, enable Tailwind first (see
Styling), then create a
components.json at your project root (npx shadcn@latest init can generate it
in JavaScript mode — answer no to TypeScript), pointing its
tailwind.css at your CSS entry (pages/styles/app.css) and its aliases at
your import alias.
Next steps
- Styling and Tailwind: Styling
- Browser-only libraries: Client Components
- Validate action bodies with Pydantic: Server Actions