Build Your Own Code Playground
10 min readWhether you use Codesandbox, Codepen, or another playground for testing code, you may have wondered how they work under the hood. In this post, we'll build a simplified code playground to test React code.
It wasn’t until Codepen came on the scene that I really started to utilize the power of online code playgrounds. I’d previously used JSFiddle, but the excellent collection of projects in Codepen caught my attention, and it started to change the way I code. Fast forward to today, I rely primarily on Codesandbox for the ease of mimicking the production code I’m used to and being able to prototype ideas quickly.
What We'll Build
Curiously, I’ve always wondered how these products worked and recently needed to build a React preview environment. Today, we’ll create a simple version of these preview environments in NextJS to understand better how they work. Follow along at the GitHub repo to see a working example. Let’s get started!
Getting Started
We need to start with an environment for creating examples. To do this, we’ll start with a simple NextJS project, but note the same will mostly apply to other frameworks like Gatsby and even beyond React.
Install the relevant dependencies in a new directory:
1yarn init2yarn add next react react-dom @types/react @types/react-dom typescript
We’ll create two pages, one for the preview and one for rendering the editor and the preview. The index page will render the preview in an iframe allowing us to preview things like window events based on the size of the preview rather than the page we render it in.
1import * as React from 'react'23const initialCodeString = `4import React from 'react'56export default function App() {7 return (8 <div>9 <h1>Hello Playground</h1>10 <h2>Start editing to see some magic happen!</h2>11 </div>12 )13}14`.trim()1516export default function Index() {17 const [code, setCode] = React.useState(initialCodeString)18 return (19 <div20 style={{21 display: 'grid',22 gridTemplateColumns: '1fr 1fr',23 minHeight: '100vh',24 }}25 >26 <textarea27 spellCheck="false"28 value={code}29 onChange={(event) => setCode(event.target.value)}30 />31 <Preview />32 </div>33 )34}3536function Preview() {37 return <iframe src="/preview" />38}
For now, we’ll leave the preview page empty and fill it in as we go along.
1export default function Preview() {2 return null3}
Getting Code
To start, we need to get the code we want to render. We’ll do this by allowing our preview page to accept a compressed code string through a query parameter called code
that we can access using next/router. We do this using the base64-url library, which decodes the query parameter to access the code string every time the query changes. Shoutout to Playroom for the inspiration for this approach!
1yarn add base64-url
1import * as React from 'react'2import { decode } from 'base64-url'3import { useRouter } from 'next/router'45export default function Preview() {6 const [code, setCode] = React.useState('')7 const [preview, setPreview] = React.useState(null)8 const router = useRouter()910 /** Decode "code" query parameter */11 useEffect(() => {12 if (router.query.code) {13 setCode(decode(router.query.code as string))14 }15 }, [router.query.code])1617 return preview18}
Now back at our index page, we can hook up our Preview
component by encoding the incoming code string and passing it to the preview page as a query parameter:
1import { encode } from 'base64-url'23export default function Index() {4 ...5 <Preview code={code} />6 ...7}89function Preview({ code = '' }) {10 return <iframe src={`/preview?code=${encode(code)}`} />11}
If you inspect the iframe at this point, you’ll see that it’s passing in a new compressed string on every change to the textarea.
Executing Code
Now that we have a code string, we need to execute it and get the result. We must compile our code at runtime since we want to write imports and exports with other modern JavaScript. This is similar to what we would need in a build step for a library or application. We’ll use SWC to accomplish this, a performant compiler we can use to transpile our code for the browser. We’ll use the official playground as inspiration for most of the following code.
Load SWC
First, we need to load the SWC web package, which we’ll dynamically in a new transformCode
utility. This way, the library will load after the initial page load:
1yarn add @swc/wasm-web
1let swc = null23export async function transformCode(code: string) {4 if (swc === null) {5 const module = await import('@swc/wasm-web')6 await module.default()7 swc = module8 }9}
Transform Code
Now that we have an swc
variable, we can use transformSync with a simple configuration that compiles TypeScript and JSX code:
1export async function transformCode(codeString: string) {2 if (swc === null) {3 const module = await import('@swc/wasm-web')4 await module.default()5 swc = module6 }7 return swc.transformSync(codeString, {8 filename: 'index.tsx',9 jsc: {10 parser: {11 syntax: 'typescript',12 tsx: true,13 },14 },15 module: {16 type: 'commonjs',17 },18 }).code19}
Execute Code
Now we’re ready to execute code and get the result. We’ll add a new executeCode
utility for this in the same file:
1export async function executeCode(2 codeString: string,3 dependencies: Record<string, unknown>4) {5 const transformedCode = await transformCode(codeString)6 const exports: Record<string, unknown> = {}7 const require = (path) => {8 if (dependencies[path]) {9 return dependencies[path]10 }11 throw Error(`Module not found: ${path}.`)12 }13 const result = new Function('exports', 'require', transformedCode)1415 result(exports, require)1617 return exports.default18}
A bit is going on here, so let’s break it down:
- Transform the incoming code string into a string of JavaScript that we can execute.
- Create a dynamic function using the new Function constructor to execute the transformed code. Notice, we use the CommonJS variables here, like
require
, which handles loading any dependencies of this code string. - Lastly, we execute the dynamic function and return the exported result.
Congrats, you made it! 🥳 We now have a very simple bundler. Now, we’re ready to use our new utility to execute the code and get the result of the preview:
1import * as React from 'react'2import { decode } from 'base64-url'3import { useRouter } from 'next/router'4import { executeCode } from '../utils/execute-code'56export default function Preview() {7 const [code, setCode] = React.useState('')8 const [loading, setLoading] = React.useState(false)9 const [error, setError] = React.useState(null)10 const [preview, setPreview] = React.useState(null)11 const router = useRouter()1213 /** Decode "code" query parameter */14 React.useEffect(() => {15 if (router.query.code) {16 setCode(decode(router.query.code as string))17 }18 }, [router.query.code])1920 /** Execute preview to render */21 React.useEffect(() => {22 if (code === null) return2324 setError(null)25 setLoading(true)2627 executeCode(code, { react: React })28 .then((Preview: React.ComponentType) => {29 setPreview(<Preview />)30 })31 .catch((error) => {32 setError(error.toString())33 })34 .finally(() => {35 setLoading(false)36 })37 }, [code])3839 return (40 <>41 {loading ? 'Loading preview...' : preview}42 {error}43 </>44 )45}
If everything went well, we should now see a preview of our code. And if there is an error, we’ll show the error message.
Loading Dependencies
We aren’t loading any dependencies here, but we can add any libraries we’d like. For example, we could add framer-motion
and then have access to use that library in our code string:
1import * as React from 'react-dom'2import * as ReactDOM from 'react-dom'3import * as FramerMotion from 'framer-motion'4import { executeCode } from '../utils/execute-code'56const codeString = `7import { motion } from 'framer-motion'89export default function App() {10 return (11 <motion.div animate={{ rotate: 360 }} />12 )13}14`1516executeCode(codeString, {17 react: React,18 'framer-motion': FramerMotion,19}).then((App) => {20 ReactDOM.render(<App />, document.getElementById('root'))21})
Also, notice that previously when we created our executeCode
utility, we got the result from exports.default
after calling the function. Take note App
is just a standard React component now and will be compiled further to React.createElement
. We could do that here if we wanted to support multiple exports, although sticking to the default export works well for simple examples.
Better Performance
You should now see the preview of your code update when changing code in the textarea
. However, performance is not that great. We’re reloading the iframe on every keystroke, which causes things to feel sluggish. While this is nice for our initial rendering of the preview, it’s not great for performance when continually updating.
Message Passing
We can utilize a remarkable API of browsers called postMessage. The postMessage
API allows us to send messages from one window to another. We can use this to send the code to the iframe and execute it much faster than updating the query.
We can easily add this feature by modifying the preview page to listen for messages from the iframe and then update the preview:
1function Preview() {2 ...34 React.useEffect(() => {5 function handleMessage(event: MessageEvent) {6 if (7 window.location.origin === event.origin &&8 event.data.type === 'preview'9 ) {10 setCode(decode(event.data.code))11 }12 }1314 window.addEventListener('message', handleMessage)1516 return () => {17 window.removeEventListener('message', handleMessage)18 }19 }, [])2021 ...22}
For security purposes, you should further sanitize the incoming data and add an identifier to make sure you’re dealing with code you trust. Also, as Ori mentions here, in a real-world situation you most likely want to run this playground on a subdomain to prevent sharing localStorage
.
Now, similarly as before, we’ll add a useEffect
hook in our index
page to send the code to the iframe:
1function Preview({ code }) {2 const frameRef = React.useRef<HTMLIFrameElement>(null)3 const frameSource = React.useRef(null)45 /**6 * Only set the source of the iframe on the initial mount since we use message7 * passing below for subsequent updates.8 */9 if (frameSource.current === null) {10 frameSource.current = `/preview?code=${encode(code)}`11 }1213 React.useEffect(() => {14 frameRef.current.contentWindow.postMessage({15 code: encode(code),16 type: 'preview',17 })18 }, [code])1920 return <iframe ref={frameRef} src={frameSource.current} />21}
Now we can send the code directly to the iframe and have it execute much faster than updating the query.
Shareable Links
As a bonus, since we started with query params, we can easily add shareable links to our code. This is a great way to share code with others and load the iframe with the exact code you want. I’ll leave this one as an exercise for you to implement on your own 😁
Conclusion
Compared to full-featured bundlers, this was a more simple look at creating a preview environment that renders any arbitrary React code. Depending on your needs, something like Sandpack or Javascript Playgrounds is probably easier to get going. Still, now you hopefully understand how to customize these libraries or create your own.
Resources
Huge thank you to the resources listed throughout this post and below, which helped me understand the process behind bundling and executing code in the browser.
Build your own interactive JavaScript playground
- javascript
- react