I recently finished the Data Engineer Associate Certification coursework by DataCamp and needed a way to practise my SQL before attempting the exam.
After some Googling, I found a few resources that genuinely made SQL practice fun again:
There are already many SQL games out there, but I wanted something that would actually keep my attention. I’m a BookTok girlie who loves a good thriller, so naturally my brain went:
“What if SQL practice felt like solving a psychological thriller?”
That idea eventually became a browser-based whodunit game inspired by some of my favourite thriller novels.
The fun part? The SQL editor runs directly in the browser using sql.js and Next.js 16 — no backend database required.
This article only scratches the surface of the implementation. The actual project evolved into a fully interactive SQL mystery game with story-driven investigations, clues, and a much more polished editor experience.
If you’re curious to see the finished version, check it out here: Murder By Query

Why sql.js?
sql.js is essentially SQLite compiled to WebAssembly.
Instead of connecting to a traditional database server, the entire SQLite engine runs directly in the browser. This means you can execute real SQL queries locally without needing a backend or persistent database connection.
You can check out their repo here: sql.js GitHub Repository
This setup was perfect for a SQL mystery game because:
- Queries execute instantly
- No server costs
- No user authentication required
- Every player gets an isolated database session
- Easy deployment on platforms like Vercel
- Most importantly, shields me from handling SQL injections
How sql.js Works in the Browser
The architecture looks something like this:
User Query
↓
SQL Editor
↓
sql.js (SQLite WASM Engine)
↓
In-memory SQLite Database
↓
Query Results
A few important implications:
a) Everything is in-memory
Unless you explicitly save/export the database, everything disappears on refresh.

