What if your React code could render as a PNG, PDF, static HTML, or fully interactive webapp just by changing the URL? I got a working demo! š
got bored during my layover
– TanStack Start app
– css-in-js styling
– regular useQuery data loading
– independent reusable componentsRenders as fully interactive webapp, png, or pdf.
Just change the url š¤ pic.twitter.com/zUALoym7heā Swizec Teller (@Swizec) January 22, 2025
Video for now because I’m using a free tier API token for data. Keep reading for screenshots. You can see the full code here: https://github.com/Swizec/react2png
I wanted a way to take regular React components and render as PNG, PDF, static HTML, or interactive webapp. This should happen at the server response level with zero thought from product engineers building individual components.
The components need to support css-in-js (JoyUI) and independently load their own data with useQuery. Once loaded in a browser context, they should hydrate into a regular React app.
Interactive webapp
When loaded in the browser, you get a fully interactive webapp. Buttons are clickable, React Query keeps data fresh, you can change state, everything works.

Static HTML
If you right click + view source, you can see the app starts as static HTML with initial data already present.

PNG
Add ?=png
to the URL and you get a PNG render of the initial app state. All in one request rendered on the server. Your browser gets the image and nothing else.

Components have all dynamic data present, but there’s a bug with font color. I must’ve missed a detail with setting up JoyUI styling for SSR.
Add ?=pdf
and you get a PDF. Same deal as before: The browser gets just the PDF all in 1 request. Data is there.

Here the styling loses backgrounds, but I think that’s on purpose. JoyUI adapting to print styles because PDFs are for printing.
This whole thing is based on TanStack Start and Tanner’s awesome work in bringing SSR and server actions to TanStack Router.
Like I wrote a year ago, TanStack Router lets you write React apps that are URL-deterministic. Every time you go to a URL, you get the same UI state. You write your components as usual and the router coordinates data loading.
Now with SSR, your server can render UI in memory and return the resulting HTML with initial data pre-loaded. Then your code can fetch fresh data as needed and act as a regular React apps.
We can hook into that SSR step to return a PNG or PDF š
A normal component
It starts with a normal React component.
const StockCard = ({ stonk }: { stonk: string }) => {
const { data, isLoading, isError, isRefetching } = useQuery({
queryKey: ["stonks", stonk],
queryFn: () => {
return getStonk({ data: { stonk } });
},
});
const value = isLoading ? (
) : isError ? (
Error
) : data.high ? (
${data.high}
) : (
No data
);
return (
// ...
{stonk}
{value}
// ...
);
};
We have loading states, error states, and no data states. React Query to fetch data. Normal rendering to display the card.
The route
We render a few of these in a route and pre-load data in the loader
.
export const Route = createFileRoute("/")({
component: Home,
loader: async ({ context }) => {
const AAPL = await getStonk({ data: { stonk: "AAPL" } });
const MSFT = await getStonk({ data: { stonk: "MSFT" } });
await context.queryClient.ensureQueryData({
queryKey: ["stonks", "AAPL"],
queryFn: () => AAPL,
});
await context.queryClient.ensureQueryData({
queryKey: ["stonks", "MSFT"],
queryFn: () => MSFT,
});
},
});
function Home() {
return (
// ...
// ...
);
}
The route renders a Home
component and uses ensureQueryData
to pre-load data from an API before rendering. This ensures that our initial UI happens without loading spinners.
Preloading is crucial for nice PNGs and PDFs. Makes the UX nicer for users too.
The SSR handler
Last step is a switching SSR handler that chooses behavior based on the ?f
query param.
const switchingHandler: typeof defaultRenderHandler = async ({
request,
router,
responseHeaders,
}) => {
const url = new URL(request.url);
const format = url.searchParams.get("f");
if (format === "png") {
return pngRenderHandler({ request, router, responseHeaders });
} else if (format === "pdf") {
return pdfRenderHandler({ request, router, responseHeaders });
} else {
return defaultRenderHandler({ request, router, responseHeaders });
}
};
export default createStartHandler({
createRouter,
getRouterManifest,
})(switchingHandler);
When requests come in, the server will choose pngRenderHandler
, pdfRenderHandler
, or defaultRenderHandler
to respond. Each handler server-side renders the React app and returns a response.
The defaultRenderHandler
returns HTML. The HTML contains a script tag that runs in browser and hydrates into a React app.
The pngRenderHandler
takes that HTML and turns it into PNG before returning. This is the fun part š
const pngRenderHandler: typeof defaultRenderHandler = async ({
router,
responseHeaders,
}) => {
let html = ReactDOMServer.renderToString( );
html = html.replace(
`