SQLite in Blazor Pivot Table
The Blazor Pivot Table can load and edit SQLite data through an ASP.NET Core API. SfDataManager sends HTTP requests to the API, and the API uses Microsoft.Data.Sqlite to access the SQLite database.
This guide uses same-origin relative API URLs. The sample read action returns the complete Orders table and does not apply DataManagerRequest operations. Add server-side filtering, sorting, and paging before using this design with large datasets.
Prerequisites
The sample was tested with the following versions and configuration:
| Software or package | Version | Notes |
|---|---|---|
| .NET SDK | 10.0 | Required to target net10.0
|
| Visual Studio | 2026 18.0 or later | Required to target net10.0; install the ASP.NET and web development workload. VS Code and the .NET CLI are also supported |
| SQLite tools | 3.x or later | The sqlite3 command-line shell or a GUI tool is needed to create and inspect the database; Microsoft.Data.Sqlite supplies the runtime provider and native engine |
| Syncfusion.Blazor.PivotTable |
35.1.37 (31.2.10 or later) |
.NET 10 support starts with 31.2.10; keep all Syncfusion packages on the same version |
| Syncfusion.Blazor.Themes |
35.1.37 (31.2.10 or later) |
Provides the component theme |
| Microsoft.Data.Sqlite | 10.0.10 | SQLite ADO.NET provider aligned with .NET 10 |
The application uses the Blazor Web App template with Interactive Server rendering. Syncfusion packages from NuGet.org require a valid license or trial key; follow the license-key registration instructions.
SQLite Database Setup and Application Configuration
Step 1: Create the Blazor Web App
Create an Interactive Server Blazor Web App:
dotnet new blazor -n PivotTableSQLite -f net10.0 -int Server -ai
cd PivotTableSQLiteIn Visual Studio, the equivalent choices are Blazor Web App, .NET 10, Interactive render mode: Server, and Interactivity location: Global.
Step 2: Create the SQLite Database
SQLite stores an entire database in a single file, so there is no server to install or configure. Download the SQLite tools from the SQLite download page and use either the sqlite3 command-line shell or a GUI client such as DB Browser for SQLite.
Create the database file Orders.db and open it:
sqlite3 Orders.dbRun the following script to create the Orders table and seed it with sample rows:
CREATE TABLE IF NOT EXISTS Orders
(
OrderID INTEGER PRIMARY KEY AUTOINCREMENT,
CustomerName TEXT NOT NULL,
EmployeeID INTEGER NOT NULL,
ShipCity TEXT NULL,
Freight REAL NULL
);
INSERT INTO Orders (CustomerName, EmployeeID, ShipCity, Freight)
SELECT * FROM (VALUES
('Toms', 1, 'New York', 35.30),
('Ravi', 2, 'London', 80.20),
('Sven', 1, 'Berlin', 52.10),
('Sara', 3, 'Madrid', 18.40),
('Paul', 2, 'Tokyo', 64.75)
) AS seed
WHERE NOT EXISTS (SELECT 1 FROM Orders);Verify the inserted data:
SELECT OrderID, CustomerName, EmployeeID, ShipCity, Freight
FROM Orders
ORDER BY OrderID;
.quitExpected output:
| OrderID | CustomerName | EmployeeID | ShipCity | Freight |
|---|---|---|---|---|
| 1 | Toms | 1 | New York | 35.30 |
| 2 | Ravi | 2 | London | 80.20 |
| 3 | Sven | 1 | Berlin | 52.10 |
| 4 | Sara | 3 | Madrid | 18.40 |
| 5 | Paul | 2 | Tokyo | 64.75 |
Copy the generated Orders.db file to a location the ASP.NET Core application can read, and use that path in the connection string configured in the next steps.
Step 3: Install the Required NuGet Packages
Run these commands in the PivotTableSQLite project directory:
dotnet add package Syncfusion.Blazor.PivotTable --version 35.1.37
dotnet add package Syncfusion.Blazor.Themes --version 35.1.37
dotnet add package Microsoft.Data.Sqlite --version 10.0.10The project file should contain:
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.10" />
<PackageReference Include="Syncfusion.Blazor.PivotTable" Version="35.1.37" />
<PackageReference Include="Syncfusion.Blazor.Themes" Version="35.1.37" />
</ItemGroup>Step 4: Configure the Connection String
Store the SQLite connection string in appsettings.json:
{
"ConnectionStrings": {
"SQLite": "Data Source=<DATABASE_FILE_NAME>"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}Note: Replace
<DATABASE_FILE_NAME>with the actual SQLite database file name or the full path to the database file used in your environment. For example,Data Source=Orders.dbfor a file in the application working directory, orData Source=C:/Data/Orders.dbfor an absolute path.
A relative Data Source path is resolved from the application’s process working directory. Use an absolute path in deployments where the working directory can differ from the project directory.
Step 5: Create the API Controller
Create a Controllers folder at the project root, and then create Controllers/OrderController.cs. In this sample, the Order model and the CRUDModel<T> wrapper are defined inside OrderController.cs rather than in a separate file. The same Order shape is also declared in the Pivot Table page (Home.razor) so the component can strongly type its data source. Keeping the model close to the code that uses it makes the contract between the controller and the page easy to follow.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Syncfusion.Blazor.Data;
using Syncfusion.Blazor;
namespace PivotTableSQLite.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
private readonly string connectionString;
public OrderController(IConfiguration configuration)
{
connectionString = configuration.GetConnectionString("SQLite")
?? throw new InvalidOperationException(
"The SQLite connection string is not configured.");
}
[HttpPost]
public object Post([FromBody] DataManagerRequest request)
{
_ = request;
List<Order> dataSource = GetOrderData();
return new
{
result = dataSource,
count = dataSource.Count
};
}
private List<Order> GetOrderData()
{
List<Order> orders = new();
const string query =
@"SELECT
OrderID,
CustomerName,
EmployeeID,
Freight,
ShipCity
FROM Orders
ORDER BY OrderID";
using SqliteConnection connection = new(connectionString);
connection.Open();
using SqliteCommand command = new(query, connection);
using SqliteDataReader reader = command.ExecuteReader();
while (reader.Read())
{
orders.Add(new Order
{
OrderID = reader.IsDBNull(0) ? null : reader.GetInt32(0),
CustomerName = reader.IsDBNull(1) ? null : reader.GetString(1),
EmployeeID = reader.IsDBNull(2) ? null : reader.GetInt32(2),
Freight = reader.IsDBNull(3) ? null : Convert.ToDecimal(reader.GetDouble(3)),
ShipCity = reader.IsDBNull(4) ? null : reader.GetString(4)
});
}
return orders;
}
[HttpPost("Insert")]
public IActionResult Insert([FromBody] CRUDModel<Order> value)
{
if (value.Value is not Order order
|| string.IsNullOrWhiteSpace(order.CustomerName)
|| !order.EmployeeID.HasValue)
{
return BadRequest(
"CustomerName and EmployeeID are required.");
}
const string query =
@"INSERT INTO Orders
(CustomerName, EmployeeID, Freight, ShipCity)
VALUES
(@CustomerName, @EmployeeID, @Freight, @ShipCity)";
using SqliteConnection connection = new(connectionString);
connection.Open();
using SqliteCommand command = new(query, connection);
command.Parameters.AddWithValue(
"@CustomerName",
order.CustomerName);
command.Parameters.AddWithValue(
"@EmployeeID",
order.EmployeeID.Value);
command.Parameters.AddWithValue(
"@Freight",
order.Freight.HasValue
? order.Freight.Value
: DBNull.Value);
command.Parameters.AddWithValue(
"@ShipCity",
order.ShipCity ?? (object)DBNull.Value);
command.ExecuteNonQuery();
using SqliteCommand keyCommand = new(
"SELECT last_insert_rowid();",
connection);
order.OrderID = Convert.ToInt32(keyCommand.ExecuteScalar());
return Ok(order);
}
[HttpPost("Update")]
public IActionResult Update([FromBody] CRUDModel<Order> value)
{
if (value.Value is not Order order
|| !order.OrderID.HasValue
|| string.IsNullOrWhiteSpace(order.CustomerName)
|| !order.EmployeeID.HasValue)
{
return BadRequest(
"OrderID, CustomerName and EmployeeID are required.");
}
const string query =
@"UPDATE Orders
SET CustomerName = @CustomerName,
EmployeeID = @EmployeeID,
Freight = @Freight,
ShipCity = @ShipCity
WHERE OrderID = @OrderID";
using SqliteConnection connection = new(connectionString);
connection.Open();
using SqliteCommand command = new(query, connection);
command.Parameters.AddWithValue(
"@CustomerName",
order.CustomerName);
command.Parameters.AddWithValue(
"@EmployeeID",
order.EmployeeID.Value);
command.Parameters.AddWithValue(
"@Freight",
order.Freight.HasValue
? order.Freight.Value
: DBNull.Value);
command.Parameters.AddWithValue(
"@ShipCity",
order.ShipCity ?? (object)DBNull.Value);
command.Parameters.AddWithValue(
"@OrderID",
order.OrderID.Value);
int rowsAffected = command.ExecuteNonQuery();
return rowsAffected == 0
? NotFound()
: Ok(order);
}
[HttpPost("Delete")]
public IActionResult Delete([FromBody] CRUDModel<Order> value)
{
if (!int.TryParse(value.Key?.ToString(), out int orderId))
{
return BadRequest(
"A numeric order key is required.");
}
const string query =
@"DELETE FROM Orders
WHERE OrderID = @OrderID";
using SqliteConnection connection = new(connectionString);
connection.Open();
using SqliteCommand command = new(query, connection);
command.Parameters.AddWithValue(
"@OrderID",
orderId);
int rowsAffected = command.ExecuteNonQuery();
return rowsAffected == 0
? NotFound()
: NoContent();
}
public class Order
{
[Key]
public int? OrderID { get; set; }
public string? CustomerName { get; set; }
public int? EmployeeID { get; set; }
public decimal? Freight { get; set; }
public string? ShipCity { get; set; }
}
public class CRUDModel<T> where T : class
{
[JsonPropertyName("action")]
public string? Action { get; set; }
[JsonPropertyName("keyColumn")]
public string? KeyColumn { get; set; }
[JsonPropertyName("key")]
public object? Key { get; set; }
[JsonPropertyName("value")]
public T? Value { get; set; }
[JsonPropertyName("added")]
public List<T>? Added { get; set; }
[JsonPropertyName("changed")]
public List<T>? Changed { get; set; }
[JsonPropertyName("deleted")]
public List<T>? Deleted { get; set; }
[JsonPropertyName("params")]
public IDictionary<string, object>? Params { get; set; }
}
}
}The controller exposes the read, insert, update, and delete endpoints described in the API Contract. The exception middleware configured in the next step logs unhandled SqliteException instances and returns generic problem responses without exposing database paths or SQL details.
Step 6: Configure Program.cs
Replace Program.cs with:
using PivotTableSQLite.Components;
using Syncfusion.Blazor;
var builder = WebApplication.CreateBuilder(args);
// Register the Syncfusion license before builder.Build().
// Syncfusion packages require a valid license or trial key.
// Syncfusion.Licensing.SyncfusionLicenseProvider.RegisterLicense(
// "YOUR LICENSE KEY");
builder.Services.AddSyncfusionBlazor();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.MapControllers();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();Key registration points:
-
AddSyncfusionBlazor()registers the Syncfusion Blazor services required by the Pivot Table. -
AddRazorComponents().AddInteractiveServerComponents()enables Interactive Server rendering. -
AddControllers()registers the API controllers, includingOrderController. -
AddProblemDetails()andUseExceptionHandler()log unhandled failures and return generic error responses. -
MapControllers()routes requests to the API endpoints. -
MapStaticAssets()maps the application’s static web assets, including assets supplied by referenced component packages.
Note: Remove the comment markers and fill in your Syncfusion license or trial key in
Program.csbefore running the application. Follow the license-key registration instructions for details.
Step 7: Configure the Pivot Table
Add these namespaces to Components/_Imports.razor:
@using Syncfusion.Blazor
@using Syncfusion.Blazor.Data
@using Syncfusion.Blazor.PivotViewIn Components/App.razor, add the Syncfusion stylesheet inside <head>:
<link href="_content/Syncfusion.Blazor.Themes/bootstrap5.css"
rel="stylesheet" />Add the Syncfusion script immediately before </body>, after the template’s existing _framework/blazor.web.js reference:
<script src="_content/Syncfusion.Blazor.Core/scripts/syncfusion-blazor.min.js"
type="text/javascript"></script>Do not add a second _framework/blazor.web.js reference if the template already contains one. The completed App.razor should look like:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["PivotTableSQLite.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" />
<link href="_content/Syncfusion.Blazor.Themes/bootstrap5.css" rel="stylesheet" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
<script src="_content/Syncfusion.Blazor.Core/scripts/syncfusion-blazor.min.js" type="text/javascript"></script>
</body>
</html>Replace Components/Pages/Home.razor with the following markup. The SfDataManager URLs use same-origin relative paths (/api/Order, /api/Order/Insert, etc.) so they resolve against whatever port the application is launched on, avoiding hard-coded development ports and HTTP-to-HTTPS mixed-content failures:
@page "/"
<SfPivotView TValue="Order" Width="1000" Height="300" ShowFieldList="true">
<PivotViewDataSourceSettings TValue="Order" ExpandAll="false" EnableSorting="true">
<SfDataManager Url="/api/Order"
InsertUrl="/api/Order/Insert"
UpdateUrl="/api/Order/Update"
RemoveUrl="/api/Order/Delete"
Adaptor="Adaptors.UrlAdaptor">
</SfDataManager>
<PivotViewColumns>
<PivotViewColumn Name="EmployeeID"></PivotViewColumn>
</PivotViewColumns>
<PivotViewRows>
<PivotViewRow Name="CustomerName"></PivotViewRow>
</PivotViewRows>
<PivotViewValues>
<PivotViewValue Name="Freight" Caption="Freight"></PivotViewValue>
</PivotViewValues>
</PivotViewDataSourceSettings>
<PivotViewGridSettings ColumnWidth="120"></PivotViewGridSettings>
<PivotViewEvents TValue="Order" BeginDrillThrough="BeginDrillThrough"></PivotViewEvents>
<PivotViewCellEditSettings AllowEditing="true"
AllowAdding="true"
AllowDeleting="true"
Mode="EditMode.Normal">
</PivotViewCellEditSettings>
</SfPivotView>
@code {
private void BeginDrillThrough(BeginDrillThroughEventArgs args)
{
// Identify the key used by URL Adaptor update and delete requests.
for (int i = 0; i < args.GridObj.Columns.Count; i++)
{
if (args.GridObj.Columns[i].Field == "OrderID")
{
args.GridObj.Columns[i].IsPrimaryKey = true;
}
else
{
args.GridObj.Columns[i].Visible = true;
}
}
}
public class Order
{
public int? OrderID { get; set; }
public string? CustomerName { get; set; }
public int? EmployeeID { get; set; }
public decimal? Freight { get; set; }
public string? ShipCity { get; set; }
}
}The BeginDrillThrough event is used to mark OrderID as the primary key on the drill-through grid. This tells the DataManager which column uniquely identifies each record so that insert, update, and delete requests carry the correct key. Without this configuration, the write operations cannot target the intended row.
API Contract
| Method | Route | Payload | Success response |
|---|---|---|---|
POST |
/api/Order |
DataManagerRequest |
200 with { result, count }
|
POST |
/api/Order/Insert |
CRUDModel<Order> |
200 with the inserted record |
POST |
/api/Order/Update |
CRUDModel<Order> |
200 with the updated record |
POST |
/api/Order/Delete |
CRUDModel<Order> |
204 with no body |
The API uses action-oriented routes because they match the URL Adaptor’s InsertUrl, UpdateUrl, and RemoveUrl contract.
For write requests, action identifies the operation, keyColumn names the primary-key field, key carries the value used by delete operations, and value carries the inserted or updated record. The Syncfusion model also supports added, changed, and deleted collections for batch editing, params for additional values, and table for an optional table name; this sample uses normal editing and does not consume those optional properties.
| Route | Failure response |
|---|---|
/api/Order |
500 when the database cannot be queried |
/api/Order/Insert |
400 when required fields are missing; 500 on a database failure |
/api/Order/Update |
400 when required fields are missing; 404 when the key does not exist; 500 on a database failure |
/api/Order/Delete |
400 when the key is not numeric; 404 when the key does not exist; 500 on a database failure |
Example read response:
{
"result": [
{
"orderID": 1,
"customerName": "Toms",
"employeeID": 1,
"freight": 35.30,
"shipCity": "New York"
},
{
"orderID": 2,
"customerName": "Ravi",
"employeeID": 2,
"freight": 80.20,
"shipCity": "London"
},
{
"orderID": 3,
"customerName": "Sven",
"employeeID": 1,
"freight": 52.10,
"shipCity": "Berlin"
},
{
"orderID": 4,
"customerName": "Sara",
"employeeID": 3,
"freight": 18.40,
"shipCity": "Madrid"
},
{
"orderID": 5,
"customerName": "Paul",
"employeeID": 2,
"freight": 64.75,
"shipCity": "Tokyo"
}
],
"count": 5
}Example insert request:
{
"action": "insert",
"keyColumn": "orderID",
"value": {
"customerName": "Mei Chen",
"employeeID": 4,
"shipCity": "Sydney",
"freight": 142.50
}
}The insert response returns the persisted record with the generated orderID populated.
Example update request:
{
"action": "update",
"keyColumn": "orderID",
"value": {
"orderID": 3,
"customerName": "Sven",
"employeeID": 1,
"shipCity": "Hamburg",
"freight": 60.00
}
}Example delete request:
{
"action": "remove",
"keyColumn": "orderID",
"key": 5
}Run and Verify the Application
Build and run the project:
dotnet build
dotnet runOpen the URL shown in the terminal. Verify the following:
- The Pivot Table displays
CustomerNameas rows,EmployeeIDas columns, and the sum ofFreightas values. - The browser Network panel shows
POST /api/Orderreturning200withresultandcount. - Double-click a value (summary) cell to open its raw-record editor.
- Add a record and confirm that
POST /api/Order/Insertreturns the generated, nonzeroorderID. - Edit and delete records, and confirm the corresponding API requests succeed with that key.
- Query the
Orderstable (using your SQLite client) to confirm the changes were persisted locally. For deployed apps with no direct database access, re-open the drill-through editor and confirm that the rows reflect the changes.
Production Considerations
SQLite is ideal for local development, embedded scenarios, and small-to-medium workloads. Before using this design in production, consider the following:
- Concurrency: SQLite allows multiple readers but serializes writers. For write-heavy workloads, switch to a client-server database such as SQL Server or PostgreSQL.
-
Server-side data operations: The sample returns the entire
Orderstable. ApplyDataManagerRequestwhere,sorted,skip, andtakeparameters on the server before using this design with large datasets. - Connection string: Store the connection string through the hosting environment or a secrets manager rather than committing production paths to source control.
- File location: Place the database file on durable storage with appropriate backup and recovery procedures. SQLite keeps the entire database in a single file.
- Authentication and authorization: Protect the write endpoints with the authentication and authorization mechanism used by your application.
-
Antiforgery: If cookie-authenticated API actions require antiforgery validation, configure
SfDataManagerto send the request token expected by the server. -
WAL mode: For better concurrent read/write performance, consider enabling Write-Ahead Logging (
PRAGMA journal_mode=WAL;) on the database. - Deployment: For cross-origin hosting, configure an explicit CORS policy that allows only the Blazor application’s origin.
Troubleshooting
| Symptom | Resolution |
|---|---|
| Pivot Table shows no data | Inspect POST /api/Order in the browser Network panel and check the server log. A GET request from the browser address bar returns 405. |
405 Method Not Allowed |
Confirm [HttpPost], AddControllers(), and MapControllers() are present. |
No such table: Orders |
Create the Orders table in the database file pointed to by the connection string and confirm the connection string selects that file. |
The SQLite connection string is not configured. |
Add a ConnectionStrings:SQLite entry to appsettings.json and restart the application. |
Unable to open database file |
Confirm the database file path is valid and that the application process has read/write permission on the file and its folder. |
| The database file is locked | Ensure no other process holds an exclusive lock, and consider enabling WAL mode for concurrent access. |
CRUD returns 400
|
Inspect the request JSON and confirm required fields (CustomerName, EmployeeID) and a numeric key are present. |
CRUD returns 404
|
Confirm that the supplied OrderID exists and that OrderID is marked as the primary key in the BeginDrillThrough event handler. |
CRUD returns 500
|
Check the server log and verify the table schema, connection string, and file permissions. |
| Browser reports mixed content or a redirect failure | Confirm Home.razor uses same-origin relative URLs (for example /api/Order) rather than hard-coded HTTP URLs. |
| Cross-origin request is blocked | Prefer same-origin relative URLs; otherwise configure AddCors and UseCors for the exact Blazor application origin. |
| Antiforgery validation fails | Configure the adaptor to send the expected request token, or use an appropriate non-cookie API authentication scheme. |
| Large datasets are slow | Process DataManagerRequest operations on the server instead of returning the entire table. |
For current component behavior, see the Pivot Table editing documentation and Pivot Table data-binding documentation.
Complete Sample Repository
A complete, working sample implementation is available in the GitHub repository.