Michael Ouroumis logoichael Ouroumis

Building Interactive Code Playgrounds with Next.js

Browser window showing an interactive code playground with SQL query results displayed in a table

Interactive learning beats passive reading. That's the thesis behind every code playground I've built, and after months of developing browser-based execution environments for FreeAcademy, I wanted to share the technical details of how it all works.

This post covers the architecture behind three different playgrounds: SQL (using sql.js), Python (using Pyodide), and JavaScript. Each presents unique challenges around browser sandboxing, performance, and user experience.

Why Interactive Learning Beats Passive Reading

Before diving into the code, let's talk about why interactive playgrounds matter.

Traditional tutorials show code in static blocks:

SELECT * FROM users WHERE active = true;

The reader sees the syntax, maybe understands the concept, but doesn't experience it. There's no muscle memory, no debugging practice, no experimentation.

Interactive playgrounds change this. When learners can modify queries, see results instantly, and break things safely, retention skyrockets. They're not just reading about SQL—they're writing it.

That's why I built playgrounds for Interactive SQL Practice, Python Basics, and JavaScript Essentials on FreeAcademy.

The Architecture Overview

Each playground follows a similar pattern:

  1. Code Editor — Monaco Editor or CodeMirror for syntax highlighting and editing
  2. Execution Engine — Language-specific runtime (WebAssembly, native JS, or CDN-loaded interpreter)
  3. React Context — State management for loading status, execution results, and errors
  4. Sandbox Wrapper — Error boundaries and security constraints

Let's break down each language.

Building the SQL Playground

The SQL playground is perhaps the most impressive technically because it runs a full SQLite database in the browser. No server calls, no API—everything happens client-side.

The Tech Stack

  • sql.js — SQLite compiled to WebAssembly
  • CodeMirror — Code editor with SQL syntax highlighting
  • React Context — State management for database instance

How sql.js Works

sql.js compiles SQLite to WebAssembly using Emscripten. The entire database engine runs in the browser, with data stored in memory.

Here's the core initialization:

import initSqlJs from 'sql.js' const SQL = await initSqlJs({ locateFile: (file) => `https://sql.js.org/dist/${file}`, }) const db = new SQL.Database()

The locateFile callback tells sql.js where to find the WebAssembly binary. Once loaded, you have a full SQLite database ready to accept queries.

Setting Up the Schema

For educational purposes, I preload schemas with sample data. Here's a simplified example:

const schema = ` CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com'), ('Bob', 'bob@example.com'), ('Charlie', 'charlie@example.com'); ` db.run(schema)

Learners can immediately query this data without understanding database setup.

Executing Queries

Query execution is straightforward but needs careful error handling:

const executeQuery = (sql: string) => { const startTime = performance.now() try { const results = db.exec(sql) const executionTime = performance.now() - startTime if (results.length === 0) { const changes = db.getRowsModified() return { columns: ['Result'], values: [[`Query executed. ${changes} row(s) affected.`]], executionTime, } } return { columns: results[0].columns, values: results[0].values, executionTime, } } catch (err) { return { error: err.message } } }

The db.exec() method returns an array of result sets (for multi-statement queries), while db.getRowsModified() reports changes from INSERT/UPDATE/DELETE statements.

React Context for State Management

I wrap the SQL functionality in a React Context:

