I've been building Fairyfly, a lightweight, backend-focused JavaScript runtime built with Zig and powered by QuickJS.
One of my goals with Fairyfly is to make backend development straightforward: write JavaScript, start an HTTP server, handle requests, and interact with a database without having to assemble a large stack of dependencies.
In this tutorial, we'll build a small REST API using Fairyfly's native HTTP server and SQLite APIs.
We'll create a task manager API that supports creating, retrieving, updating, and deleting tasks.
By the end, we'll have a working backend with persistent database storage, input validation, and appropriate HTTP status codes.
What we're building
Our API will expose the following endpoints:
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/health |
Check server health |
GET |
/api/tasks |
Retrieve all tasks |
POST |
/api/tasks |
Create a task |
GET |
/api/tasks/:id |
Retrieve a specific task |
PATCH |
/api/tasks/:id |
Update a task |
DELETE |
/api/tasks/:id |
Delete a task |
We'll use SQLite to store tasks locally. This means the data will remain available after the server restarts.
No Express, no external database server, and no npm dependencies are required for this example.
1. Install Fairyfly
If you haven't installed Fairyfly yet, follow the installation instructions in the GitHub repository.
For supported macOS and Linux systems, you can use the installation script:
curl -fsSL https://raw.githubusercontent.com/AbdElMajidOuhamane/ff/main/install.sh | sh
Verify that the runtime is available:
ff --version
If Fairyfly is already installed, you can skip this step.
2. Create the project
Create a directory for our application:
mkdir fairyfly-tasks
cd fairyfly-tasks
Create a file called server.js.
Our entire backend will live in this file. We'll use Fairyfly's built-in APIs directly instead of importing a web framework.
3. Start with Fairyfly's HTTP server
The basic shape of a Fairyfly HTTP server looks like this:
http.serve({ port: 3000 }, (url, method, body) => {
if (url === "/hello") {
return new Response("Hello from Fairyfly!");
}
return new Response("Not found", { status: 404 });
});
Run it using:
ff server.js
There are a few important details here.
First, http.serve() starts the HTTP server. Its handler receives three arguments: url, method, and body.
Unlike frameworks that give you a request object, this handler receives the URL, HTTP method, and request body as separate values. The body is a string, so we need to parse JSON ourselves when accepting JSON requests.
Second, the handler returns a Response. We can use Response.json() when we want to return JSON, including a specific HTTP status code.
For the task manager, we'll expand this simple handler into a complete API.
4. Build the REST API
Replace the contents of server.js with the following implementation:
const PORT = 3000;
const db = Database.open("tasks.db");
// Create the table if it does not exist.
db.execNoArgs(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
function json(data, status = 200) {
return Response.json(data, { status });
}
// Request bodies arrive as strings, so handle malformed JSON.
function parseJson(body) {
try {
return {
value: JSON.parse(body || "{}"),
error: null,
};
} catch {
return {
value: null,
error: "Request body must be valid JSON",
};
}
}
// Convert database rows into our public API format.
function normalizeTask(row) {
return {
id: row.id,
title: row.title,
completed: row.completed === 1,
createdAt: row.created_at,
};
}
http.serve({ port: PORT }, (url, method, body) => {
try {
const requestUrl = new URL(url, "http://localhost");
const path = requestUrl.pathname;
// API overview
if (path === "/" && method === "GET") {
return new Response(
"Fairyfly Tasks API\n\n" +
"GET /api/health\n" +
"GET /api/tasks\n" +
"POST /api/tasks\n" +
"GET /api/tasks/:id\n" +
"PATCH /api/tasks/:id\n" +
"DELETE /api/tasks/:id\n"
);
}
// Health check
if (path === "/api/health" && method === "GET") {
return json({
ok: true,
runtime: "Fairyfly",
});
}
// List tasks
if (path === "/api/tasks" && method === "GET") {
const rows = db.rows(
`SELECT id, title, completed, created_at
FROM tasks
ORDER BY id DESC`
);
return json(rows.map(normalizeTask));
}
// Create a task
if (path === "/api/tasks" && method === "POST") {
const parsed = parseJson(body);
if (parsed.error) {
return json({ error: parsed.error }, 400);
}
const data = parsed.value;
if (
!data ||
typeof data !== "object" ||
Array.isArray(data)
) {
return json({
error: "Request body must be a JSON object",
}, 400);
}
if (
typeof data.title !== "string" ||
!data.title.trim()
) {
return json({
error: "title is required and must be a non-empty string",
}, 400);
}
const title = data.title.trim();
if (title.length > 200) {
return json({
error: "title must be 200 characters or fewer",
}, 400);
}
db.exec(
"INSERT INTO tasks (title) VALUES (?)",
[title]
);
const id = db.lastInsertRowId();
const created = db.row(
`SELECT id, title, completed, created_at
FROM tasks
WHERE id = ?`,
[id]
);
return json(normalizeTask(created), 201);
}
// Match routes such as /api/tasks/1
const taskMatch = path.match(/^\/api\/tasks\/(\d+)$/);
if (taskMatch) {
const id = Number(taskMatch[1]);
if (!Number.isSafeInteger(id) || id < 1) {
return json({ error: "Invalid task id" }, 400);
}
// Retrieve a single task
if (method === "GET") {
const row = db.row(
`SELECT id, title, completed, created_at
FROM tasks
WHERE id = ?`,
[id]
);
if (!row) {
return json({ error: "Task not found" }, 404);
}
return json(normalizeTask(row));
}
// Update a task
if (method === "PATCH") {
const parsed = parseJson(body);
if (parsed.error) {
return json({ error: parsed.error }, 400);
}
const data = parsed.value;
if (
!data ||
typeof data !== "object" ||
Array.isArray(data)
) {
return json({
error: "Request body must be a JSON object",
}, 400);
}
const assignments = [];
const params = [];
if ("title" in data) {
if (
typeof data.title !== "string" ||
!data.title.trim()
) {
return json({
error: "title must be a non-empty string",
}, 400);
}
const title = data.title.trim();
if (title.length > 200) {
return json({
error: "title must be 200 characters or fewer",
}, 400);
}
assignments.push("title = ?");
params.push(title);
}
if ("completed" in data) {
if (typeof data.completed !== "boolean") {
return json({
error: "completed must be a boolean",
}, 400);
}
assignments.push("completed = ?");
params.push(data.completed ? 1 : 0);
}
if (assignments.length === 0) {
return json({
error: "Provide title and/or completed to update",
}, 400);
}
params.push(id);
db.exec(
`UPDATE tasks
SET ${assignments.join(", ")}
WHERE id = ?`,
params
);
if (db.changes() === 0) {
return json({ error: "Task not found" }, 404);
}
const updated = db.row(
`SELECT id, title, completed, created_at
FROM tasks
WHERE id = ?`,
[id]
);
return json(normalizeTask(updated));
}
// Delete a task
if (method === "DELETE") {
db.exec(
"DELETE FROM tasks WHERE id = ?",
[id]
);
if (db.changes() === 0) {
return json({ error: "Task not found" }, 404);
}
return json({ deleted: true, id });
}
return json({ error: "Method not allowed" }, 405);
}
return json({ error: "Not found" }, 404);
} catch (error) {
console.log("Request failed:", String(error));
return json({
error: "Internal server error",
}, 500);
}
});
console.log(`Fairyfly Tasks API listening on port ${PORT}`);
Let's look at the important parts of the implementation before testing it.
Initialize SQLite
We open a local database with:
const db = Database.open("tasks.db");
The CREATE TABLE IF NOT EXISTS statement creates our table on the first run. The table contains an automatically generated ID, a title, a completion flag, and a creation timestamp.
Fairyfly exposes SQLite through its built-in Database API, so we don't need to install a separate JavaScript database package.
The database file is relative to the current working directory. Running ff server.js from our project directory keeps tasks.db there.
Parse and validate JSON
The parseJson() function protects the API against malformed JSON.
For example, a request containing an invalid JSON body should return 400 Bad Request, rather than causing an unhandled parsing error.
We also validate the task title and reject empty titles or titles longer than 200 characters.
Validation is important because clients cannot be trusted to send valid data.
Use parameterized SQL
Notice how database operations pass values separately:
db.exec(
"INSERT INTO tasks (title) VALUES (?)",
[title]
);
The ? placeholder binds the provided value to the SQL statement. We use the same approach for database lookups, updates, and deletions.
This is preferable to concatenating user input into SQL strings and helps protect against SQL injection.
Return consistent JSON responses
Our helper function keeps JSON responses simple:
function json(data, status = 200) {
return Response.json(data, { status });
}
We use 200 OK for successful reads and updates, 201 Created when creating a task, 400 Bad Request for invalid input, 404 Not Found when a task doesn't exist, and 405 Method Not Allowed when a task route receives an unsupported method.
5. Start the server
From the same directory as server.js, run:
ff server.js
The API will listen on port 3000.
Open another terminal to send requests. Keep the server process running while testing the endpoints.
First, verify the health endpoint:
curl http://127.0.0.1:3000/api/health
Expected response:
{
"ok": true,
"runtime": "Fairyfly"
}
This confirms that the server responds to HTTP requests.
6. Create a task
Send a POST request with a JSON body:
curl -X POST http://127.0.0.1:3000/api/tasks \
-H 'content-type: application/json' \
-d '{"title":"Build something with Fairyfly"}'
The response should look like this:
{
"id": 1,
"title": "Build something with Fairyfly",
"completed": false,
"createdAt": "2026-10-11 21:10:05"
}
The ID and timestamp will depend on your database.
The server inserts the task into SQLite, retrieves the newly created row, and returns it with a 201 Created status.
7. Retrieve all tasks
Now fetch the collection:
curl http://127.0.0.1:3000/api/tasks
Example response:
[
{
"id": 1,
"title": "Build something with Fairyfly",
"completed": false,
"createdAt": "2026-10-11 21:10:05"
}
]
The response is an array of tasks, ordered by ID from newest to oldest.
If no tasks exist, the API returns an empty array:
[]
8. Retrieve a single task
Use the task's ID in the URL:
curl http://127.0.0.1:3000/api/tasks/1
The handler extracts the ID from the URL, queries SQLite, and returns the matching task.
If the ID doesn't exist, the API responds with a 404 status and a JSON error.
9. Update a task
Let's mark our task as completed:
curl -X PATCH http://127.0.0.1:3000/api/tasks/1 \
-H 'content-type: application/json' \
-d '{"completed":true}'
Response:
{
"id": 1,
"title": "Build something with Fairyfly",
"completed": true,
"createdAt": "2026-10-11 21:10:05"
}
We can also update the title:
curl -X PATCH http://127.0.0.1:3000/api/tasks/1 \
-H 'content-type: application/json' \
-d '{"title":"Build a REST API with Fairyfly"}'
The implementation supports updating either property independently or both in the same request.
10. Delete a task
Finally, delete the task:
curl -X DELETE http://127.0.0.1:3000/api/tasks/1
Response:
{
"deleted": true,
"id": 1
}
Confirm that it has been removed:
curl http://127.0.0.1:3000/api/tasks
Expected response:
[]
If you already have other tasks in your database, the array may contain those instead.
Note: If you're following these steps against a database you've used before, the newly created task may not have ID 1. Use the ID returned by the POST request in subsequent commands.
11. Verify database persistence
An API that stores data should preserve it when the server restarts.
Let's test that.
First, create a task:
curl -X POST http://127.0.0.1:3000/api/tasks \
-H 'content-type: application/json' \
-d '{"title":"Test SQLite persistence"}'
Stop the Fairyfly process with Ctrl+C, then start it again:
ff server.js
Now request the task collection:
curl http://127.0.0.1:3000/api/tasks
The task should still be there.
I ran this test with Fairyfly and confirmed that the task remained available after restarting the server. Because the database is stored on disk, its data survives a normal process restart.
What we've built
We've now implemented a complete REST-style API with Fairyfly and SQLite.
The application can create, list, retrieve, update, and delete tasks. It handles JSON input, validates user-supplied fields, uses parameterized SQL, and returns meaningful HTTP status codes.
The architecture is deliberately simple:
-
http.serve()handles incoming HTTP requests. -
ResponseandResponse.json()produce responses. -
Databaseprovides SQLite storage. - A small routing function maps HTTP methods and paths to operations.
There is no external web framework, package installation, or separately managed database server in this example.
A few things to keep in mind
This is a learning example, not a production-ready service.
Fairyfly is still evolving, so check the official documentation for the current APIs and limitations.
Final thoughts
Building this small application was a useful way to exercise Fairyfly's HTTP and SQLite APIs in a real workflow rather than just running a hello-world example.
The goal of Fairyfly is to provide a lightweight environment for backend JavaScript, with useful capabilities available directly from the runtime.
If you're interested in trying it, you can find the project and installation instructions here:
GitHub: https://github.com/AbdElMajidOuhamane/ff
Fairyfly is an early-stage open-source project, and feedback is welcome. If you try this tutorial, find a bug, or have ideas for improving the runtime, I'd love to hear about your experience.
Top comments (0)