Skip to main content

Adding a Backend to SVAR Grid in Nuxt

In Part 1 we built a Nuxt app with a styled grid on local data. This guide shows you how to connect that grid to SQLite database.

By the end of this guide you will have the grid that loads records from a REST API, saves edits automatically, and supports row creation and deletion.

The full code is on the backend branch of vue-grid-demo-nuxt.

Step 1. Install SQLite

We use SQLite. It runs from a single file on disk and needs no separate server or Docker setup.

npm install better-sqlite3
npm install -D @types/better-sqlite3

Step 2. Define the schema

Create server/utils/db.ts with one table:

CREATE TABLE records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
firstName TEXT DEFAULT '',
lastName TEXT DEFAULT '',
email TEXT DEFAULT '',
city TEXT DEFAULT '',
score INTEGER DEFAULT 0,
birthday TEXT
);

Two things to note:

  • birthday is TEXT. SQLite has no native date type. We store ISO strings and convert them on the client.
  • Initialize lazily. Open the database on first access, not at module load. This avoids errors when Nuxt build-time workers try to open a file that doesn't exist yet.

Insert sample rows if the table is empty, so the grid has data to display.

Step 3. Create the API routes

Nuxt uses file-based server routes under server/api/. Each file maps to an endpoint based on its name and HTTP method suffix:

server/api/records/
index.get.ts → GET /api/records
index.post.ts → POST /api/records
[id].get.ts → GET /api/records/:id
[id].put.ts → PUT /api/records/:id
[id].patch.ts → PATCH /api/records/:id
[id].delete.ts → DELETE /api/records/:id

The GET handler returns all records:

export default defineEventHandler(() => {
return getAllRecords();
});

You don't need to import anything. Nuxt auto-imports everything from server/utils/ and exposes the database functions globally in server routes.

The full REST surface:

MethodEndpointPurpose
GET/recordsGet all records
POST/recordsCreate record
PUT/records/{id}Update record
PATCH/records/{id}Update single field
DELETE/records/{id}Delete record

The client relies on three response contracts:

  • POST returns { "id": 123 } so the client can update its local ID.
  • DELETE returns {}.
  • PATCH expects { "key": "fieldName", "value": "newValue" } and handles cell-level edits.

Here's the PATCH handler:

export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, "id"));
const { key, value } = await readBody(event);
const recordId = updateRecord(id, { [key]: value });
return { id: Number(recordId) };
});

Open /api/records in your browser to verify the setup. It returns a JSON array of records.

Step 4. Install the data provider

Instead of wiring up fetch calls manually, use SVAR Data Provider:

npm install @svar-ui/grid-data-provider

Step 5. Load data from the server

In DataGrid.vue, replace the hardcoded array with RestDataProvider:

const server = new RestDataProvider(apiUrl, normalize);

onMounted(() => {
server.getData().then((records) => {
data.value = records as IRow[];
});
});

We use normalize to convert date strings back into Date objects:

function normalize(record: Record<string, unknown>) {
if (record.birthday) {
record.birthday = new Date(record.birthday as string);
}
}

RestDataProvider fetches the endpoint, runs normalize on each record, and returns the data ready to bind.

Refresh the page. The grid now shows the database rows. Edits don't persist yet.

Step 6. Connect saves to the grid

One line connects the data provider to the Grid's action pipeline:

function handleInit(gridApi: IApi) {
api.value = gridApi;
gridApi.setNext(server);
}

After setNext, every Grid action flows through the provider. The Grid emits add-row, update-cell, and delete-row. The provider maps those to POST, PATCH, and DELETE. It also updates local IDs after the server assigns them.

This approach needs no manual fetch calls or event handlers.

Edit a cell and refresh the page. The change persists.

Step 7. Add buttons for create and delete

Add the UI:

<div style="display: flex; gap: 10px; margin-bottom: 10px">
<Button :onClick="addRow">
Add Row
</Button>
<Button v-if="dataToEdit" :onClick="deleteRow">
Delete Selected Row
</Button>
</div>

Add the handlers:

function addRow() {
api.value!.exec("add-row", { row: {} });
}

