Adding a backend
This guide continues from Setting up SVAR Gantt in SvelteKit. We have a working Gantt chart with client-side data and editing. Now let's add a backend to persist changes.
The complete source code is available at github.com/svar-widgets/gantt-demo-sveltekit. This guide covers the backend branch.
Why a backend?
Without a backend, changes disappear on page refresh. A backend adds:
- Persistent storage for tasks and links
- Multi-user access to the same project data
- Server-side validation and business logic
The demo uses SQLite as the storage layer. It's file-based, requires no separate server, and works well for small teams and demos.
Setting up the database
Install the SQLite package:
npm install better-sqlite3
npm install -D @types/better-sqlite3
The database module is at src/lib/server/db.ts. The schema has two tables:
CREATE TABLE tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT DEFAULT '',
start TEXT,
end TEXT,
duration INTEGER,
progress INTEGER DEFAULT 0,
type TEXT,
parent INTEGER DEFAULT 0,
orderId INTEGER DEFAULT 0
);
CREATE TABLE links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source INTEGER NOT NULL,
target INTEGER NOT NULL,
type TEXT NOT NULL
);
Note the following about the schema:
startandend: the database stores these as TEXT (ISO date strings)parent: references another task's ID for hierarchical structures (0 = top level)orderId: maintains display order within each branch; siblings sort by this valuetypeon tasks: can be "summary", "milestone", or null for regular taskstypeon links: defines the dependency type. Possible values: "e2s" (end-to-start), "s2s", "e2e", "s2e"
The database initializes on first access. If tables are empty, it populates them with sample data.
Loading data
Server side
Create +server.ts files in SvelteKit to expose tasks and links. The tasks route at src/routes/api/tasks/+server.ts looks like this:
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAllTasks, createTask } from '$lib/server/db';
export const GET: RequestHandler = async () => {
const tasks = getAllTasks();
return json(tasks);
};
The links route follows the same pattern. The database stores dates as ISO strings. RestDataProvider converts them on load.
Client side
To connect Gantt to your backend, install the data provider package:
npm install @svar-ui/gantt-data-provider
Instead of hardcoded arrays, initialize a RestDataProvider and call its getData() method:
<script lang="ts">
import { browser } from "$app/environment";
import { RestDataProvider } from "@svar-ui/gantt-data-provider";
let tasks = $state<any[]>([]);
let links = $state<any[]>([]);
const server = new RestDataProvider("/api");
if (browser) {
server.getData().then((data) => {
tasks = data.tasks;
links = data.links;
});
}
</script>
You can also follow one of the approaches here:
Option 1 (client-side loading):
You can use the browser check to limit data loading to client-side code only and start it as early as possible. Gantt can render without data:
- Gantt will show an empty chart initially and populate when data arrives
- Users see the Gantt interface immediately rather than waiting for a full page load (better UX)
Option 2 (server-side data loading with SvelteKit's load function):
You can alternatively move data loading to +page.server.ts using SvelteKit's load function. It helps avoid the empty state but introduces a delay: the page only renders once the data fetch completes. The following snippet shows server-side data loading with SvelteKit's load function:
// src/routes/+page.server.ts
import { getAllTasks, getAllLinks } from '$lib/server/db';
export function load() {
return {
tasks: getAllTasks(),
links: getAllLinks()
};
}
<!-- src/routes/+page.svelte -->
<script lang="ts">
let { data } = $props();
</script>
<GanttChart tasks={data.tasks} links={data.links} />

RestDataProvider.getData() handles two things:
- Fetches
/api/tasksand/api/linksin parallel - Converts date strings from JSON into JavaScript
Dateobjects
Saving changes
To save changes to the server, set up a full set of REST endpoints:
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/tasks | Get all tasks |
| POST | /api/tasks | Create task |
| PUT | /api/tasks/{id} | Update task |
| DELETE | /api/tasks/{id} | Delete task |
| GET | /api/links | Get all links |
| POST | /api/links | Create link |
| PUT | /api/links/{id} | Update link |
| DELETE | /api/links/{id} | Delete link |
For POST and PUT, the response must include the ID: { "id": 123 }. For DELETE, return an empty object: {}.
SvelteKit route structure
Organize the API routes as follows:
src/routes/api/
├── tasks/
│ ├── +server.ts # GET, POST
│ └── [taskId]/
│ └── +server.ts # GET, PUT, DELETE
└── links/
├── +server.ts # GET, POST
└── [id]/
└── +server.ts # GET, PUT, DELETE
The task update/delete route at src/routes/api/tasks/[taskId]/+server.ts handles all three operations:
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getTaskById, updateTask, deleteTask, moveTask } from '$lib/server/db';
export const GET: RequestHandler = async ({ params }) => {
const task = getTaskById(Number(params.taskId));
if (!task) {
throw error(404, 'Task not found');
}
return json(task);
};
export const PUT: RequestHandler = async ({ params, request }) => {
const body = await request.json();
if (body.operation === 'move') {
const id = moveTask(Number(params.taskId), body.target, body.mode);
return json({ id: Number(id) });
}
const id = updateTask(Number(params.taskId), body);
return json({ id: Number(id) });
};
export const DELETE: RequestHandler = async ({ params }) => {
deleteTask(Number(params.taskId));
return json({});
};
See the REST routes reference for detailed request and response formats.
Connecting the component
With endpoints ready, connect the DataProvider to Gantt through its init callback:
<script lang="ts">
function init(ganttApi: any) {
api = ganttApi;
ganttApi.setNext(server);
}
</script>
<Gantt {tasks} {links} {scales} {init} />
Call setNext to plug the Provider into the component's action pipeline. When a user creates, updates, deletes, or moves a task, the action flows through the Provider to the appropriate REST endpoint.
How does this work internally?
The Gantt emits actions for every data operation: add-task, update-task, delete-task, and so on. You don't write custom event handlers or manage state rollbacks. RestDataProvider handles the mapping from Gantt actions to REST calls, including updating local IDs after creation.
Handle row reordering
Users can drag tasks to reorder them in the grid. The client side already handles this: RestDataProvider sends move operations automatically. The server needs to track display order separately.
When a task moves, the request includes extra fields:
{
"operation": "move",
"mode": "after",
"target": 4
}
This means "place this task after task #4". The mode can be "after", "before", or "child" (to nest under another task).
The PUT endpoint checks for the operation field. When it's a move operation, instead of updating task properties, we recalculate the task's parent and orderId based on where it goes. The server updates orderId values for all siblings to keep the display order consistent.
POST requests for new tasks can also include mode and target to specify where in the hierarchy the task appears.
Adding the Toolbar
Let's add it to test reordering, along with other operations like adding tasks, deleting, and indenting. Toolbar is included in the Gantt package.
The Toolbar needs the Gantt API reference to trigger actions. Capture it during initialization and pass it to both Toolbar and Editor:
<Willow>
{#if browser}
<Toolbar {api} />
<Gantt {tasks} {links} {scales} {init} />
<Editor {api} />
{/if}
</Willow>
Clicking "New Task" in the Toolbar creates a task through the same action pipeline, which flows through the data provider to the REST endpoint.
Handling errors
Loading errors
getData() returns a promise, so handling fetch failures is straightforward:
<script lang="ts">
if (browser) {
server.getData()
.then((data) => {
tasks = data.tasks;
links = data.links;
})
.catch((error) => {
// Show error UI, retry option, etc.
console.error("Failed to load data:", error);
});
}
</script>
Save operation errors
Reacting to errors
When a save fails, Gantt emits the failed action with error details. You can listen for specific actions and respond. For instance, remove a task when its save fails.
Validation
The server isn't the right layer for validation. Validate on the client to prevent invalid operations before they start. Server errors should be rare exceptions, not part of the normal flow.
Recovery
On failure, inform the user and, after their confirmation, reload the Gantt with fresh data. This keeps the UI in sync with the actual database state.
Error handling with RestDataProvider
To handle all REST errors in one place, subclass RestDataProvider and override the send method:
class MyDataProvider extends RestDataProvider {
async send<T>(
url: string,
method: string,
data?: any,
customHeaders: any = {}
): Promise<T> {
try {
return await super.send(url, method, data, customHeaders);
} catch (error) {
// Show toast notification, log to monitoring service, etc.
showErrorNotification("Failed to save changes");
throw error;
}
}
}
This intercepts all REST operations in one place. It's the simplest way to detect failures and surface them in your UI.
Progress and sync state
Related to error handling is tracking operation progress. Avoid showing a loading spinner for the initial data fetch. Data loads quickly, and a spinner causes unnecessary flickering.
Loading progress
If you do need a loading indicator, wrap the getData() call:
<script lang="ts">
let loading = $state(true);
if (browser) {
server.getData()
.then((data) => {
tasks = data.tasks;
links = data.links;
})
.finally(() => loading = false);
}
</script>
Tracking all server operations
To show progress for save operations as well, override send in a custom provider. The onProgress callback is a function you define and pass when creating the provider:
class MyDataProvider extends RestDataProvider {
constructor(url: string, private onProgress: (active: boolean) => void) {
super(url);
}
async send<T>(url: string, method: string, data?: any, headers: any = {}): Promise<T> {
this.onProgress(true);
try {
return await super.send(url, method, data, headers);
} finally {
this.onProgress(false);
}
}
}
// Usage:
let saving = $state(false);
const server = new MyDataProvider("/api", (active) => saving = active);
This fires on every REST call, covering the initial load and all subsequent saves.
Complete component code
The following snippet shows the full GanttChart.svelte with all pieces connected:
<script lang="ts">
import { browser } from "$app/environment";
import { Gantt, Willow, Editor, Toolbar } from "@svar-ui/svelte-gantt";
import { RestDataProvider } from "@svar-ui/gantt-data-provider";
let api = $state<any>(null);
let tasks = $state<any[]>([]);
let links = $state<any[]>([]);
const server = new RestDataProvider("/api");
const scales = [
{ unit: "month", step: 1, format: "%M %Y" },
{ unit: "week", step: 1, format: "Week %w" },
];
if (browser) {
server.getData().then((data) => {
tasks = data.tasks;
links = data.links;
});
}
function init(ganttApi: any) {
api = ganttApi;
ganttApi.setNext(server);
}
</script>
<div style="height: 100%; width: 100%;">
<Willow>
{#if browser}
<Toolbar {api} />
<Gantt {tasks} {links} {scales} {init} />
<Editor {api} />
{/if}
</Willow>
</div>
Summary
Gantt now syncs all changes to the server:
- Loading:
RestDataProvider.getData()fetches and parses data - Saving: actions flow through
api.setNext(server)to REST endpoints - Reordering: special
operation: "move"requests update task positions - UI:
Toolbarprovides common operations,Editorallows detailed editing
Use this demo as a starting point. You can extend the REST endpoints and database layer with your own business logic.