Skip to main content

React 19 with JSX, pure client (no build)

· 5 min read

I love simplicity when it comes to software, and for web development I try to get rid of servers whenever possible. For all APIs, libraries or frameworks I use, I want to have a minimalistic HTML page that instantiates and sets up the bare minimum for that tool to work, and that I can load from localhost — no web server required. Those files I use as boilerplate and reference, cloning them for whatever experiment I want to do. Of course, web servers and build steps are great for all sorts of reasons, but sometimes you just want to play around or develop a quick proof of concept.

A fancy thumbnail I created with ChatGPT

React 19 is almost eight months old now. However, the stand-alone HTML page suggested in the official documentation to “try React locally on your computer” is still using version 18:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Hello World</title>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
function MyApp() {
return <h1>Hello, world!</h1>;
}
const container = document.getElementById('root');
const root = ReactDOM.createRoot(container);
root.render(<MyApp />);
</script>
</body>
</html>

Naively, I copied-and-pasted the page, then removed both @18 bits above so that Unpkg would return @latest by default (in this case, React 19). But that URL failed.

What was happening? After all, I had done something similar in the past: loading native JS modules dynamically on the client, those modules in turn importing React 18, the whole thing working with no build steps… I consulted with ChatGPT, but it lied to me and gave me a “solution” that could not work.

It turns out there were two issues.

First, UMD builds aren't provided any more: starting with React 19, an “ESM-based CDN such as esm.sh” is recommended instead. A blog post by Peter Kellner gave me the hint. At the time, react@9.0.0 did not exist in npm yet, so Peter used a beta build. But today, we can fetch the production release.

And so the original boilerplate goes from this:

<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>

to this:

<script type="module">
import React from "https://esm.sh/react?dev"; // ≡ latest ≡ 19
import ReactDOM from "https://esm.sh/react-dom/client?dev"; // ≡ latest ≡ 19
</script>

The second issue was that after this change, JSX support was broken, and thus the page didn't work as a fully-fledged, realistic React playground yet. Indeed, both in my aforementioned “Theming PoC” experiments and in Peter's post above, JSX syntax is conspicuously absent! How come?

Turns out that Babel automatically transpiles script elements of type="text/babel" — and only those. Our JSX markup needs to be inside a <script type="text/babel"> tag. At the same time, we need type="module" to be able to load React (v19) dependencies as ES modules…

After some tests and some thinking, I came up with this (ahem) slightly controversial solution:

<script type="module">
import React from "https://esm.sh/react?dev";
import ReactDOM from "https://esm.sh/react-dom/client?dev";
window.__tmp = { React, ReactDOM };
</script>

<script type="text/babel">
const { React, ReactDOM } = window.__tmp;
delete window.__tmp;
// <YourJSXstuffGoesHere />
</script>

Yes: I am writing to the global scope from within a module; I am polluting window. But hey, it's a super-quick exchange of data, all encapsulated into a single property, and I'm cleaning after myself!

Here is the final version, with the latest React and React DOM on the browser, with JSX support, no build needed:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Hello World</title>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module">
import React from "https://esm.sh/react?dev";
import ReactDOM from "https://esm.sh/react-dom/client?dev";
window.__tmp = { React, ReactDOM };
</script>
<script type="text/babel">
const { React, ReactDOM } = window.__tmp;
delete window.__tmp;
function MyApp() {
return <h1>Hello, world!</h1>;
}
const container = document.getElementById('root');
const root = ReactDOM.createRoot(container);
root.render(<MyApp />);
</script>
</body>
</html>

You can also use this equivalent CodePen.

(Needless to say, this is not the set-up that you want to deploy your precious application for end users!)