The next evolution of SQLite is here! Read Announcement
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:
Let me walk you through how we built it.
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.
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;
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.