Building Interactive Code Playgrounds with Next.js

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:
- Code Editor — Monaco Editor or CodeMirror for syntax highlighting and editing
- Execution Engine — Language-specific runtime (WebAssembly, native JS, or CDN-loaded interpreter)
- React Context — State management for loading status, execution results, and errors
- 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
windowobject - 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:
- "use strict" — Prevents silent errors and dangerous practices
- Custom console — Captures output without accessing the real console
- 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
- Lazy load runtimes — Don't load Pyodide until the user reaches a Python lesson
- Use refs for heavy objects — Keep database instances in
useRef, notuseState - Debounce execution — Don't run code on every keystroke
- CDN caching — Host WASM files on CDNs with proper cache headers
- Code splitting — Each playground is its own chunk, loaded on demand
Try Them Live
All these playgrounds are available on FreeAcademy:
- Interactive SQL Practice — Write queries against pre-loaded datasets
- Python Basics — Learn Python with instant feedback
- JavaScript Essentials — Practice JS in the browser
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.