Building a Browser-Based SQL Editor with sql.js and Next.js 16

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

murder by query image


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:

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.

refresh gif

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

  1. Create a new Next.js app named my-sql-editor
  2. cd my-sql-editor and start the dev server
  3. 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:

  1. sql-wasm.wasm
  2. sql-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.js depends 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:

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:

  1. User input gets concatenated into backend SQL queries
  2. The database has sensitive access
  3. The attacker can manipulate production data

In this case:

So classic SQL injection vulnerabilities are mostly irrelevant here.

nopower

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

  1. Query Timeouts - one option is running sql.js inside a Web Worker so heavy queries don’t block the main UI thread.
  2. Result Limits - adding row caps with LIMIT 100 either automatically or through validation.
  3. 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.

Sonia Lomo

© 2026 Sonia Lomo

LinkedIn 𝕏 GitHub