- Define the Meal once as a zod v4 schema and derive everything from it: request validation, response validation, types and the OpenAPI spec. - Full CRUD on /api/menu: list (filter/search/sort), get one, create, replace, update and delete, while keeping the original GET error messages intact. - Normalize Elysia's 422 validation failures into 400 with a per-field `fields` map the frontend renders under each input. - Add the @elysiajs/openapi plugin: Scalar UI at /openapi, spec at /openapi/json, with zod -> JSON Schema via mapJsonSchema. - Rebuild index.html on the it3206-minisys design language (dark, white/10 hairlines) with create/edit/delete, filters and a two-step delete confirm. - Read index.html per request so frontend edits need no restart.
Meal API
A small food menu API with a single-page frontend.
A Meal is one item on the menu. The collection lives at /api/menu, and every route is
validated with zod schemas that also generate the OpenAPI docs at /openapi.
Files
| File | Purpose |
|---|---|
index.html |
The frontend. Markup, styles and script are all inline. |
server.js |
The whole backend: zod schemas, the meals, the CRUD routes, the docs. |
package.json |
So that elysia, @elysiajs/node, zod and @elysiajs/openapi can be installed. |
There is no public/ folder and no separate menu.js, app.js or style.css — the app is
index.html + server.js.
Run it
npm install
npm start # node server.js
Then open http://localhost:3000. The docs are at http://localhost:3000/openapi.
Other ways to run the exact same file:
npm run dev # node --watch server.js (auto-restart on save)
npm run bun # bun server.js
server.js runs on both Node and Bun. Elysia serves through Bun.serve on Bun, and the
file loads the @elysiajs/node adapter automatically whenever Bun isn't present, so neither
runtime needs its own copy of the server.
index.html is read from disk per request, so editing it shows up on refresh without a restart.
Schema
server.js defines the meal once as a zod schema, and everything else follows from it: the
request validation, the TypeScript types, the response validation and the OpenAPI spec.
const MEAL_CATEGORIES = ['appetizer', 'main', 'dessert', 'drink']
const mealDraftSchema = z.object({
name: z.string().trim().min(1, 'Name is required.').max(80, 'Keep the name under 80 characters.'),
category: z.enum(MEAL_CATEGORIES, { error: 'Pick a category: …' }),
price: z.number({ error: 'Price is required.' })
.nonnegative('Price cannot be negative.')
.max(100000, 'That price looks too high.')
.refine(hasTwoDecimals, 'Price can have at most 2 decimal places.'),
description: z.string().trim().max(240, '…').optional(),
available: z.boolean({ error: 'Available must be true or false.' }).optional(),
})
A stored meal is that draft plus the server-managed fields:
| Field | Type | Notes |
|---|---|---|
id |
integer ≥ 1 | assigned by the server |
name |
string, 1–80 chars | trimmed |
category |
enum | appetizer · main · dessert · drink |
price |
number ≥ 0 | max 2 decimal places |
description |
string, ≤ 240 chars | defaults to "" |
available |
boolean | defaults to true |
createdAt |
ISO datetime | set on create |
updatedAt |
ISO datetime | bumped on every update |
price is a real JSON number on purpose. An empty form field arrives as "" and is rejected
with "Price is required." rather than being silently coerced into a ₱0 meal.
Endpoints
| Method | Path | Result |
|---|---|---|
| GET | / or /index.html |
the frontend |
| GET | /openapi |
interactive API docs |
| GET | /openapi/json |
the OpenAPI 3.1 spec |
| GET | /api/menu |
list meals (filter + sort) |
| GET | /api/menu/:id |
one meal |
| POST | /api/menu |
create a meal → 201 + Location |
| PUT | /api/menu/:id |
replace a meal |
| PATCH | /api/menu/:id |
update only the fields you send |
| DELETE | /api/menu/:id |
remove a meal → 204 |
Filtering and sorting
curl "http://localhost:3000/api/menu?category=main&available=true&sort=price&order=desc"
curl "http://localhost:3000/api/menu?search=adobo"
category · available · search (matches name and description) · sort (name, price,
createdAt) · order (asc, desc). Plain /api/menu keeps insertion order.
Examples
curl http://localhost:3000/api/menu
curl http://localhost:3000/api/menu/2
curl -X POST http://localhost:3000/api/menu \
-H 'content-type: application/json' \
-d '{"name":"Halo-Halo","category":"dessert","price":65.5}'
curl -X PATCH http://localhost:3000/api/menu/2 \
-H 'content-type: application/json' \
-d '{"price":55}'
curl -X DELETE http://localhost:3000/api/menu/2
GET /api/menu → 200
[
{
"id": 1,
"name": "Adobo",
"category": "main",
"price": 75,
"description": "Pork belly simmered in soy sauce, vinegar and bay leaves.",
"available": true,
"createdAt": "2026-09-16T12:00:00.000Z",
"updatedAt": "2026-09-16T12:00:00.000Z"
}
]
Errors
Every failure returns the same shape — error for the message, fields for the inputs the
frontend should highlight:
{
"error": "Name is required.",
"fields": {
"name": "Name is required.",
"category": "Pick a category: appetizer, main, dessert, drink.",
"price": "Price is required."
}
}
Elysia answers validation failures with 422 by default; onError normalizes them to 400.
| Case | Status | Body |
|---|---|---|
| invalid body/query/param | 400 |
{ "error": "…", "fields": { … } } |
id isn't a whole number ≥ 1 (/api/menu/abc) |
400 |
{ "error": "ID must be a whole number, like 1." } |
id doesn't exist (/api/menu/99) |
404 |
{ "error": "No menu item with ID 99." } |
unknown path (/nope) |
404 |
Not found |
| unexpected server error | 500 |
{ "error": "Internal server error." } |
Frontend
index.html is one page that talks to the API with fetch():
- lists meals in a table, with the description under each name
- searches and filters by category and availability (the search box is debounced)
- creates meals with
POST, edits them withPATCH(sending only the changed fields) and deletes them behind a two-step confirm - renders the API's
fieldsmessages under the matching inputs, so validation lives on the server and is displayed, not duplicated
Rows are built with createElement/textContent rather than innerHTML, so a meal name can
never inject markup into the page.
The styling follows the dark design language used in the it3206-minisys
project — neutral-900 background, white/10 hairlines, white/5 fills, rounded panels and a
blurred navbar. It is hand-written CSS in the <style> tag, so the page needs no build step, no
Tailwind and no CDN.
Notes
- Meals live in memory, so restarting the server restores the original three. Swap the
menuarray for a database when the data needs to survive a restart. - The meal's name field is
name. If something still expects the olderfoodkey, addfood: z.string().optional()to the schema and map it in the handlers. - The
/openapipage is the Scalar UI, which loads its assets from a CDN, so it needs internet access to render./openapi/jsonis served entirely locally.