const SQLContext = createContext(null) export function SQLProvider({ children }) { const [isReady, setIsReady] = useState(false) const [isLoading, setIsLoading] = useState(true) const dbRef = useRef(null) useEffect(() => { const initDB = async () => { const SQL = await initSqlJs({ locateFile: (file) => `https://sql.js.org/dist/${file}`, }) dbRef.current = new SQL.Database() dbRef.current.run(schema) setIsReady(true) setIsLoading(false) } initDB() }, []) // ... executeQuery, resetDatabase methods return ( <SQLContext.Provider value={{ isReady, isLoading, executeQuery }}> {children} </SQLContext.Provider> ) }

This pattern keeps the database instance alive across component renders and provides a clean API for the playground UI.

Try the SQL playground yourself: Interactive SQL Practice

Building the Python Playground

Running Python in the browser is more complex than SQL. There's no native Python interpreter in browsers, so we need to bring one in.

Enter Pyodide

Pyodide is the CPython interpreter compiled to WebAssembly. It's a full Python 3.11 runtime that runs in the browser, complete with the standard library and support for popular packages like NumPy and Pandas.

Loading Pyodide

Pyodide is larger than sql.js (~10MB), so loading requires patience:

// Load Pyodide from CDN const script = document.createElement('script') script.src = 'https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js' document.head.appendChild(script) await new Promise((resolve) => { script.onload = resolve }) const pyodide = await loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.24.1/full/', })

The first load is slow (several seconds), but subsequent visits benefit from browser caching.

Capturing Output

Python's print() function writes to stdout, but there's no stdout in browsers. The solution is to redirect it:

import sys from io import StringIO class OutputCapture: def __init__(self): self.outputs = [] def write(self, text): if text: self.outputs.append(text) def flush(self): pass def get_output(self): result = ''.join(self.outputs) self.outputs = [] return result _output_capture = OutputCapture() sys.stdout = _output_capture sys.stderr = _output_capture

This Python code runs once during initialization. All subsequent print() calls get captured and can be retrieved via _output_capture.get_output().

Executing Python Code

With stdout captured, execution is simple:

const executePython = async (code: string) => { const startTime = performance.now() try { await pyodide.runPythonAsync('_output_capture.outputs = []') const returnValue = await pyodide.runPythonAsync(code) const output = await pyodide.runPythonAsync('_output_capture.get_output()') return { output, error: null, executionTime: performance.now() - startTime, returnValue, } } catch (err) { return { output: '', error: err.message, executionTime: performance.now() - startTime, } } }

Notice the use of runPythonAsync—this is crucial for code that might use async features or take a long time to execute.

Try Python in your browser: Python Basics

Building the JavaScript Playground

JavaScript is the easiest playground to build because the browser is the JavaScript runtime. But sandboxing becomes critical.

The Sandboxing Challenge

Running arbitrary user code is dangerous. Users could:

  • Access the global window object
  • Make network requests
  • Modify the DOM
  • Access localStorage or cookies

The solution is controlled execution with new Function():

const executeJS = (code: string) => { const outputs = [] const customConsole = { log: (...args) => outputs.push(args.map(formatValue).join(' ')), error: (...args) => outputs.push('Error: ' + args.map(formatValue).join(' ')), warn: (...args) => outputs.push('Warning: ' + args.map(formatValue).join(' ')), } try { const sandboxedFunction = new Function( 'console', `"use strict"; ${code}` ) const returnValue = sandboxedFunction(customConsole) return { output: outputs.join('\n'), error: null, returnValue, } } catch (err) { return { output: outputs.join('\n'), error: err.message, } } }

Key sandboxing techniques:

  1. "use strict" — Prevents silent errors and dangerous practices
  2. Custom console — Captures output without accessing the real console
  3. new Function() — Creates a new scope without access to local variables

This isn't perfect isolation (the code can still access globals), but it's sufficient for educational playgrounds where the goal is learning, not security.

Try JavaScript exercises: JavaScript Essentials

The Code Editor: CodeMirror

All playgrounds use CodeMirror for the editor. It provides:

  • Syntax highlighting for each language
  • Line numbers
  • Keyboard shortcuts
  • Theme support
import CodeMirror from '@uiw/react-codemirror' import { sql } from '@codemirror/lang-sql' import { python } from '@codemirror/lang-python' import { javascript } from '@codemirror/lang-javascript' function Editor({ language, value, onChange }) { const extensions = { sql: [sql()], python: [python()], javascript: [javascript()], } return ( <CodeMirror value={value} height="200px" extensions={extensions[language]} onChange={onChange} theme="dark" basicSetup={{ lineNumbers: true, highlightActiveLine: true, }} /> ) }

CodeMirror is lightweight and performant, making it ideal for embedding in lessons.

Challenges and Solutions

Challenge 1: Initial Load Time

WebAssembly runtimes are large. sql.js is ~1MB, Pyodide is ~10MB. The first load is slow.

Solution: Lazy loading and loading indicators.

if (isLoading) { return ( <div className="loading"> Loading Python engine... <span className="hint">(This may take a few seconds)</span> </div> ) }

Setting expectations helps. Users understand that a full Python runtime takes time to load.

Challenge 2: Memory Management

In-browser databases and interpreters consume memory. Long-running sessions can become sluggish.

Solution: Reset functionality.

const resetDatabase = async () => { db.close() const newDb = new SQL.Database() newDb.run(schema) dbRef.current = newDb }

A "Reset" button gives users a fresh environment without reloading the page.

Challenge 3: Error Messages

Runtime errors often include internal stack traces that confuse learners.

Solution: Clean up error messages.

const cleanError = errorMessage .replace(/File "<exec>", /g, '') .replace(/PythonError: /g, '')

Remove internal references, keep the meaningful part of the error.

Challenge 4: Browser Compatibility

WebAssembly is widely supported, but edge cases exist.

Solution: Error boundaries and fallback messages.

function ErrorBoundary({ children, componentName }) { // ... error boundary logic if (hasError) { return ( <div className="error"> {componentName} failed to load. Please try refreshing. </div> ) } return children }

Graceful degradation is better than a broken page.

Performance Tips

  1. Lazy load runtimes — Don't load Pyodide until the user reaches a Python lesson
  2. Use refs for heavy objects — Keep database instances in useRef, not useState
  3. Debounce execution — Don't run code on every keystroke
  4. CDN caching — Host WASM files on CDNs with proper cache headers
  5. Code splitting — Each playground is its own chunk, loaded on demand

Try Them Live

All these playgrounds are available on FreeAcademy:

Every course is completely free. No account required to start learning.

Conclusion

Building interactive code playgrounds is technically challenging but incredibly rewarding. When learners can experiment, fail safely, and see results instantly, education becomes more effective.

The key technologies:

  • sql.js for SQL execution via WebAssembly
  • Pyodide for Python in the browser
  • Native JS with sandboxing for JavaScript
  • CodeMirror for a professional editing experience
  • React Context for state management

If you're building educational content, consider adding interactive elements. The upfront investment pays off in learner engagement and retention.


Questions about implementing your own code playground? Get in touch.

Enjoyed this post? Share: