FEWO running in a browser on a laptop: search bar with the filter panel open, over the listing carousel
A holiday-rental portal built on a raw Airbnb export: 1,476 listings, 504,795 calendar nights and 103,295 reviews, behind a search, a booking flow and a live availability feed. Express and SQLite on the server, hand-written JS on the client — no front-end framework, no build step.
1476listings
504kcalendar nights
23API endpoints
0front-end frameworks
What this buys a client: search, filters, a booking that cannot double-book, and live updates — built without a framework, so nothing needs a rebuild in two years to keep working.
Highlights
The brief
Turn a CSV dump into something bookable. Everything above the data — schema, API, UI, booking rules — is written from scratch.
Search
Every filter is a SQL clause: place, guests, date, price, rating, rooms, type. 100 rows per request; filters, page and scroll position survive a reload.
Booking
Creating a booking and blocking the nights is one transaction. Cancelling or re-dating gives the nights back.
Live
A Server-Sent-Events stream pushes bookings and 2-minute viewing reservations to every open tab. No WebSocket dependency.
01 — Finding something
Search first, form second
The dataset is 1,476 listings in one city, which is small enough to browse and large enough that browsing is useless. So the landing page is a search bar and a carousel, and everything that narrows the set is one popover away — visible when you want it, gone when you do not.
Landing
Place, guests, date in one row. The rest of the filters live behind More Filter, so the first thing on the page is a question, not a form.
Continent chips filter the rail without a round trip — the set is already loaded.
A card carries only what decides a booking: rating, review count, price per night, next free date. No calendar rows for a listing and it says Preis auf Anfrage / Zurzeit keine Verfügbarkeit instead of inventing a price.
A rail, not a grid, on the landing page. The grid is one click away and holds all 1,476.
Filters
Price is a two-handle range, capped at €1600+ because the tail above it is four listings and stretching the scale for them makes the useful 90% unusable.
Rating, rooms and type are selects rather than more sliders — a discrete value should not be dragged to.
The apply button carries the live result count. The count query runs while you are still deciding, so you never apply a filter into an empty page.
All listings
The total comes back with the page, not from counting what arrived — the server answers limit/offset with a total, so pagination knows how far it goes.
Sorting is ORDER BY in SQL, not a client-side sort of the current page. “Near me” is the one exception: geolocation, so it has to happen in the browser.
Filters, current page and scroll position go to localStorage. Open a listing, come back, and the grid is where you left it.
02 — Booking it
The calendar is the product
Half a million calendar rows exist so that one question can be answered honestly: can I have these nights, and what do they cost. Every rule below is checked in the browser so the answer is instant, and again on the server so the answer is true.
Availability
Click twice: arrival and departure. No date pickers, no dropdown of months — the availability is the interface.
Struck-out days are taken. They come from the calendar table, one row per listing per night, which is where 504,795 of the rows live.
The total is the sum of the actual per-night prices in that range, not nights × the headline price — the dataset prices weekends differently and the summary should not lie about it.
Minimum-nights is caught here and again in POST /api/bookings. The client check is for speed; the server check is the one that counts.
Booking form
The recap repeats the dates and the total before anything is asked of you. Nothing on this screen can change them.
Name and address are prefilled from the profile and required by the API — a booking with nobody attached to it is not a booking.
Guest count is capped at the listing’s maximum, read from the listing rather than hardcoded.
Submit is one POST. Server-side it is a single transaction: insert the booking, flip those calendar nights to unavailable. Either both happen or neither does.
Confirmation
A confirmation screen rather than a bounce back to the listing. The booking id comes back in the response and everything here is rendered from it.
The dates and the total are the server’s, not the client’s arithmetic. If the two ever disagree, this screen shows the one that was actually saved.
The booking now sits in the history on the profile, where it can be re-dated or cancelled. Either releases the calendar nights and broadcasts on the SSE stream — a second tab watching that listing updates without a refresh.
03 — Under it
What the server actually does
Ten dependencies, eight routers, one SQLite file. The interesting part was not any single endpoint but deciding which problems deserved a library and which deserved twenty lines.
Data
A CSV dump, normalised once
The source is three exports totalling ~10MB, imported through a streaming parser into prepared statements. Listings, calendar and reviews become three tables with real foreign keys, so “is this night free” is an index lookup instead of a scan.
Auth, admin, listings, calendar, reviews, bookings, events, reservations — 23 endpoints, none of them longer than the thing they do. Filtering, sorting and pagination all happen in one parameterised query rather than in three passes over the result.
routers: 8
endpoints: 23
dependencies: 10
Live
SSE, because it is already in the browser
Opening a listing reserves it for two minutes; other tabs see that live. EventSource reconnects on its own and every client→server action already goes through REST, so a WebSocket layer would have bought a dependency and a reconnect loop to write.
stream: 1
reservation TTL: 2 min
extra dependencies: 0
Security
Assume the client is hostile
bcrypt at 12 rounds, JWT with an expiry, helmet with a CSP, rate limiting on auth, prepared statements everywhere, and ownership checks so a booking id in a URL is not an edit permission. Login failures are deliberately generic — no user enumeration.
bcrypt rounds: 12
auth rate limit: 10 / 15 min
raw SQL strings: 0
Front-end
No framework, on purpose
~4,200 lines of plain JS and HTML served straight out of static/. State lives in localStorage and the URL; rendering is template strings with escaped output. Nothing to build, so nothing to get out of date.
The admin side is deliberately plain: an import form, a dashboard, a dark-mode switch. Still open, left visible rather than cropped out of a screenshot: the JWT lives in localStorage, mitigated by CSP and escaping but not solved. bookings.json is rewritten after every change and never read back, so it is a mirror pretending to be a store. The 2-minute reservations are in memory and vanish on restart. And the pay button charges nobody.
Profile — admin section
Dark mode is a token flip, stored per user, defaulting to the system setting when nobody has chosen.
The whole admin block is gated server-side, not just hidden — the dashboard route checks the role, the UI is only a convenience.
Import takes the original .csv.gz. The warning about not opening it in Excel first is there because Excel rewrites the listing ids into scientific notation and silently breaks every foreign key.