This was actually useful for my game because each player session resets naturally.
b) No server roundtrip
Queries execute entirely on the client, which makes interactions feel instant.
For a mystery game where players are rapidly testing theories, this responsiveness matters a lot.
c) You still write real SQL
This is not a fake parser or simplified syntax layer.
Players use actual SQLite syntax:
SELECT name
FROM suspects
WHERE alibi IS NULL;
That means the game doubles as legitimate SQL practice.
Setting Up sql.js in Next.js 16
1. Set up Next.js 16
- Create a new Next.js app named
my-sql-editor cd my-sql-editorand start the dev server- Visit
http://localhost:3000
pnpm create next-app@latest my-sql-editor
cd my-sql-editor
pnpm dev
2. Install sql.js
Run the command below on your terminal to install sql.js:
pnpm add sql.js
Since we’re using TYpescript for this guide, you have to install type definitions for sql.js as a dev dependency.
pnpm add --save-dev @types/sql.js
3. Create a SQL client utility
Create a lib/sql.ts file on your root directory then add the code below:
// lib/sql.ts
import initSqlJs, { Database, SqlJsStatic } from "sql.js";
let SQL: SqlJsStatic = null;
let db: Database | null = null;
export async function initDB() {
if (db) return db;
SQL = await initSqlJs({
locateFile: (file) => `/${file}`,
});
db = new SQL.Database();
return db;
}
4. Copy the WASM files
sql.js ships with .wasm files that need to be publicly accessible, specifically:
sql-wasm.wasmsql-wasm-browser.wasm
Copy these 2 files from:
node_modules/sql.js/dist/
into:
public/
Or simply run the command below to achieve this:
cp node_modules/sql.js/dist/sql-wasm-browser.wasm public/ && cp node_modules/sql.js/dist/sql-wasm.wasm public/
You should end up with these files:
public/sql-wasm.wasm
public/sql-wasm-browser.wasm
5. Create the database in the browser
Now let’s set up our main page component. Replace the default app/page.tsx with:
// app/page.tsx
"use client";
import { useEffect, useState } from "react";
import { initDB } from "@/lib/sql";
export default function Home() {
//1. State to track if database is ready
const [isDbReady, setIsDbReady] = useState(false);
useEffect(() => {
const setupDatabase = async () => {
try {
//2. Initialize your SQLite database
const db = await initDB();
//3. Create a sample "suspect" table in the database
db.run(`
CREATE TABLE IF NOT EXISTS suspect (
id INTEGER PRIMARY KEY,
name TEXT,
age INTEGER,
role TEXT,
weapon TEXT,
location TEXT
);
`);
//4. Insert sample data to the "suspect" table
db.run(`
INSERT INTO suspect (name, age, role, weapon, location) VALUES
('John Smith', 45, 'Butler', 'Candlestick', 'Library'),
('Jane Doe', 32, 'Housekeeper', 'Rope', 'Kitchen'),
('Bob Wilson', 50, 'Chef', 'Knife', 'Kitchen'),
('Alice Brown', 28, 'Gardener', 'Lead Pipe', 'Garden');
`);
//5. Mark database as ready
setIsDbReady(true);
} catch (err) {
console.error("Failed to initialize database:", err);
}
};
//6. Call the setup function
setupDatabase();
}, []);
//7. Show loading state while database initializes
if (!isDbReady) {
return <div>Initializing database...</div>;
}
//8. Render main app when ready
return <div>Database Ready! Start building your SQL editor.</div>;
}
Congratulations! 🎉 You’ve just created a database that exists in a quantum state — both real and not real until you refresh the page.
Note: Because
sql.jsdepends on browser APIs and WebAssembly, it must run client-side. Always use the"use client"directive when importing this utility.
Building the Browser SQL Editor
For this part, you can choose to use packages that offer a more “IDE” experience like @monaco-editor/react. However, you’re better than! You love getting your hands dirty.
You can build a lightweight SQL editor with:
- Native textarea for query input
- Syntax highlighting (optional)
- Keyboard shortcuts (Ctrl/Cmd + Enter to execute)
- Real-time query execution
- Results display in an HTML table
- Error handling
1. Creating the Query Editor Component
Create components/QueryEditor.tsx and copy the code below:
// components/QueryEditor.tsx
"use client";
import { useState } from "react";
interface QueryEditorProps {
onExecute: (query: string) => void;
isLoading?: boolean;
error?: string;
initialQuery?: string;
}
export function QueryEditor({
onExecute,
isLoading,
error,
initialQuery,
}: QueryEditorProps) {
//1. State for the current query text in the editor
const [query, setQuery] = useState(initialQuery || "SELECT * FROM suspect;");
//2. Handle query execution
const handleExecute = () => {
if (query.trim()) {
onExecute(query);
}
};
//3. Handle keyboard shortcuts
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
handleExecute();
}
};
return (
<div className="query-editor">
<h3>SQL Query Editor</h3>
{/* 4. Textarea to write queries */}
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
rows={10}
placeholder="SELECT * FROM suspect;"
spellCheck={false}
/>
{/* 5. Display error message if present */}
{error && (
<div className="error">
<span>⚠️</span>
<span>{error}</span>
</div>
)}
{/* 6. Execute button with loading state */}
<button onClick={handleExecute} disabled={isLoading}>
{isLoading ? "Executing..." : "Execute Query"}
</button>
</div>
);
}
2. Creating the Results Viewer Component
Create components/ResultsViewer.tsx and copy the code below:
// components/ResultsViewer.tsx
"use client";
interface ResultsViewerProps {
rows: any[];
columns: string[];
isLoading?: boolean;
}
export function ResultsViewer({ rows, columns, isLoading }: ResultsViewerProps) {
//1. Show loading spinner
if (isLoading) {
return <div>Loading results...</div>;
}
//2. Show empty state
if (columns.length === 0) {
return <div>Execute a query to see results</div>;
}
//3. Render results table
return (
<div className="results-viewer">
<h3>Query Results ({rows.length} rows)</h3>
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={col}>{col}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr key={idx}>
{columns.map((col) => (
<td key={`${idx}-${col}`}>
{String(row[col] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
3.Executing User Queries
Now let’s wire everything together in app/page.tsx:
// app/page.tsx
"use client";
import { useEffect, useState } from "react";
import { QueryEditor } from "@/components/QueryEditor";
import { ResultsViewer } from "@/components/ResultsViewer";
import { initDB } from "@/lib/sql";
export default function Home() {
const [results, setResults] = useState<any[]>([]);
const [columns, setColumns] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>("");
const [isDbReady, setIsDbReady] = useState(false);
useEffect(() => {
const setupDatabase = async () => {
try {
//1. Initialize database connection
const db = await initDB();
//2. Create suspects table
db.run(`
CREATE TABLE IF NOT EXISTS suspect (
id INTEGER PRIMARY KEY,
name TEXT,
age INTEGER,
role TEXT,
weapon TEXT,
location TEXT
);
`);
//3. Check if table has data
const count = db.exec("SELECT COUNT(*) as count FROM suspect;");
if (count[0]?.values[0][0] === 0) {
db.run(`
INSERT INTO suspect (name, age, role, weapon, location) VALUES
('John Smith', 45, 'Butler', 'Candlestick', 'Library'),
('Jane Doe', 32, 'Housekeeper', 'Rope', 'Kitchen'),
('Bob Wilson', 50, 'Chef', 'Knife', 'Kitchen'),
('Alice Brown', 28, 'Gardener', 'Lead Pipe', 'Garden');
`);
}
//4. Mark database as ready
setIsDbReady(true);
} catch (err) {
console.error("Failed to initialize database:", err);
setError("Failed to initialize database");
}
};
setupDatabase();
}, []);
//5. Handle query execution
const handleExecute = async (query: string) => {
setIsLoading(true);
setError("");
try {
//5.1. Get database instance
const db = await initDB();
//5.2. Execute the user's query
const result = db.exec(query);
//5.3. Process results if any
if (result && result.length > 0) {
//5.3.1. Extract column names
setColumns(result[0].columns);
//5.3.2. Transform array results to objects
setResults(
result[0].values.map((row: any[]) => {
const obj: any = {};
result[0].columns.forEach((col: string, idx: number) => {
obj[col] = row[idx];
});
return obj;
})
);
} else {
//5.3.3. Handle empty results
setColumns([]);
setResults([]);
if (query.trim().toUpperCase().startsWith("SELECT")) {
setError("Query returned no results");
}
}
} catch (err: any) {
//5.4. Handle query execution errors
setError(err.message || "Error executing query");
setColumns([]);
setResults([]);
} finally {
//5.5. Always clear loading state
setIsLoading(false);
}
};
//6. Show loading state while DB initializes
if (!isDbReady) {
return <div>Initializing database...</div>;
}
//7. render the full application
return (
<div>
<h1>SQL Query Editor</h1>
<QueryEditor
onExecute={handleExecute}
isLoading={isLoading}
error={error}
/>
<ResultsViewer
rows={results}
columns={columns}
isLoading={isLoading}
/>
</div>
);
}
When the player submits a query, db.exec() returns arrays representing rows and columns:
// Example response from db.exec("SELECT name, age FROM suspect;")
[
{
columns: ["name", "age"],
values: [["John Smith", 45], ["Jane Doe", 32]],
},
];
The component then transforms this into objects and displays them in an HTML table.
Security Concerns
Running SQL written by users can sound a bit terrifying at first. But the architecture here reduces the security risks significantly.
Why SQL Injection Isn’t the Main Problem
Traditional SQL injection happens when:
- User input gets concatenated into backend SQL queries
- The database has sensitive access
- The attacker can manipulate production data
In this case:
- The database exists only in the user’s browser
- There is no server database
- Users are intentionally supposed to write SQL
- Queries never touch production infrastructure
So classic SQL injection vulnerabilities are mostly irrelevant here.
The user is only attacking their own temporary in-browser database.
They want to DROP TABLE suspects? Fine. Enjoy solving the mystery with zero suspects, genius.
Browser Tab Freezing / Memory Exhaustion
If you run a query that creates an infinite loop or millions of rows:
WITH RECURSIVE infinite(n) AS (
SELECT 1 UNION ALL SELECT n+1 FROM infinite
)
SELECT * FROM infinite;
This can consume massive memory and freeze the tab.
You can fix this by refreshing/closing the browser tab.
Mitigation Strategies
- Query Timeouts - one option is running
sql.jsinside a Web Worker so heavy queries don’t block the main UI thread. - Result Limits - adding row caps with
LIMIT 100either automatically or through validation. - Disable Dangerous Features - avoid exposing: File system APIs, SQLite extensions, Arbitrary imports. The database should be isolated and minimal.
Final Thoughts
This project accidentally became one of the most enjoyable ways I’ve ever practised SQL.
Instead of grinding endless GROUP BY questions, I was interrogating suspects, tracing alibis, and uncovering fictional conspiracies through queries.
And honestly? That made me want to keep learning.