REST API Crash Course - Full ElysiaJS CRUD API Beginner Tutorial

Timestamps

  • - Intro
  • - A look at Elysia
  • - Video overview
  • - What is bun?
  • - Installation
  • - Create a Repo
  • - Creating a route or endpoint
  • - Details Endpoint
  • - Automatic documentation
  • - Data validation
  • - Working with a Database
  • - Updating and deleting data
  • - Delete Endpoint
  • - Update with PUT
  • - Errors
  • - Object Relational Mappers (ORM)
  • - Postgres and Docker installation
  • - psql
  • - Get Data with Drizzle
  • - Insert with Drizzle
  • - Deleting with Drizzle
  • - Updating with Drizzle
  • - Type Validation
  • - Array Destructuring
  • - Put vs Patch
  • - What's next?

Full code for this video is on GitHub: https://github.com/CalebCurry/elysia

Elysia is a backend TypeScript framework for building APIs, and it runs on Bun instead of Node. Bun is a JavaScript runtime, so you can think of it as an alternative to Node.

What Elysia gives you out of the box:

  • request validation built in, so the data you work with matches your rules at runtime AND is typed while you write the code
  • type safety all the way through, including types shared between client and server
  • first class OpenAPI documentation from the very first route

The documentation page specs out the whole API, so it's what you hand to whoever builds a frontend, a client library, or an MCP server against it (or an agent doing the same).

What we're building

An API for running marketing campaigns. A campaign has a name and a due date, and later on it could have content pieces hanging off of it (social posts, blogs, newsletters, whatever).

For this video it's just campaigns, and by the end we'll have full CRUD:

GET    /api/v1/campaigns
GET    /api/v1/campaigns/:id
POST   /api/v1/campaigns
PUT    /api/v1/campaigns/:id
DELETE /api/v1/campaigns/:id

We start on mock data, move to SQLite with raw SQL, then finish on Postgres through an ORM (Drizzle).

What is Bun

Bun is on v1.4 as I record this. The first 1.0 came out September 8th, 2023, so it's a few years old and past the point where it's a bunch of bugs. It's got almost 100,000 stars on GitHub and some pretty big users: Claude Code ships as a Bun single file executable, Midjourney runs notifications through Bun's WebSocket server, and Railway built its serverless functions on it. Bun is also part of Anthropic now, which I didn't know until I was researching this.

It runs TypeScript files directly, which takes a chunk of the usual TypeScript setup off your plate.

💡 Tip[!tip]
Bun strips the types and runs the JavaScript underneath, it does NOT typecheck. If you want type errors reported you still run tsc --noEmit (or lean on your editor).

Installation

Mac and Linux:

Bash
curl -fsSL https://bun.com/install | bash

Windows (PowerShell):

Bash
powershell -c "irm bun.sh/install.ps1|iex"

I keep all my projects in one folder, so make a directory for the project and scaffold in to it. The project is called omnicopy (copy as in marketing text, and it's supposed to be everywhere, I don't know, naming is hard):

Bash
mkdir omnicopy
cd omnicopy
bun create elysia .

The . means the current directory. If you'd rather it make the folder for you, give it the name instead: bun create elysia omnicopy.

Then run it:

Bash
bun run src/index.ts

If you get Failed to start server. Is port 3000 in use? something else is already on 3000. Kill it, or change the port in src/index.ts.

Open http://localhost:3000 and you get "Hello Elysia". The whole server is in src/index.ts:

TypeScript
import { Elysia } from "elysia";

const app = new Elysia().get("/", () => "Hello Elysia").listen(3000);

