Introduction
In this exercise, we’ll learn how to run a real SQLite database entirely in the browser using Blazor WebAssembly and sql.js.
No server.
No connection string.
No Docker.
Everything runs inside the user’s browser tab, powered by WebAssembly.
We’ll build a simple Blazor page that loads the popular Chinook sample database, runs SQL queries client-side, and renders the results in a table.
This approach works great for:
- Offline‑first demos and prototypes
- Data exploration tools
- Training content and workshops
- Scenarios where you want "database feel" without any backend
Step 1: Add sql.js to Your Blazor WebAssembly App
First, we need the sql.js library (SQLite compiled to WebAssembly for browsers).
Create a folder under wwwroot:
-…
Introduction
In this exercise, we’ll learn how to run a real SQLite database entirely in the browser using Blazor WebAssembly and sql.js.
No server.
No connection string.
No Docker.
Everything runs inside the user’s browser tab, powered by WebAssembly.
We’ll build a simple Blazor page that loads the popular Chinook sample database, runs SQL queries client-side, and renders the results in a table.
This approach works great for:
- Offline‑first demos and prototypes
- Data exploration tools
- Training content and workshops
- Scenarios where you want "database feel" without any backend
Step 1: Add sql.js to Your Blazor WebAssembly App
First, we need the sql.js library (SQLite compiled to WebAssembly for browsers).
Create a folder under wwwroot:
wwwroot/sqljs/
Download the following files from the official sql.js GitHub releases (or a CDN) and place them in wwwroot/sqljs/:
Your structure should look like:
wwwroot/
sqljs/
sql-wasm.js
sql-wasm.wasm
Step 2: Create a Thin JavaScript Wrapper
Next, we create a small JavaScript interop layer that:
- Loads
sql-wasm.jsand initializes SQL.js - Opens databases from byte arrays
- Executes SQL and returns rows as JavaScript objects
- Exports and closes databases
Add a new file: wwwroot/sqljs/sqljsInterop.js:
window.sqliteJs = {
init: async () => {
try {
console.log("sqliteJs.init: Starting initialization...");
if (window.SQL) {
console.log("sqliteJs.init: Already initialized");
return true;
}
console.log("sqliteJs.init: Loading sql-wasm.js...");
await new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = "/sqljs/sql-wasm.js";
s.onload = () => {
console.log("sqliteJs.init: sql-wasm.js loaded successfully");
resolve();
};
s.onerror = (e) => {
console.error("sqliteJs.init: Failed to load sql-wasm.js", e);
reject(e);
};
document.head.appendChild(s);
});
console.log("sqliteJs.init: Initializing SQL.js...");
window.SQL = await initSqlJs({
locateFile: f => {
console.log("sqliteJs.init: Locating file:", f);
return "/sqljs/" + f;
}
});
window._dbs = {};
console.log("sqliteJs.init: Initialization complete");
return true;
} catch (error) {
console.error("sqliteJs.init: Error during initialization", error);
throw error;
}
},
openDb: (bytes) => {
try {
console.log("sqliteJs.openDb: Opening database, bytes:", bytes ? bytes.length : 0);
const db = bytes ? new SQL.Database(new Uint8Array(bytes))
: new SQL.Database();
const id = crypto.randomUUID();
window._dbs[id] = db;
console.log("sqliteJs.openDb: Database opened with ID:", id);
return id;
} catch (error) {
console.error("sqliteJs.openDb: Error opening database", error);
throw error;
}
},
exec: (id, sql, params) => {
try {
console.log("sqliteJs.exec: Executing query", { id, sql, params });
const db = window._dbs[id];
if (!db) {
throw new Error("Database not found: " + id);
}
const stmt = db.prepare(sql);
if (params) stmt.bind(params);
const rows = [];
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
console.log("sqliteJs.exec: Query returned", rows.length, "rows");
return rows;
} catch (error) {
console.error("sqliteJs.exec: Error executing query", error);
throw error;
}
},
export: (id) => {
try {
console.log("sqliteJs.export: Exporting database", id);
return window._dbs[id].export();
} catch (error) {
console.error("sqliteJs.export: Error exporting database", error);
throw error;
}
},
close: (id) => {
try {
console.log("sqliteJs.close: Closing database", id);
window._dbs[id].close();
delete window._dbs[id];
} catch (error) {
console.error("sqliteJs.close: Error closing database", error);
throw error;
}
}
};
Step 3: Reference the Interop Script in index.html
Blazor WebAssembly apps use wwwroot/index.html as the host page.
Open wwwroot/index.html and add the sql.js interop script before the Blazor script:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WASMDemowithBlazorApp</title>
<base href="/" />
<link rel="preload" id="webassembly" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="WASMDemowithBlazorApp.styles.css" rel="stylesheet" />
<script type="importmap"></script>
</head>
<body>
<div id="app">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div class="loading-progress-text"></div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
<!-- sql.js interop -->
<script src="/sqljs/sqljsInterop.js"></script>
<!-- Blazor WebAssembly -->
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body>
</html>
Step 4: Create a C# SqlJsService for JS Interop
Now we wrap the JavaScript API with a clean C# service.
Add a new class: Services/SqlJsService.cs:
using System.Text.Json;
using Microsoft.JSInterop;
namespace WASMDemowithBlazorApp.Services;
public class SqlJsService
{
private readonly IJSRuntime _js;
private bool _init;
public SqlJsService(IJSRuntime js)
{
_js = js;
}
public async Task InitAsync()
{
if (_init)
{
return;
}
await _js.InvokeVoidAsync("sqliteJs.init");
_init = true;
}
public Task<string> OpenAsync(byte[]? bytes = null)
{
return _js.InvokeAsync<string>("sqliteJs.openDb", bytes).AsTask();
}
public async Task<List<Dictionary<string, object>>> QueryAsync(
string id,
string sql,
object[]? args = null)
{
var raw = await _js.InvokeAsync<object>("sqliteJs.exec", id, sql, args);
// Convert JS objects -> JSON -> Dictionary<string, object>
return JsonSerializer.Deserialize<List<Dictionary<string, object>>>(
JsonSerializer.Serialize(raw))
?? new List<Dictionary<string, object>>();
}
public Task<byte[]> ExportAsync(string id)
{
return _js.InvokeAsync<byte[]>("sqliteJs.export", id).AsTask();
}
public Task CloseAsync(string id)
{
return _js.InvokeVoidAsync("sqliteJs.close", id).AsTask();
}
}
Register the service in Program.cs:
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using WASMDemowithBlazorApp;
using WASMDemowithBlazorApp.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});
// Register SQL.js service
builder.Services.AddScoped<SqlJsService>();
await builder.Build().RunAsync();
Step 5: Add the Chinook SQLite Database
We’ll use the classic Chinook sample database (music store data).
- Create a folder:
wwwroot/
chinook/
chinook.sqlite
- Download
Chinook_Sqlite.sqlitefrom the Chinook GitHub repo and rename it tochinook.sqlite. - Place it under
wwwroot/chinook/chinook.sqlite.
This file will be served as a static asset and loaded into memory by the browser.
Step 6: Build the Blazor Page to Query SQLite
Now we create a Blazor component that:
- Loads the database file via
HttpClient - Opens it with
SqlJsService - Executes a
SELECTagainst theAlbumtable - Renders the first 10 albums
Create Pages/Chinook.razor:
@page "/chinook"
@inject Services.SqlJsService Sql
@inject HttpClient Http
@implements IAsyncDisposable
<PageTitle>Chinook Database</PageTitle>
<h1>Chinook Database Demo</h1>
<p>This page demonstrates client-side SQLite using sql.js in Blazor WebAssembly.</p>
@if (rows == null)
{
<p><em>Loading database...</em></p>
}
else if (errorMessage != null)
{
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> @errorMessage
</div>
}
else
{
<h3>Albums (First 10)</h3>
<table class="table">
<thead>
<tr>
<th>Album ID</th>
<th>Title</th>
</tr>
</thead>
<tbody>
@foreach (var r in rows)
{
<tr>
<td>@r["AlbumId"]</td>
<td>@r["Name"]</td>
</tr>
}
</tbody>
</table>
}
@code {
string? dbId;
List<Dictionary<string, object>>? rows;
string? errorMessage;
protected override async Task OnInitializedAsync()
{
try
{
Console.WriteLine("Starting SQL initialization...");
await Sql.InitAsync();
Console.WriteLine("SQL initialized successfully");
Console.WriteLine("Fetching database file...");
var bytes = await Http.GetByteArrayAsync("chinook/chinook.sqlite");
Console.WriteLine($"Database loaded: {bytes.Length} bytes");
Console.WriteLine("Opening database...");
dbId = await Sql.OpenAsync(bytes);
Console.WriteLine($"Database opened with ID: {dbId}");
Console.WriteLine("Executing query...");
rows = await Sql.QueryAsync(
dbId,
"SELECT AlbumId, Title AS Name FROM Album LIMIT 10"
);
Console.WriteLine($"Query returned {rows.Count} rows");
}
catch (Exception ex)
{
errorMessage = $"{ex.GetType().Name}: {ex.Message}";
Console.WriteLine($"Error: {errorMessage}");
Console.WriteLine($"Stack trace: {ex.StackTrace}");
}
}
public async ValueTask DisposeAsync()
{
if (dbId != null)
{
try
{
await Sql.CloseAsync(dbId);
}
catch
{
// Ignore dispose errors
}
}
}
}
Finally, add a nav link (optional, but nice) in Layout/NavMenu.razor:
<div class="nav-item px-3">
<NavLink class="nav-link" href="chinook">
<span class="bi bi-database" aria-hidden="true"></span> Chinook DB
</NavLink>
</div>
Step 7: Run and See the Result
Run the Blazor WebAssembly app:
dotnet run
Navigate to:
https://localhost:xxxx/chinook
You should see:
- A loading message
- Then a table of the first 10 albums from the
Albumtable rendered directly from SQLite in the browser.
No API controller. No EF Core. Just pure WASM + Blazor + sql.js.
What We Learned (and Things to Be Careful About)
While wiring this up, a few important lessons surfaced:
Static file locks during build
- Visual Studio may complain that
chinook.sqliteis in use during build if some process has it open. - If you see
IOException: The process cannot access the file ... chinook.sqlite, close any external tools (SQLite browser, etc.) and stop the running dev server before rebuilding.
SQLite table names are case-sensitive here
- The Chinook schema uses
Album, notalbums. - A tiny mismatch like
FROM albumsresults inError: no such table: albumsdeep in a JS stack trace. - When troubleshooting, run a quick
SELECT name FROM sqlite_master WHERE type='table';using a local SQLite client to confirm table names.
Blazor + JSInterop error surfacing
- SQL errors show up first in the browser console (JS), then are re-thrown into .NET as
JSException. - Logging in both C# (
Console.WriteLine) and JS (console.log,console.error) makes it much easier to trace problems across the boundary.
Disposal still matters in WebAssembly
- Even though everything is in-memory, it’s good practice to implement
IAsyncDisposableon your Blazor components and close the in-memory databases when the component is torn down. - This keeps your in-browser resources tidy, especially if you open multiple databases.
Script load order is important
sqljsInterop.jsmust load before Blazor starts callingsqliteJs.init.- Keep the interop
<script>tag before_framework/blazor.webassembly*.jsinindex.html.
These small details are easy to miss when you’re new to mixing Blazor, JS interop, and WebAssembly, but they’re exactly what make the experience smooth once you know them.
Summary
In this exercise, we:
- Integrated sql.js (SQLite on WebAssembly) into a Blazor WebAssembly app.
- Created a JavaScript interop layer to load, open, query, export, and close SQLite databases in the browser.
- Wrapped that logic in a C#
SqlJsService, exposing a clean async API. - Loaded the Chinook sample database from
wwwroot, executed a SQL query against theAlbumtable, and rendered results in a Blazor component. - Learned several practical troubleshooting tips for working with Blazor + WebAssembly + JS interop.
With this foundation, you can:
- Build offline data explorers
- Ship demo apps that "feel" like full-stack but run entirely in the browser
- Prototype data-driven features without standing up real infrastructure
Love C# & Blazor WASM!