Skip to main content

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:

  • start and end: 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 value
  • type on tasks: can be "summary", "milestone", or null for regular tasks
  • type on 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} />

main

RestDataProvider.getData() handles two things:

  1. Fetches /api/tasks and /api/links in parallel
  2. Converts date strings from JSON into JavaScript Date objects

Saving changes

To save changes to the server, set up a full set of REST endpoints:

MethodEndpointPurpose
GET/api/tasksGet all tasks
POST/api/tasksCreate task
PUT/api/tasks/{id}Update task
DELETE/api/tasks/{id}Delete task
GET/api/linksGet all links
POST/api/linksCreate 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.

toolbar

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: Toolbar provides common operations, Editor allows detailed editing

Use this demo as a starting point. You can extend the REST endpoints and database layer with your own business logic.