DEV Community

Majid
Majid

Posted on

Build a REST API with Fairyfly and SQLite

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
Enter fullscreen mode Exit fullscreen mode

Verify that the runtime is available:

ff --version
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

Run it using:

ff server.js
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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]
);
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Expected response:

{
  "ok": true,
  "runtime": "Fairyfly"
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

The response should look like this:

{
  "id": 1,
  "title": "Build something with Fairyfly",
  "completed": false,
  "createdAt": "2026-10-11 21:10:05"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Example response:

[
  {
    "id": 1,
    "title": "Build something with Fairyfly",
    "completed": false,
    "createdAt": "2026-10-11 21:10:05"
  }
]
Enter fullscreen mode Exit fullscreen mode

The response is an array of tasks, ordered by ID from newest to oldest.

If no tasks exist, the API returns an empty array:

[]
Enter fullscreen mode Exit fullscreen mode

8. Retrieve a single task

Use the task's ID in the URL:

curl http://127.0.0.1:3000/api/tasks/1
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "id": 1,
  "title": "Build something with Fairyfly",
  "completed": true,
  "createdAt": "2026-10-11 21:10:05"
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "deleted": true,
  "id": 1
}
Enter fullscreen mode Exit fullscreen mode

Confirm that it has been removed:

curl http://127.0.0.1:3000/api/tasks
Enter fullscreen mode Exit fullscreen mode

Expected response:

[]
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

Stop the Fairyfly process with Ctrl+C, then start it again:

ff server.js
Enter fullscreen mode Exit fullscreen mode

Now request the task collection:

curl http://127.0.0.1:3000/api/tasks
Enter fullscreen mode Exit fullscreen mode

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.
  • Response and Response.json() produce responses.
  • Database provides 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)