console.log(
  `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
);

Change the string and refresh, and nothing happens. bun run doesn't watch for changes. Use the dev script instead:

Bash
bun dev

That runs bun run --watch src/index.ts, which comes from the dev script in package.json. Same idea as npm run dev in Node, it's just a convention.

Create a Repo

The scaffold already made a git repo and a .gitignore, so commit right away:

Bash
git add .
git commit -m "initial commit"

Then create a repo on GitHub and push to it.

The generated .gitignore is decent, but it's missing two things we're about to create: database files and a plain .env (it only has the scoped ones like .env.local). Add these at the top:

.env
*.db

A .env is where anything sensitive goes, like a database password, and the .gitignore is what keeps it out of the repo. In VS Code the file goes from green to gray once it's ignored, which is a quick way to check.

Bash
git add .
git commit -m "update .gitignore"
git push origin main

Creating a route or endpoint

Some vocabulary first. A resource is the thing you're working with, so campaigns. An endpoint is any one thing you can do with that resource: GET it, POST it, PUT it, DELETE it. They'll all share the same path, just with different HTTP methods.

We'll mock the data with an array first, so the API side can get built without a database in the way, then swap in the database after. At the top of src/index.ts:

TypeScript
const campaigns = [
    {
        campaignId: 1,
        name: 'Summer Sale',
        dueDate: new Date(),
        createdAt: new Date(),
    },
    {
        campaignId: 2,
        name: 'Black Friday',
        dueDate: new Date(),
        createdAt: new Date(),
    },
];

Every route is going to live under /api/v1, and instead of typing that on each one you pass a prefix to the constructor. I also put each chained method on its own line so I don't lose any of them, with .listen() always last:

TypeScript
const app = new Elysia({ prefix: '/api/v1' })
    .get('/campaigns', () => {
        return { data: campaigns };
    })
    .get('/', () => 'Hello Caleb!')
    .listen(3000);

With the prefix, localhost:3000 is now a 404 and the hello route moved to http://localhost:3000/api/v1. The campaigns are at http://localhost:3000/api/v1/campaigns. Returning a plain object sends it back as JSON.

You could write { campaigns } (shorthand when the key and variable name match), but I wrap everything in data. It's the same on every endpoint, it works for one item or a list, and it leaves room for other attributes later, like pagination info.

Details Endpoint

Now one campaign by id. The :id in the path is a parameter, and it shows up on params:

TypeScript
.get('/campaigns/:id', ({ params: { id }, status }) => {
    const campaign = campaigns.find((c) => c.campaignId == Number(id));
    if (!campaign) {
        return status(404);
    }
    return { data: campaign };
})

The handler gets one argument, the context, which describes the request. You could write (context) => context.params.id, the parentheses above are just destructuring that same object to grab params (and id out of it) plus status.

id comes in as a string, so it gets converted with Number() for now. Try /api/v1/campaigns/1, /2, and something that doesn't exist to see the 404.

Here's what else is on the context:

  • body, query, params, headers, cookie, store for request data
  • redirect, status, set for building the response
  • request, server, path for the raw request info

More on the handler and context in the docs.

💡 Tip[!tip]
Coming from Express, the shape is different. Express gives you (req, res) and you call res.status(404).json(...). In Elysia you get one context object and you RETURN the response. A plain object goes out as JSON with a 200, and status() is how you pick a different code.

Bash
git add .
git commit -m "details api example"

Automatic documentation

Two routes is already enough to get a documentation page:

Bash
bun add @elysia/openapi
TypeScript
import openapi from '@elysia/openapi';

const app = new Elysia({ prefix: '/api/v1' })
    .use(openapi())

Then go to http://localhost:3000/api/v1/openapi. It's under /api/v1 because of the prefix, not /openapi like the docs show.

You can send requests right from this page. Open the :id endpoint, give it 1, run it, and the response shows up below, so you don't have to stare at raw JSON in the browser. I've worked with other languages and frameworks where getting a spec like this built out took a lot of time even with a library helping, so this one is pretty nice.

💡 Tip[!tip]
You'll see a lot of older tutorials installing @elysiajs/swagger. The OpenAPI plugin replaced it. The plugins are also published under both the @elysia/ and @elysiajs/ scopes, and the docs use @elysia/ now.

Data validation

This is checking data at RUNTIME. TypeScript only checks the code you write, it can't stop a user from sending you the wrong thing, so validation is what makes sure requests actually have the type and structure you expect.

Import t and give the route a third argument, an object describing what you expect:

TypeScript
import { Elysia, t } from 'elysia';

.get(
    '/campaigns/:id',
    ({ params: { id }, status }) => {
        const campaign = campaigns.find((c) => c.campaignId == id);
        if (!campaign) {
            return status(404);
        }
        return { data: campaign };
    },
    {
        params: t.Object({ id: t.Number() }),
    },
)

The Number() cast is gone. The schema says id is a number, so Elysia converts it before your handler runs, and TypeScript knows it's a number too.

Now try /api/v1/campaigns/hello:

{
  "type": "validation",
  "on": "params",
  "property": "/id",
  "message": "Expected union value",
  "summary": "Property 'id' should be one of: 'numeric', 'number'",
  ...
}

Without the schema that same request was just a 404 not found, which doesn't tell the caller anything. And if you go back to the docs page, the expected structure for id is described there now, so whoever uses the API isn't guessing.

t is a fork of TypeBox. If you already know Zod, or already have Zod schemas, Elysia supports those too through Standard Schema, you don't have to use t.

90% of what you'll write looks like this:

TypeScript
{
    params: t.Object({ id: t.Number() }),
    query: t.Object({ limit: t.Optional(t.Number()) }),
    body: t.Object({
        name: t.String(),
        dueDate: t.Optional(t.String())
    })
}

params is the URL, query is the stuff after the ?, and body is the POST or PUT payload. There's a lot more in the validation docs, but once you know what you're trying to allow it's easy to look up the exact type.

Working with a Database

I don't want to build every endpoint on the array and then go back and change all of them, so the database comes in now, before POST, PUT and DELETE exist. Bun has SQLite built in, so there's nothing to install:

TypeScript
import { Database } from 'bun:sqlite';

const db = new Database('campaigns.db');

db.run(`
    CREATE TABLE IF NOT EXISTS campaigns (
      campaign_id INTEGER PRIMARY KEY AUTOINCREMENT,
      name TEXT NOT NULL,
      due_date TEXT,
      created_at TEXT NOT NULL
    )
`);

This runs every time the server starts (which with bun dev is every save), and that's fine because of IF NOT EXISTS. Take that part out and you'll get this on the next save:

SQLiteError: table campaigns already exists

Columns are snake_case, the SQL convention. SQLite stores dates as text, and NOT NULL means required, so due_date is the only optional column.

Seeding

Seeding is putting starter data in the tables, usually for development (or something like an admin user in production). Only do it if the table is empty, otherwise you get a new row every restart:

TypeScript
if (!db.query('SELECT 1 FROM campaigns').get()) {
    db.run(`INSERT INTO campaigns (name, created_at) VALUES (?, ?)`, [
        'Cyber Monday',
        new Date().toISOString(),
    ]);
}

SELECT 1 ... .get() gives you nothing back when there are no rows, so ! flips that to true and we insert. We don't name campaign_id (the database assigns it) or due_date (optional).

Those ? marks are placeholders. The values get passed in separately in the array, and SQLite binds them as data instead of pasting them in to the SQL string, which is how you avoid SQL injection. Never build a query by sticking user input in to the string.

Now delete (or comment out) the mock campaigns array and point the two GET routes at the database:

TypeScript
.get('/campaigns', () => {
    return {
        data: db.query('SELECT * FROM campaigns').all(),
    };
})
.get(
    '/campaigns/:id',
    ({ params: { id }, status }) => {
        const campaign = db
            .query('SELECT * FROM campaigns WHERE campaign_id = ?')
            .get(id);
        if (!campaign) {
            return status(404);
        }
        return { data: campaign };
    },
    {
        params: t.Object({ id: t.Number() }),
    },
)

.all() returns every row, .get() returns the first one (or null). Test both from the docs page and you get the Cyber Monday row back.

Aliases

Look at the JSON though, it says campaign_id and due_date. The API is leaking the database's snake_case. Not the end of the world, but the easy fix is an alias on each column with AS:

TypeScript
.get('/campaigns', () => {
    return {
        data: db
            .query(
                'SELECT campaign_id AS campaignId, name, due_date AS dueDate, created_at AS createdAt FROM campaigns',
            )
            .all(),
    };
})
.get(
    '/campaigns/:id',
    ({ params: { id }, status }) => {
        const campaign = db
            .query(
                'SELECT campaign_id AS campaignId, name, due_date AS dueDate, created_at AS createdAt FROM campaigns WHERE campaign_id = ?',
            )
            .get(id);
        if (!campaign) {
            return status(404);
        }
        return { data: campaign };
    },
    {
        params: t.Object({ id: t.Number() }),
    },
)

It's a bit of extra typing. An ORM can do this conversion for you automatically, which we get to later.

Updating and deleting data

Time to create data. POST goes on the same /campaigns path as the "get all" route, since you're adding to that collection:

TypeScript
.post(
    '/campaigns',
    ({ body, status }) => {
        const campaign = db
            .query(
                `
    INSERT INTO campaigns (name, due_date, created_at)
    VALUES (?, ?, ?)
    RETURNING campaign_id AS campaignId, name, due_date AS dueDate, created_at AS createdAt
  `,
            )
            .get(body.name, body.dueDate ?? null, new Date().toISOString());

        return status(201, { data: campaign });
    },
    {
        body: t.Object({
            name: t.String(),
            dueDate: t.Optional(t.String({ format: 'date' })),
        }),
    },
)

RETURNING hands back the row that was just created, including the id the database picked, and .get() is where the values for the three ? go. dueDate is optional so it defaults to null. We send back 201 Created with the new campaign.

The format: 'date' makes dueDate a calendar date, year-month-day like 2026-11-27. Send something else and you get:

Expected string to match 'date' format

createdAt is a full timestamp on purpose. It's logging when the row was made and we never touch it, while the due date is something the user sets.

Delete Endpoint

DELETE uses the same path as the details endpoint, and the same params schema:

TypeScript
.delete(
    '/campaigns/:id',
    ({ params: { id }, status }) => {
        const { changes } = db.run(
            'DELETE FROM campaigns WHERE campaign_id = ?',
            [id],
        );
        if (changes === 0) return status(404);
        return status(204);
    },
    { params: t.Object({ id: t.Number() }) },
)

db.run() gives back an object with changes, how many rows were affected. Zero means there was nothing to delete, so 404. Otherwise 204 No Content.

Delete id 2 from the docs page and you get a 204, run it again and it's a 404, and it's gone from the list.

💡 Tip[!tip]
In the video I first wrote return 204 instead of return status(204). That sends back a 200 with the literal number 204 as the body. Returning a number is just data, status() is what actually sets the code.

Update with PUT

Give this one a try yourself before looking. It's the same patterns as above, update the row and return the updated campaign:

TypeScript
.put(
    '/campaigns/:id',
    ({ params: { id }, body, status }) => {
        const campaign = db
            .query(
                `UPDATE campaigns SET name = ?, due_date = ? WHERE campaign_id = ?
         RETURNING campaign_id AS campaignId, name, due_date AS dueDate, created_at AS createdAt`,
            )
            .get(body.name, body.dueDate ?? null, id);

        if (!campaign) return status(404);
        return { data: campaign };
    },
    {
        params: t.Object({ id: t.Number() }),
        body: t.Object({
            name: t.String(),
            dueDate: t.Optional(t.String({ format: 'date' })),
        }),
    },
)

Grab an id from the list, PUT a new name (I renamed mine to "Black Friday Cyber Monday"), and the updated row comes back with the same id. The attributes get replaced but the id stays, so it's logically still the same campaign. created_at isn't in the SET, so that stays too.

Bash
git add .
git commit -m "fix delete status code and add put"

Errors

There's default error handling already. Send a POST with "name": 5 and you get a big verbose validation error. If you're the one building the frontend that's probably fine, it gives you all the details you need. For a user facing API you'll probably want to control it, and that's .onError(), chained right after .use(openapi()):

TypeScript
.onError(({ code, error, status }) => {
    if (code === 'NOT_FOUND') return status(404, { error: 'Not Found' });
    if (code === 'VALIDATION')
        return status(422, { error: 'Bad request data' });
    console.error(error);
    return status(500, { error: 'Something went wrong, try again later' });
})

422 Unprocessable Content is the common code for invalid data. I wrap each message in an object with an error key rather than sending back a plain string, same reason as the data wrapper.

The last line is the catch-all. Anything that isn't a 404 or a validation problem gets logged on the server with console.error and the client gets a generic 500, so you're not leaking internals. To see it, put a throw new Error() in the PUT handler and send a valid request (an invalid one fails validation before your handler ever runs).

If you're happy with the default messages, you don't need any of this.

Object Relational Mappers (ORM)

An ORM (object relational mapper) maps the objects in your code to rows in the database, so you work with the database through code instead of writing the SQL yourself. There are pros and cons you can go research, but I'd generally recommend one unless you specifically need to hand write your SQL.

Prisma is a great option. We're going with Drizzle, which is probably the recommended option for Elysia (it has its own integration page in the Elysia docs). The reason is drizzle-typebox. Elysia's t is a fork of TypeBox, and drizzle-typebox turns a Drizzle table in to TypeBox schemas, so you get request validation straight from your table definition without describing the same shape twice.

It's hard to see why that matters until you do it, so let's install it:

Bash
bun add drizzle-orm drizzle-typebox
bun add -D drizzle-kit

drizzle-kit is the CLI that creates the tables, so it's a dev dependency.

We're moving to Postgres at the same time, so the file structure changes a bit:

omnicopy/
  .env                 DATABASE_URL, gitignored
  drizzle.config.ts    settings for the drizzle-kit CLI
  src/
    schema.ts          the table definitions
    index.ts           the server

drizzle.config.ts goes at the top level next to package.json. It tells the CLI where the schema is, turns on automatic snake_case conversion, and reads the connection string from .env:

TypeScript
// needed for drizzle kit (CLI)

import { defineConfig } from 'drizzle-kit';

export default defineConfig({
    schema: './src/schema.ts',
    dialect: 'postgresql',
    casing: 'snake_case',
    dbCredentials: {
        url: process.env.DATABASE_URL!,
    },
});

Bun loads .env automatically, so there's no dotenv package. For now just put a placeholder in .env, we'll fill in the real value once Postgres is running:

DATABASE_URL=

src/schema.ts is where the CREATE TABLE moves to, written as TypeScript:

TypeScript
import { pgTable, serial, text, date, timestamp } from 'drizzle-orm/pg-core';

export const campaigns = pgTable('campaigns', {
    campaignId: serial().primaryKey(),
    name: text().notNull(),
    dueDate: date(),
    createdAt: timestamp().notNull().defaultNow(),
});

The properties are camelCase now. With casing: 'snake_case' Drizzle converts campaignId to campaign_id going in to the database and back again coming out, so all those AS aliases go away.

dueDate is a date() with no notNull(), so it's optional. createdAt is a timestamp(), a point in time the database manages for you, and defaultNow() means Postgres fills it in when the row gets created so we stop passing new Date().toISOString() around.

Now in src/index.ts, swap the SQLite setup for a Drizzle connection. The new Database(...), the CREATE TABLE, and the seed block all get deleted, along with the bun:sqlite import:

TypeScript
import { drizzle } from 'drizzle-orm/bun-sql';
import * as schema from './schema';
import { campaigns } from './schema';

const db = drizzle(process.env.DATABASE_URL!, { schema, casing: 'snake_case' });

drizzle-orm/bun-sql uses Bun's built in Postgres client. The casing gets repeated here because the config file is only for the CLI, and your queries need to know about the conversion too. The ! tells TypeScript to assume the env variable has a value, otherwise it complains that it's string | undefined.

Then update the first route. Queries go over the network now, so the handler is async and the query gets an await:

TypeScript
.get('/campaigns', async () => {
    return {
        data: await db.select().from(campaigns),
    };
})

Send a request and you get Something went wrong, try again later from our onError, because there's no Postgres database yet.

Postgres and Docker installation

You can install Postgres directly, but I like running it in a Docker container. It's by far the easiest way to set it up, and it's easy to have a separate one per project. You'll need Docker installed.

drizzle-kit also needs a Postgres driver of its own (your app uses Bun's client, the CLI doesn't), so add pg as a dev dependency:

Bash
bun add -D pg

Then start Postgres:

Bash
docker run --name campaigns-db -e POSTGRES_PASSWORD=devpassword -e POSTGRES_USER=omnicopy -e POSTGRES_DB=campaigns -p 5432:5432 -d postgres:18
  • --name campaigns-db so you can refer to the container by name
  • the -e variables create the user, password, and database the first time the container starts
  • -p 5432:5432 maps a port on your machine to Postgres inside the container
  • -d runs it detached so you get your terminal back

If you already have something on 5432 (I usually do), change only the FIRST number, like -p 5435:5432. The second one is the port inside the container and stays the same.

Check it's running:

Bash
docker ps

psql

You don't need Postgres installed on your machine to run SQL against it, there's psql inside the container:

Bash
docker exec -it campaigns-db psql -U omnicopy -d campaigns
SQL
SELECT 5 + 5;

That gives back 10. A couple of psql commands worth knowing: \dt lists the tables (there aren't any yet), and \q quits. You'll probably want to write these commands down somewhere.

Now fill in .env. Every piece of the connection string comes from the docker run flags, user, password, host, port, and database:

DATABASE_URL=postgresql://omnicopy:devpassword@localhost:5432/campaigns

Use whatever port you mapped (mine was 5435). Restart the server and try the request again. Now the error is:

relation "campaigns" does not exist

So it's working, it's just broken, if that makes sense. We connected, the database just doesn't have the table yet. I split the terminal so the server keeps running on one side, and on the other:

Bash
bunx drizzle-kit push
Reading config file '.../drizzle.config.ts'
Using 'pg' driver for database querying
[✓] Pulling schema from database...
[✓] Changes applied

Send the request again and you get {"data":[]}. The container is running, the app is connected through Drizzle, and the table exists.

push makes the database match your schema directly. That's fine while it's one table on your own machine. Once you have a real database that other people depend on you'd switch to migrations (drizzle-kit generate and drizzle-kit migrate), which write versioned SQL files you commit. There's more on that in the Drizzle docs.

Get Data with Drizzle

The details endpoint, async and await again, with eq for the WHERE:

TypeScript
import { eq } from 'drizzle-orm';

.get(
    '/campaigns/:id',
    async ({ params: { id }, status }) => {
        const [campaign] = await db
            .select()
            .from(campaigns)
            .where(eq(campaigns.campaignId, id));
        if (!campaign) {
            return status(404);
        }
        return { data: campaign };
    },
    {
        params: t.Object({ id: t.Number() }),
    },
)

db.select() always gives back an array, even when you're looking up one id. That's what the [campaign] is for, more on it in a second.

Insert with Drizzle

TypeScript
.post(
    '/campaigns',
    async ({ body, status }) => {
        const [campaign] = await db
            .insert(campaigns)
            .values(body)
            .returning();
        return status(201, { data: campaign });
    },
    {
        body: t.Object({
            name: t.String(),
            dueDate: t.Optional(t.String({ format: 'date' })),
        }),
    },
)

.returning() does the same job as RETURNING in the raw SQL, hands you back the new row. Create a few from the docs page (I clicked send a few times, which is fine, now there's stuff to delete).

An empty string for dueDate gets rejected with Bad request data. Leave dueDate off completely and it works, since it's nullable, but "" isn't null and it isn't a valid date.

That error message isn't very helpful though. Our onError hides the details and doesn't log validation errors, which makes building harder. While you're developing you can just comment it out and get Elysia's full validation errors back:

TypeScript
// .onError(({ code, error, status }) => {
//     if (code === 'NOT_FOUND') return status(404, { error: 'Not Found' });
//     if (code === 'VALIDATION')
//         return status(422, { error: 'Bad request data' });
//     console.error(error);
//     return status(500, { error: 'Something went wrong, try again later' });
// })

You could also switch it on only outside of development with an environment variable, but I don't want to get off topic.

Deleting with Drizzle

TypeScript
.delete(
    '/campaigns/:id',
    async ({ params: { id }, status }) => {
        const deleted = await db
            .delete(campaigns)
            .where(eq(campaigns.campaignId, id))
            .returning();
        if (deleted.length === 0) return status(404);
        return status(204);
    },
    { params: t.Object({ id: t.Number() }) },
)

There's no changes count anymore. With .returning() you get an array of the deleted rows, so an empty array means nothing matched and it's a 404. Delete id 2, you get a 204, do it again and it's a 404.

Updating with Drizzle

TypeScript
.put(
    '/campaigns/:id',
    async ({ params: { id }, body, status }) => {
        const [campaign] = await db
            .update(campaigns)
            .set(body)
            .where(eq(campaigns.campaignId, id))
            .returning();

        if (!campaign) return status(404);
        return { data: campaign };
    },
    {
        params: t.Object({ id: t.Number() }),
        body: t.Object({
            name: t.String(),
            dueDate: t.Optional(t.String({ format: 'date' })),
        }),
    },
)

Update campaign 1 with a new name and it works. Notice the due date stayed though, even though we didn't send one. That's more like a PATCH, which we fix below.

Type Validation

Those t.Object bodies on POST and PUT describe the exact same shape as the table. createInsertSchema from drizzle-typebox builds that validation schema from the table instead:

TypeScript
import { createInsertSchema } from 'drizzle-typebox';

const _insertCampaign = createInsertSchema(campaigns, {
    dueDate: (schema) => t.String({ ...schema, format: 'date' }),
});
const campaignBody = t.Omit(_insertCampaign, ['campaignId', 'createdAt']);

_insertCampaign is an in-between step (hence the underscore). It's everything you could insert in to the table, including campaignId and createdAt. The database owns those two, so t.Omit takes them out. Hover over campaignBody and it's name, required, and dueDate, optional.

💡 Tip[!tip]
The video uses createInsertSchema(campaigns) with no second argument. That quietly drops the format: 'date' check we had on the hand written body, because a Postgres date() column just becomes a plain string schema. With the original version, "dueDate": "next tuesday" passes validation, hits Postgres, and the client gets a 500 (with onError commented out, the raw Failed query: insert into "campaigns" ... SQL comes back in the response). The second argument puts the date format back, and the callback form keeps dueDate optional and nullable. Without the callback, passing t.String(...) directly makes dueDate required.

💡 Tip[!tip]
Assign the schema to a variable like _insertCampaign rather than calling createInsertSchema inline in a route. The Elysia Drizzle docs call out that inlining it can send TypeScript in to an infinite type instantiation loop.

Now both routes use it:

TypeScript
{
    body: campaignBody,
}

PUT keeps its params as well. Change a column in schema.ts and the validation, the types, and the docs page all follow.

Array Destructuring

Quick one I missed at first. Every select, insert, and update in Drizzle gives back an array, so a single row needs pulled out with array destructuring, const [campaign] = await ..., which grabs the first element. Without the brackets, campaign is an array. An empty array is still truthy, so the 404 check never fires and you'd send back { data: [] }.

Put vs Patch

A PUT is supposed to replace the whole resource, and a PATCH changes only the fields you send. .set(body) behaves like a PATCH: Drizzle skips keys that are undefined, so leaving dueDate out of the request left the old due date in place.

To make it a real PUT, set each column explicitly and default the due date to null:

TypeScript
.set({
    name: body.name,
    dueDate: body.dueDate ?? null,
})

Now a PUT without dueDate clears it, and a PUT with one sets it.

Here's the full src/index.ts at the end:

TypeScript
import openapi from '@elysia/openapi';
import { Elysia, t } from 'elysia';
import { drizzle } from 'drizzle-orm/bun-sql';
import { eq } from 'drizzle-orm';
import { campaigns } from './schema';
import * as schema from './schema';
import { createInsertSchema } from 'drizzle-typebox';

// needed for drizzle orm
const db = drizzle(process.env.DATABASE_URL!, { schema, casing: 'snake_case' });

const _insertCampaign = createInsertSchema(campaigns, {
    dueDate: (schema) => t.String({ ...schema, format: 'date' }),
});
const campaignBody = t.Omit(_insertCampaign, ['campaignId', 'createdAt']);

const app = new Elysia({ prefix: '/api/v1' })
    .use(openapi())
    // .onError(({ code, error, status }) => {
    //     if (code === 'NOT_FOUND') return status(404, { error: 'Not Found' });
    //     if (code === 'VALIDATION')
    //         return status(422, { error: 'Bad request data' });
    //     console.error(error);
    //     return status(500, { error: 'Something went wrong, try again later' });
    // })
    .get('/campaigns', async () => {
        return {
            data: await db.select().from(campaigns),
        };
    })
    .get(
        '/campaigns/:id',
        async ({ params: { id }, status }) => {
            const [campaign] = await db
                .select()
                .from(campaigns)
                .where(eq(campaigns.campaignId, id));
            if (!campaign) {
                return status(404);
            }
            return { data: campaign };
        },
        {
            params: t.Object({ id: t.Number() }),
        },
    )
    .post(
        '/campaigns',
        async ({ body, status }) => {
            const [campaign] = await db
                .insert(campaigns)
                .values(body)
                .returning();
            return status(201, { data: campaign });
        },
        {
            body: campaignBody,
        },
    )
    .delete(
        '/campaigns/:id',
        async ({ params: { id }, status }) => {
            const deleted = await db
                .delete(campaigns)
                .where(eq(campaigns.campaignId, id))
                .returning();
            if (deleted.length === 0) return status(404);
            return status(204);
        },
        { params: t.Object({ id: t.Number() }) },
    )
    .put(
        '/campaigns/:id',
        async ({ params: { id }, body, status }) => {
            const [campaign] = await db
                .update(campaigns)
                .set({
                    name: body.name,
                    dueDate: body.dueDate ?? null,
                })
                .where(eq(campaigns.campaignId, id))
                .returning();

            if (!campaign) return status(404);
            return { data: campaign };
        },
        {
            params: t.Object({ id: t.Number() }),
            body: campaignBody,
        },
    )
    .get('/', () => 'Hello Caleb!')
    .listen(3000);

console.log(
    `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
);

💡 Tip[!tip]
This matches the repo with two changes: the dueDate date format fix from the Type Validation section, and the leftover import { Database } from 'bun:sqlite' removed, since nothing uses SQLite anymore.

What's next?

From here the next steps are database relationships (the content pieces that hang off a campaign), using this API from a frontend, and the approach Elysia recommends for that. Leave a comment on the video if you want that one. The Elysia docs are good and pretty short if you want to keep going on your own.