How to Embed a SQL Editor in Your Web App
Embed a SQL editor in your web app the right way: credentials stay server-side, safety limits are built in, and users get a full workbench. Working code inside.
To embed a SQL editor in a web app you need four things: an editor component, a server-side query API that holds the database credentials, a results grid, and safety limits that stop one careless query from becoming an incident. Building all four yourself is a real project. The shortcut is to embed a self-hosted browser SQL workbench β SQLly Embedded gives your users a full SQL IDE (IntelliSense, object explorer, execution plans, results grid) on a page of your site, while every database password stays on your server.
This guide walks through both routes: a build-it-yourself sketch with a code editor and a query endpoint, and a working integration of SQLly Embedded with real bridge API calls. It also covers the credential problem that kills most first attempts, and an honest "when to build instead" section.
What "embedding a SQL editor" actually requires
A text box that sends strings to a database is a weekend. A SQL editor your users can be productive β and safe β in is four subsystems:
- An editor component with SQL syntax highlighting and, ideally, schema-aware autocomplete. Autocomplete without schema metadata is just keyword highlighting; useful autocomplete needs your table and column names, and foreign keys for JOIN suggestions.
- A query API on your backend. The browser cannot hold database credentials (more below), so something server-side must connect, run the query, and stream or page results back.
- A results experience. A grid you can sort, scan and export; large result sets that don't freeze the tab; row editing if you allow edits at all.
- Safety limits. Least-privilege database roles, SELECT-only modes for the users who only need reads, confirmation on
UPDATE/DELETEwithout aWHERE, query timeouts, and an audit trail of who ran what.
Skip #4 and the first curious user with write access becomes your incident report. Skip #2's streaming and the first 5-million-row export takes down the tab.
Your options, honestly compared
| Approach | Time to first useful version | Credential safety | Editor quality | Ongoing cost |
|---|---|---|---|---|
| Build on Monaco or CodeMirror + your own query API | Weeks to months | Yours to get right | Editor is good; autocomplete/results/exports are all yours to build | Engineering time, forever |
| Link out to a desktop client or hosted dashboard | Hours | Fine (nothing embedded) | Varies | None in-app, but users leave your app |
| Embed a self-hosted browser workbench (SQLly Embedded) | An afternoon with the sample app | Bridge holds credentials; browser gets names only | Full workbench: IntelliSense, explorer, plans, grid | Yearly license, priced by concurrent users |
There is no fourth "fully client-side" option for real databases. A browser tab can't keep a database password from its own user, so anything that connects to your production databases needs a server-side component holding those credentials. (SQLly does ship a browser preview at sqlly.app/live-preview β the real workbench compiled to WebAssembly, running sample data β but it's a demo, not a way to reach production.)
The credential problem (where most embeds go wrong)
The single most important architectural rule: the browser never receives a database password. A browser can't hide anything from the person using it β devtools, memory inspection, a proxy β so any password you ship in JavaScript, a config endpoint or a connection string is effectively published.
The pattern that works:
- Credentials live server-side, in a store your server controls.
- Your app decides who sees which connections β by team, tenant, or plan β and assigns each user a named set.
- A short-lived launch token says "open the editor for this user with these connections." The token carries no secrets; the server resolves it to credentials when the session starts.
- The browser only ever sees names: connection names, engines, databases, labels. Queries travel as frames over a WebSocket to the server, which runs them with credentials only it holds.
This is exactly the split SQLly Embedded implements: the SQLly UI runs in the user's browser as WebAssembly, and everything that touches a database runs on your server in the SQLly bridge, one engine process per session. Your web app tells the bridge who may open SQLly with which connections; the bridge never sees your users' passwords and SQLly never sees anything at all about your queries.
Route 1: build it yourself on Monaco or CodeMirror
If the editor is a minor feature β one query box on an admin page β build it. Monaco (the editor component that powers VS Code) and CodeMirror are both open source with SQL modes:
import * as monaco from 'monaco-editor';
monaco.editor.create(document.getElementById('editor'), {
value: 'SELECT id, name, created_at FROM customers ORDER BY created_at DESC LIMIT 20;',
language: 'sql',
automaticLayout: true,
});
Then a query endpoint on your server. The critical part is the database role it uses β least privilege, not your admin login:
-- PostgreSQL: a role that can read, and nothing else
CREATE ROLE app_readonly LOGIN PASSWORD 'β¦';
GRANT CONNECT ON DATABASE app TO app_readonly;
GRANT USAGE ON SCHEMA public TO app_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO app_readonly;
ALTER ROLE app_readonly SET statement_timeout = '5s';
// Node + pg β a sketch, not production code
import pg from 'pg';
const pool = new pg.Pool({
connectionString: process.env.READONLY_DATABASE_URL, // app_readonly role
max: 10,
options: '-c default_transaction_read_only=on',
});
app.post('/api/query', requireSignIn, async (req, res) => {
const sql = String(req.body.sql ?? '');
if (!/^\s*select\b/i.test(sql.trim())) {
return res.status(403).json({ error: 'read_only' });
}
try {
const { rows } = await pool.query(sql); // enforce row limits, timeouts, pagination here
res.json({ rows });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
That's the honest 10% you can write in an afternoon. The other 90% is what the list above implies: schema sync for real autocomplete, JOIN suggestions from foreign keys, a virtualized grid for large results, cancellation of running queries, CSV/JSON export, query history, timeouts and rate limits per user, and an audit log. Each piece is small; the sum is a product.
Choose this route when the SQL surface is genuinely small, you need the editor woven into custom UI in ways no product anticipates, or your team can own the security surface long-term.
Route 2: embed a self-hosted SQL workbench (SQLly Embedded)
SQLly Embedded is the browser build of SQLly plus a bridge you run next to your databases. Your users get the editor, IntelliSense, object explorer, execution plans and results grid on a page of your site; your server keeps every password. It works with SQL Server, Azure SQL, PostgreSQL, MySQL, MariaDB, SQLite, DuckDB, ClickHouse, Oracle, libSQL/Turso, Redis and Valkey, and their cloud variants β every engine SQLly's desktop app supports.
Step 1: run the bridge
Download the Embedded package, then start the bridge. Generate the manage token once and share it with your app via a secret store β never in the repository:
export SQLLY_EMBEDDED_MANAGE_TOKEN="$(openssl rand -base64 24)"
export SQLLY_EMBEDDED_LICENSE_KEY=sqe_β¦ # or link later, or an offline license file
./bin/sqlly-bridge embedded \
--domain portal.example.com \
--public-url wss://portal.example.com/sqlly/proxy \
--manage-bind 127.0.0.1:8445 \
--state-dir /var/lib/sqlly-embedded
The bridge does three jobs: accepts browser WebSockets (after checking each client's Origin against your licensed domain and the launch token), exposes a management API on loopback for your app, and keeps its license valid β connected licenses check in with sqlly.app every 20 minutes, offline licenses verify a signed file locally and never call home.
The management API is server-to-server only: it sends no CORS headers and holds the passwords, so bind it to loopback and never expose it to browsers.
Step 2: define what each group of users sees (profiles)
A profile is a named credential document β the connections one group of users gets. Store it with PUT /manage/profiles/{id}; the bridge validates the whole document and reports every problem at once:
// Node 20+, server-side only (the bridge API holds credentials)
const BRIDGE = process.env.SQLLY_BRIDGE_URL ?? 'http://127.0.0.1:8445';
const TOKEN = process.env.SQLLY_EMBEDDED_MANAGE_TOKEN;
async function bridge(method, path, body) {
const res = await fetch(`${BRIDGE}${path}`, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(`${res.status} ${err.error ?? 'error'}: ${err.message ?? ''}`);
}
return res.status === 204 ? null : res.json();
}
await bridge('PUT', '/manage/profiles/analysts', {
name: 'Analysts',
credentials: {
format: 'sqlly-credentials',
version: 1,
environments: [
{ name: 'Production', color: '#C62828',
safety: { only_allow_select: true } },
],
sql_auth_accounts: [
{ name: 'Reader', username: 'reader', password: 'β¦' },
],
connections: [
{ name: 'Northwind', engine: 'sql-server', server: 'sql01.internal',
database: 'Northwind', environment: 'production', client: 'Northwind Traders',
authentication: { type: 'sql-login', account: 'Reader' },
tls: { encrypt: true, trust_server_certificate: true } },
{ name: 'Warehouse', engine: 'postgres', server: 'dw.internal',
database: 'dw', environment: 'production', client: 'Northwind Traders',
authentication: { type: 'sql-login', account: 'Reader' },
tls: { mode: 'verify-full' } },
],
},
});
Two safety levers are built into the profile itself: an environment label with only_allow_select, or block_all_but_select on a connection, makes the engine refuse anything but SELECT before the query reaches your database. Give analysts a read-only profile, support staff another, or one profile per customer in a multi-tenant app. File-based engines (SQLite, DuckDB) are refused unless the bridge is started with --allow-file-engines, because a file connection is file access on the bridge host.
Prove a connection works before anyone uses it with POST /manage/test-connection.
Step 3: mint a launch token and serve the page
When a signed-in user opens the SQLly page, your server asks the bridge for a short-lived token naming that user and their profile:
const minted = await bridge('POST', '/manage/tokens', {
user: session.userId, // your stable id, no whitespace, β€128 chars
profile: 'analysts',
ttlSeconds: 600, // token lifetime: 10 s β 24 h (default 600)
});
// minted => { token, expiresAt, proxyUrl, user, profile, seatsUsed, seats }
Serve the workbench page (web/index.html from the package) with the two globals set before boot.js:
// Express sketch β inject the launch token into the page
const indexHtml = await readFile('./web/index.html', 'utf8');
app.get('/sqlly', requireSignIn, async (req, res) => {
try {
const minted = await bridge('POST', '/manage/tokens', {
user: req.session.userId,
profile: profileFor(req.user), // your policy: role, tenant, planβ¦
});
const page = indexHtml.replace('</head>', `<script>
globalThis.SQLLY_PROXY_URL = ${JSON.stringify(minted.proxyUrl)};
globalThis.SQLLY_PROXY_TOKEN = ${JSON.stringify(minted.token)};
</script></head>`);
res.type('html').send(page);
} catch (err) {
if (String(err).includes('seat_limit')) {
return res.status(409).send('All seats are in use right now β try again soon.');
}
throw err;
}
});
From here the browser connects to wss://β¦/proxy?token=β¦, the bridge starts a dedicated engine process for the session with the profile's credentials, and the browser receives the connection catalog β names, engines, databases, labels β with every secret removed. Queries travel as binary frames; every query and row edit is recorded in the bridge's audit trail (queries.jsonl, retrievable via GET /manage/queries, disable with --query-log false if you must).
Step 4: release seats when access ends
A seat is a user with SQLly open or an unexpired token; it frees the moment they're done. Seats are concurrent, not named β 5 users at a time on the smallest plan means today's five can be five different people tomorrow. Revoke on sign-out or when you remove someone's access:
// on sign-out, or when you revoke a user's access
await bridge('DELETE', `/manage/seats/${encodeURIComponent(session.userId)}`);
Releasing a user who holds no seat is not an error, so you can call it unconditionally.
What it costs
Priced yearly by users connected at a time (a connected license's bridge checks in with sqlly.app every 20 minutes; the offline variant never calls home and lets you run as many bridges as you need):
| Users at a time | Connected | Offline |
|---|---|---|
| 5 | $200/yr | $500/yr |
| 25 | $500/yr | $1000/yr |
| 100 | $2000/yr | $5000/yr |
Every license includes one domain with unlimited subdomains, four browser tabs per connected user, a 15-day grace period after expiry, and a free three-month single-user trial on every account. SQLly's $200 Lifetime desktop license includes a 5-user connected Embedded license that never expires, and small companies (under $5M revenue) can ask for a free license. If you're putting SQLly in front of your own customers across many tenants or domains, there's separate SaaS pricing.
What SQLly Embedded is not
Stated plainly, because self-hosted products get oversold:
- Not zero-knowledge. The bridge is designed to hold the credentials β that's the point. Run it where you'd run any service that can reach your databases.
- Not hosted. SQLly hosts nothing but the license check. The page, the bridge and the databases are all yours.
- Not single sign-on. Your app decides who a user is and mints the token; the bridge trusts it.
- Not a rebrandable widget. The hosting is white-label β your domain, your auth, your page, and users never leave your app β but the workbench UI is SQLly's.
- The trial is one user and one bridge, with a second browser tab refused, so you can see exactly how seats behave before buying.
Safety features worth having in any embed
Whether you build or embed, this is the checklist that separates a tool from a liability:
- Least-privilege roles per profile, not one shared admin login.
- Read-only enforcement for users who only need reads β ideally enforced before the query reaches the database, as SQLly Embedded's
only_allow_select/block_all_but_selectrules do. - Confirmation on destructive SQL β SQLly warns and confirms on
UPDATE/DELETEwithout aWHERE, offers read-only connections and rollback wraps, and can lock production connections. Guardrails like these are rare among SQL GUIs and rarer in hand-rolled embeds. - Query timeouts and cancellation, so one runaway scan can't pin your database.
- An audit trail β who ran what, against which connection, when, and how it ended. The bridge logs every query and row edit to
queries.jsonlby default.
Build vs embed: how to decide
Build it yourself when:
- The SQL surface is one query box, not a workspace.
- You need the editor embedded inside bespoke UI that a full workbench can't occupy.
- You can staff the ongoing work: schema sync, grids, cancellation, export, audits, and the security review each change needs.
Embed a workbench when:
- Users need a real SQL IDE β autocomplete that knows your schema and foreign keys, an object explorer, plans, a grid that streams 50M rows with spill-to-disk instead of freezing.
- Credentials must stay server-side, and you want per-group connection assignment without building the token plumbing yourself.
- You support many engines (SQL Server, PostgreSQL, MySQL, ClickHouse, DuckDB, Oracle, Turso/libSQL, Redisβ¦) and don't want to write one driver integration per engine.
- Time-to-market matters: the package ships a sample admin app you can run in minutes and replace incrementally β the whole bridge API is server-to-server HTTP/JSON with an OpenAPI document (
swagger.json) you can generate a client from in any stack: Node, ASP.NET, Django, Rails, Go, PHP.
And when neither fits: if users want dashboards more than ad-hoc SQL, a BI tool is the better category; if you truly cannot run any server-side component, no safe embed of a live production database exists β credentials in the browser are published, full stop. Use a client-side demo (like SQLly's live preview) only with sample data or data the user already holds locally.
FAQ
Can I put a database password in the browser? No. Anything in the browser β JavaScript, config endpoints, connection strings β is visible to the person using it. Keep credentials in a server-side store and hand the browser a short-lived, secret-free launch token that the server resolves to credentials when the session starts.
What's the fastest way to embed a SQL editor in a web app? Embed a self-hosted browser SQL workbench. With SQLly Embedded: download the package, start the bridge (one command), store a profile of connections, and mint a launch token per signed-in user β the sample admin app demonstrates the whole flow, and every account includes a free three-month single-user trial.
How do I keep users from running UPDATE or DELETE? Enforce it twice: a read-only database role on your server, and a SELECT-only rule in the editor. SQLly Embedded supports only_allow_select (per environment label) and block_all_but_select (per connection), enforced by the engine before the query reaches your database. SQLly also warns and asks for confirmation on UPDATE/DELETE without a WHERE.
Which databases can an embedded SQL editor connect to? SQLly Embedded supports every engine SQLly supports: SQL Server and Azure SQL, PostgreSQL, MySQL, MariaDB, SQLite, DuckDB, ClickHouse, Oracle, libSQL/Turso, Redis and Valkey, and their cloud variants β 30 engines in total.
Is SQLly Embedded white-label? The hosting is: it serves from your own domain (one licensed domain, unlimited subdomains), behind your sign-in, on a page of your app. The workbench UI itself is SQLly's, not a rebrandable SDK.
What happens if the license check fails or expires? Connected licenses keep serving on their last signed grant for 40 minutes if sqlly.app is unreachable, then pause and resume automatically on the next successful check-in β no restart. After a license period ends there's a 15-day grace period where everything works with a countdown. Offline licenses verify a local file and never contact SQLly servers at all.
Does embedding send my users' queries to sqlly.app? No. The bridge sends only license bookkeeping at check-in: license key, instance id, version, hostname, requested domain, the user ids holding seats, and the open-session count. Queries, results, database names and hosts, credentials, profiles and anything your users type never leave your server. Offline licenses send nothing at all.
Can I try it without buying? Yes β every sqlly.app account includes a three-month, single-user Embedded trial (one user, one bridge), and the browser preview at sqlly.app/live-preview runs the real workbench on sample data with no account at all.