The next evolution of SQLite is here! Read Announcement

How We Built the Turso Shell in the Browser

Jamie BartonJamie Barton
Cover image for How We Built the Turso Shell in the Browser

Ever wondered how to run a full SQLite database directly in the browser? That's exactly what we did with the Turso Shell demo. No backend required. Everything runs in your browser using WebAssembly.

The Turso Shell allows you to:

  • Execute SQL queries directly in the browser
  • Store data locally using OPFS (Origin Private File System)
  • Get syntax highlighting and autocomplete for SQL
  • See query results in a nice table format

Let me walk you through how we built it.

#Project Setup with Vite and Turso WASM

First, let's create a new Vite project with React and TypeScript:

npm create vite@latest turso-shell -- --template react-ts
cd turso-shell
npm install

Here's the critical part. Turso's WASM package has a special export for Vite that handles the WASM loading properly:

npm install @tursodatabase/database-wasm

The key thing to know: when using Vite, you need to import from @tursodatabase/database-wasm/vite instead of the default export. This works around a known Vite dev server issue with WASM module imports. The /vite export uses a conditional export in the package.json that points to a specialized version during development:

"./vite": {
  "development": "./dist/promise-vite-dev-hack.js",
  "default": "./dist/promise-default.js"
}

This only affects the Vite dev server, production builds work fine with the standard WASM loading approach.

Next, update your vite.config.ts to handle WASM files correctly:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    headers: {
      'Cross-Origin-Embedder-Policy': 'require-corp',
      'Cross-Origin-Opener-Policy': 'same-origin',
    },
  },
});

The COOP and COEP headers are essential for SharedArrayBuffer support, which Turso uses for better performance with OPFS — read more.

Now create a new file src/db.ts:

import { connect, Database } from '@tursodatabase/database-wasm/vite';

let db: Database | null = null;

export async function initDatabase() {
  if (db) return db;

  // Connect to a local database file stored in OPFS
  db = await connect('turso-shell.db');

  // Optional: Create a sample table for first-time users
  try {
    await db.execute(`
      CREATE TABLE IF NOT EXISTS query_history (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        query TEXT NOT NULL,
        executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
      )
    `);
  } catch (error) {
    console.error('Error creating table:', error);
  }

  return db;
}

export function getDatabase() {
  if (!db) {
    throw new Error('Database not initialized. Call initDatabase() first.');
  }
  return db;
}

That's pretty much it for setting up Turso in the Browser. Next, update src/App.tsx to initialize the database:

import { useEffect, useState } from 'react';
import { initDatabase } from './db';
import './App.css';

function App() {
  const [isDbReady, setIsDbReady] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    initDatabase()
      .then(() => setIsDbReady(true))
      .catch((err) => {
        console.error('Failed to initialize database:', err);
        setError(err.message);
      });
  }, []);

  if (error) {
    return (
      <div className="error-container">
        <h2>Failed to initialize database</h2>
        <p>{error}</p>
      </div>
    );
  }

  if (!isDbReady) {
    return (
      <div className="loading-container">
        <p>Initializing Turso database...</p>
      </div>
    );
  }

  return (
    <div className="app">
      <h1>Turso Shell</h1>
      {/* We'll add the shell interface here */}
    </div>
  );
}

export default App;

At this point, you should be able to run npm run dev and see the app initialize the database successfully.

#Building the Query Interface (without CodeMirror)

Before we add the fancy editor, let's build a working query interface with a simple textarea:

Create src/components/QueryExecutor.tsx:

import { useState } from 'react';
import { getDatabase } from '../db';

interface QueryResult {
  columns: string[];
  rows: unknown[][];
  rowsAffected?: number;
  lastInsertRowid?: number;
}