function deleteRow() {
api.value!.exec("delete-row", { id: dataToEdit.value!.id });
}
  • Add Row fires add-row with an empty object. The server assigns the ID and defaults.
  • Delete Selected Row only shows when a row is selected.

Both actions flow through the same pipeline. Neither needs extra backend code.

Step 8. Handle errors

Two places can fail: initial load and saves.

Loading errors. Add a .catch() to the getData() call:

server.getData()
.then((records) => {
data.value = records as IRow[];
})
.catch((error) => {
console.error("Failed to load data:", error);
});

Save errors. When a save fails, the error propagates through the Grid's event system. Two recommendations:

  • Validate on the client first. Prevent bad data from leaving the browser.
  • If a server error reaches the UI, notify the user and reload fresh data from the database. This keeps the UI in sync and avoids complex reconciliation.

The full component

The following snippet shows the complete DataGrid.vue with all pieces assembled:

<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import type { IColumnConfig, IApi, IRow } from "@svar-ui/vue-grid";
import { Grid, getEditorConfig, Willow } from "@svar-ui/vue-grid";
import { DatePicker, Button } from "@svar-ui/vue-core";
import { Editor, registerEditorItem } from "@svar-ui/vue-editor";
import { RestDataProvider } from "@svar-ui/grid-data-provider";
import "@svar-ui/vue-grid/all.css";
import "@svar-ui/vue-editor/style.css";

registerEditorItem("datepicker", DatePicker);

const apiUrl = "/api/records";

function normalize(record: Record<string, unknown>) {
if (record.birthday) {
record.birthday = new Date(record.birthday as string);
}
}

const columns: IColumnConfig[] = [
{ id: "id", width: 60, header: "ID", sort: true },
{ id: "firstName", header: "First Name", width: 150, sort: true, editor: "text" },
{ id: "lastName", header: "Last Name", width: 150, sort: true, editor: "text" },
{ id: "email", header: "Email", width: 220, editor: "text" },
{ id: "city", header: "City", width: 150, sort: true, editor: "text" },
{ id: "score", header: "Score", width: 100, sort: true },
{ id: "birthday", header: "Birthday", width: 150, editor: "datepicker" },
];

const data = ref<IRow[]>([]);
const api = ref<IApi | null>(null);
const dataToEdit = ref<IRow | undefined>(undefined);
const items = computed(() => getEditorConfig(columns));
const server = new RestDataProvider(apiUrl, normalize);

onMounted(() => {
server.getData().then((records) => {
data.value = records as IRow[];
});
});

function handleInit(gridApi: IApi) {
api.value = gridApi;
gridApi.setNext(server);
}

function handleSelectRow(ev: { id: number }) {
dataToEdit.value = api.value!.getRow(ev.id);
}

function handleChange({ key, value }: { key: string; value: any }) {
api.value!.exec("update-cell", {
id: dataToEdit.value!.id,
column: key,
value,
});
}

function handleAction() {
dataToEdit.value = undefined;
}

function addRow() {
api.value!.exec("add-row", { row: {} });
}

function deleteRow() {
api.value!.exec("delete-row", { id: dataToEdit.value!.id });
}
</script>

<template>
<div>
<Willow>
<div style="display: flex; gap: 10px; margin-bottom: 10px">
<Button :onClick="addRow">
Add Row
</Button>
<Button v-if="dataToEdit" :onClick="deleteRow">
Delete Selected Row
</Button>
</div>
<Editor
v-if="dataToEdit"
:values="dataToEdit"
:items="items"
placement="sidebar"
:autoSave="true"
:onchange="handleChange"
:onaction="handleAction"
/>
<Grid
:data="data"
:columns="columns"
:select="true"
:init="handleInit"
:onSelectRow="handleSelectRow"
/>
</Willow>
</div>
</template>

We extended the client-only Grid with:

  • Data loading via RestDataProvider.getData(): fetches from the server and normalizes each record.
  • Automatic saves through api.setNext(server): every Grid action maps to a REST call.
  • Cell-level updates via PATCH: only the changed field goes to the server.
  • Add and delete buttons that work through the same pipeline.

The data provider handles the transport layer. Implement standard REST endpoints for your database, and the Grid connects automatically.