export function QueryExecutor() {
  const [query, setQuery] = useState('SELECT sqlite_version()');
  const [result, setResult] = useState<QueryResult | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [isExecuting, setIsExecuting] = useState(false);

  const executeQuery = async () => {
    if (!query.trim()) return;

    setIsExecuting(true);
    setError(null);
    setResult(null);

    try {
      const db = getDatabase();
      const stmt = db.prepare(query);

      // Execute the query
      const data = await stmt.all();

      // Get column names from the first row or use empty array
      const columns = data.length > 0 ? Object.keys(data[0]) : [];

      // Convert objects to arrays for table display
      const rows = data.map((row) =>
        columns.map((col) => row[col as keyof typeof row]),
      );

      setResult({
        columns,
        rows,
        rowsAffected: stmt.changes,
        lastInsertRowid: stmt.lastInsertRowid,
      });
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      setIsExecuting(false);
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    // Execute on Cmd+Enter or Ctrl+Enter
    if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
      e.preventDefault();
      executeQuery();
    }
  };

  return (
    <div className="query-executor">
      <div className="query-input-section">
        <textarea
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="Enter your SQL query here..."
          rows={8}
          disabled={isExecuting}
        />
        <button onClick={executeQuery} disabled={isExecuting || !query.trim()}>
          {isExecuting ? 'Executing...' : 'Execute Query'}
        </button>
        <small>
          Tip: Press Cmd+Enter (Mac) or Ctrl+Enter (Windows) to execute
        </small>
      </div>

      {error && (
        <div className="error-message">
          <strong>Error:</strong> {error}
        </div>
      )}

      {result && (
        <div className="query-results">
          <div className="result-info">
            {result.rowsAffected !== undefined && (
              <span>Rows affected: {result.rowsAffected}</span>
            )}
            {result.lastInsertRowid !== undefined &&
              result.lastInsertRowid > 0 && (
                <span>Last insert ID: {result.lastInsertRowid}</span>
              )}
          </div>

          {result.columns.length > 0 && (
            <div className="table-container">
              <table>
                <thead>
                  <tr>
                    {result.columns.map((col, idx) => (
                      <th key={idx}>{col}</th>
                    ))}
                  </tr>
                </thead>
                <tbody>
                  {result.rows.map((row, rowIdx) => (
                    <tr key={rowIdx}>
                      {row.map((cell, cellIdx) => (
                        <td key={cellIdx}>
                          {cell === null ? (
                            <span className="null-value">NULL</span>
                          ) : (
                            String(cell)
                          )}
                        </td>
                      ))}
                    </tr>
                  ))}
                </tbody>
              </table>
              <div className="row-count">
                {result.rows.length} {result.rows.length === 1 ? 'row' : 'rows'}
              </div>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

Pretty basic. let's continue. Inside App.tsx, update it to:

import { useEffect, useState } from 'react';
import { initDatabase } from './db';
import { QueryExecutor } from './components/QueryExecutor';
import './App.css';

function App() {
  const [isDbReady, setIsDbReady] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    initDatabase()
      .then(() => setIsDbReady(true))
      .catch((err) => {
        console.error('Failed to initialize database:', err);
        setError(err.message);
      });
  }, []);

  if (error) {
    return (
      <div className="error-container">
        <h2>Failed to initialize database</h2>
        <p>{error}</p>
      </div>
    );
  }

  if (!isDbReady) {
    return (
      <div className="loading-container">
        <p>Initializing Turso database...</p>
      </div>
    );
  }

  return (
    <div className="app">
      <header>
        <h1>Turso Shell</h1>
        <p>A SQLite database running entirely in your browser</p>
      </header>
      <main>
        <QueryExecutor />
      </main>
    </div>
  );
}

export default App;

Finally, add this to src/App.css to give it some style. We used Tailwind CSS in the demo, but you can use any (or no) CSS framework you prefer.

.app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
}

header {
  margin-bottom: 2rem;
  text-align: center;
}

.query-executor {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.query-input-section {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.query-input-section textarea {
  width: 100%;
  padding: 1rem;
  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
  font-size: 14px;
  border: 1px solid #ccc;
  border-radius: 4px;
  resize: vertical;
}

.query-input-section button {
  align-self: flex-start;
  padding: 0.75rem 1.5rem;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.query-input-section button:hover:not(:disabled) {
  background-color: #0056b3;
}

.query-input-section button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.query-input-section small {
  color: #666;
}

.error-message {
  padding: 1rem;
  background-color: #fee;
  border-left: 4px solid #c33;
  color: #c33;
}

.query-results {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.result-info {
  display: flex;
  gap: 1rem;
  font-size: 14px;
  color: #666;
}

.table-container {
  overflow-x: auto;
}

table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
}

th,
td {
  padding: 0.75rem;
  text-align: left;
  border-bottom: 1px solid #ddd;
}

th {
  background-color: #f5f5f5;
  font-weight: 600;
}

tr:hover {
  background-color: #f9f9f9;
}

.null-value {
  color: #999;
  font-style: italic;
}

.row-count {
  margin-top: 0.5rem;
  font-size: 14px;
  color: #666;
}

.loading-container,
.error-container {
  text-align: center;
  padding: 2rem;
}

At this point, you have a fully functional SQL shell! Try running some queries:

CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT);
INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com');
INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com');
SELECT * FROM users;

#Adding CodeMirror for Better UX

Now let's upgrade the textarea to a proper code editor with syntax highlighting. Install the following dependencies:

npm install @codemirror/state @codemirror/view @codemirror/commands
npm install @codemirror/lang-sql @codemirror/autocomplete
npm install @codemirror/theme-one-dark

Then create src/components/SqlEditor.tsx:

import { useEffect, useRef } from 'react';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { defaultKeymap, historyKeymap, history } from '@codemirror/commands';
import { sql } from '@codemirror/lang-sql';
import { autocompletion } from '@codemirror/autocomplete';
import { oneDark } from '@codemirror/theme-one-dark';

interface SqlEditorProps {
  value: string;
  onChange: (value: string) => void;
  onExecute: () => void;
  disabled?: boolean;
}

export function SqlEditor({
  value,
  onChange,
  onExecute,
  disabled,
}: SqlEditorProps) {
  const editorRef = useRef<HTMLDivElement>(null);
  const viewRef = useRef<EditorView | null>(null);

  useEffect(() => {
    if (!editorRef.current) return;

    const executeCommand = () => {
      onExecute();
      return true;
    };

    const state = EditorState.create({
      doc: value,
      extensions: [
        history(),
        sql(),
        autocompletion(),
        oneDark,
        EditorView.lineWrapping,
        EditorView.updateListener.of((update) => {
          if (update.docChanged) {
            onChange(update.state.doc.toString());
          }
        }),
        keymap.of([
          ...defaultKeymap,
          ...historyKeymap,
          {
            key: 'Mod-Enter',
            run: executeCommand,
          },
        ]),
        EditorView.editable.of(!disabled),
      ],
    });

    const view = new EditorView({
      state,
      parent: editorRef.current,
    });

    viewRef.current = view;

    return () => {
      view.destroy();
    };
  }, []); // Only run on mount

  // Handle disabled state changes
  useEffect(() => {
    if (viewRef.current) {
      viewRef.current.dispatch({
        effects: EditorView.editable.reconfigure(
          EditorView.editable.of(!disabled),
        ),
      });
    }
  }, [disabled]);

  // Update the editor content when value prop changes from outside
  useEffect(() => {
    if (viewRef.current) {
      const currentValue = viewRef.current.state.doc.toString();
      if (currentValue !== value) {
        viewRef.current.dispatch({
          changes: {
            from: 0,
            to: currentValue.length,
            insert: value,
          },
        });
      }
    }
  }, [value]);

  return <div ref={editorRef} className="sql-editor" />;
}

Now update QueryExecutor to use SqlEditor.

Inside src/components/QueryExecutor.tsx:

import { useState } from 'react';
import { getDatabase } from '../db';
import { SqlEditor } from './SqlEditor';

interface QueryResult {
  columns: string[];
  rows: unknown[][];
  rowsAffected?: number;
  lastInsertRowid?: number;
}

export function QueryExecutor() {
  const [query, setQuery] = useState('SELECT sqlite_version()');
  const [result, setResult] = useState<QueryResult | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [isExecuting, setIsExecuting] = useState(false);

  const executeQuery = async () => {
    if (!query.trim()) return;

    setIsExecuting(true);
    setError(null);
    setResult(null);

    try {
      const db = getDatabase();
      const stmt = db.prepare(query);

      const data = await stmt.all();
      const columns = data.length > 0 ? Object.keys(data[0]) : [];
      const rows = data.map((row) =>
        columns.map((col) => row[col as keyof typeof row]),
      );

      setResult({
        columns,
        rows,
        rowsAffected: stmt.changes,
        lastInsertRowid: stmt.lastInsertRowid,
      });
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      setIsExecuting(false);
    }
  };

  return (
    <div className="query-executor">
      <div className="query-input-section">
        <SqlEditor
          value={query}
          onChange={setQuery}
          onExecute={executeQuery}
          disabled={isExecuting}
        />
        <button onClick={executeQuery} disabled={isExecuting || !query.trim()}>
          {isExecuting ? 'Executing...' : 'Execute Query'}
        </button>
        <small>
          Tip: Press Cmd+Enter (Mac) or Ctrl+Enter (Windows) to execute
        </small>
      </div>

      {error && (
        <div className="error-message">
          <strong>Error:</strong> {error}
        </div>
      )}

      {result && (
        <div className="query-results">
          <div className="result-info">
            {result.rowsAffected !== undefined && (
              <span>Rows affected: {result.rowsAffected}</span>
            )}
            {result.lastInsertRowid !== undefined &&
              result.lastInsertRowid > 0 && (
                <span>Last insert ID: {result.lastInsertRowid}</span>
              )}
          </div>

          {result.columns.length > 0 && (
            <div className="table-container">
              <table>
                <thead>
                  <tr>
                    {result.columns.map((col, idx) => (
                      <th key={idx}>{col}</th>
                    ))}
                  </tr>
                </thead>
                <tbody>
                  {result.rows.map((row, rowIdx) => (
                    <tr key={rowIdx}>
                      {row.map((cell, cellIdx) => (
                        <td key={cellIdx}>
                          {cell === null ? (
                            <span className="null-value">NULL</span>
                          ) : (
                            String(cell)
                          )}
                        </td>
                      ))}
                    </tr>
                  ))}
                </tbody>
              </table>
              <div className="row-count">
                {result.rows.length} {result.rows.length === 1 ? 'row' : 'rows'}
              </div>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

Finally, let's apply some CSS updates for CodeMirror. Add this to src/App.css:

.sql-editor {
  border: 1px solid #ccc;
  border-radius: 4px;
  overflow: hidden;
  min-height: 200px;
}

.sql-editor .cm-editor {
  height: 100%;
}

.sql-editor .cm-scroller {
  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
  font-size: 14px;
}

What we built here is more than a browser demo, it’s a glimpse into a new class of applications that are local-first, offline-capable, and server-optional.

With Turso's embedded database, developers can create rich, persistent apps that run anywhere, instantly, and sync only when needed.

The Turso Shell is just the beginning, imagine your next product using the same approach.