Persekutuan Remaja
A website for running the youth fellowship at my church: the week's theme and roster, a song presenter whose lyrics follow the recording, and a game engine with a scoreboard. It runs on one laptop plugged into a projector, with no cloud behind it.
- Next.js
- React 19
- TypeScript
- SQLite
- better-sqlite3
One Laptop, One Projector
Every Sunday the youth fellowship needs the same handful of things: who is serving this week, what the theme is, the songs, a game, and a list of who turned up.
For a long time that lived across a WhatsApp group, someone's Google Sheet, and a folder of PowerPoint files that had to be rebuilt every week. The failure mode was always the same. The laptop at the front is the only one that matters, the wifi in that room is not reliable, and there is exactly one person operating it while a room full of teenagers waits.
So I built for that room specifically. No cloud, no accounts, no deployment. It is a Next.js app reading a SQLite file on the same machine through better-sqlite3, which is synchronous, so server components just read the database and render. There is no data-fetching layer, no API routes, no loading states, and nothing that can spin because a network call is hanging.
The Next.js dev indicator is switched off in the config for the same reason. That screen is projected in front of the congregation, and a framework logo has no business being on it.
The Screen the Room Sees
The home page has no controls on it. Not fewer controls, none.
It shows the date, the week's theme with one word pulled out in an accent, the Bible reading, who is serving, and the songs and games chosen for that Sunday. Anything you can toggle, edit or save lives on a separate operator page. The rule is that nothing on the projected screen should be clickable by accident.
The week rolls forward on Monday, not on Sunday night. upcomingSunday() returns today if today is Sunday and otherwise the next one, so the app is always showing the service being prepared rather than the one that just finished.
There is a birthday block, and it is my favourite small detail. Birthdays in the seven days before the Sunday count, because Sunday is the first time the group is together after a midweek birthday. The block sits behind a teaser that just says "Apa Hayoo..?" and it renders even when nobody has a birthday, because a block that only appears when there is something to celebrate would give the surprise away by existing.
Lyrics That Follow the Music
Songs are written as plain text where one blank line starts a new slide, with a live preview beside the editor. That is the whole authoring model and it has never needed to be more.
A song can also carry a recording and a start time for every line of lyrics. When it plays, the slides follow the audio on their own.
Getting that to feel right on a projector took two decisions. The presenter reads audio.currentTime inside a requestAnimationFrame loop rather than listening for timeupdate, because timeupdate fires about four times a second and at that rate the slide visibly lands after the line has already been sung. And the moment anyone touches an arrow key, following switches off. A live band drifts from the recording, and slides snapping backwards mid-song is far worse than slides sitting still while a human takes over.
Where there is a long gap between lines, the deck derives a break slide that shows the first line of what is coming next, at half size and in a muted colour. The room can read ahead without starting to sing early. It is never stored, just recomputed from the timings.
Teaching the App to Listen
Marking the start of every line by hand is tedious, so the app can do it by listening to the recording.
Whisper and ffmpeg run locally on the same laptop to transcribe the audio into timestamped words. Then the interesting part: the transcript is not used as lyrics. Whisper mishears worship lyrics constantly, and the typed lyrics are the ones the room is going to read.
Instead I run Needleman-Wunsch sequence alignment over fuzzy word similarity to map the typed lyrics onto Whisper's timestamps. It is the same dynamic-programming alignment used for comparing gene sequences, and the problem has the same shape: two sequences that mostly correspond, with insertions, deletions and substitutions in between. A misheard word is skipped without shifting everything after it out of position, and any line that finds no match at all is interpolated between its timed neighbours. The normaliser collapses repeated vowels, because worship lyrics are full of contracted spellings that Whisper writes out in full.
On the first real song it matched all 32 lines.
The whole thing degrades gracefully. If ffmpeg, the Whisper binary or the model file is missing, the feature stops and says which one is absent, and every other part of the app carries on working.
Type That Always Fits
Lyrics vary wildly in length, and text spilling off the bottom of a projector is the one failure the room notices immediately.
The presenter runs a scale loop that shrinks the type until the lyric area stops overflowing. The subtle part is that the stage is height: 100dvh rather than min-height, and it has to be.
With min-height, the stage simply grows to fit its content instead of overflowing. scrollHeight then never exceeds clientHeight, the loop concludes everything fits, and it never fires. Meanwhile the page has overflow: hidden while presenting, so the lines that ran off the bottom cannot even be scrolled to. One wrong CSS property and the text is silently gone.
Games
Games are slide decks with a scoreboard attached. A slide marked as a round turns the group cards live, and from then on the number keys 1 to 9 add a point to that group, with shift to take one away.
The presenter asks how many groups there are before it starts, pickable with the mouse or by pressing the digit. Scores live in memory and reset on refresh, deliberately: a score that survives a reload is a score that has to be cleaned up before the next week.
For games that go round the room one group at a time, the round menu labels each entry with whose turn it is, "Ronde 3 - Kelompok 2", computed from a rotation that skips tiebreak rounds so they do not throw the order off. There is a hidden tiebreak slide that is only reachable if the final standings come out level.
Songs and games behave differently on purpose. At the end of a song, "next" returns to that song's first slide, because a song is nearly always repeated and moving to a different song should be a deliberate act. Games do not wrap, because rounds are sequential and the deck ends on the standings.
Jadwal Pelayanan
The roster is a grid of Sundays down the side and five service roles across the top, filled in with a searchable picker in every cell. It autosaves. There is no Simpan button anywhere on that table, because planning a roster is dozens of small changes and a save button just becomes a thing to forget.
The picker is a custom component rather than a native select, for one reason: typing three characters of a name always beats scrolling a list of teenagers while the service is about to start.
The rule that shaped this part is that members are deactivated, never deleted. Someone who leaves the youth group drops out of the pickers but stays in every past roster and attendance record they were part of. And if an inactive member is already assigned to a slot, the picker adds them back to its own options, so a filled slot never renders as empty just because the person is no longer active. A record somebody made should not silently disappear.
Attendance is one tap per name, with the row existing in the database being the entire representation of "present". No status column, nothing to keep in sync.
Screenshots
Every member name, phone number and birthday in these screenshots is blurred, and the data behind the blur is invented. These were captured against a fresh database seeded only with songs and games, with fictional members entered by hand. No real member information was ever on screen.
The projected home screen, the roster grid, the attendance board, the member list, the song library, the song editor with its live slide preview, a lyric slide as the room sees it, the group-count setup, and a scored round with the scoreboard.
Tech Stack
- Next.js 15 App Router - server components reading SQLite directly, with server actions as the only way anything is written
- better-sqlite3 - synchronous, so a page render is just a function call, and one file is the entire backup
- One hand-written stylesheet - no Tailwind, no component library, no icon package; the icons are inline SVG
- URL as state - search, filters and which week you are looking at all live in the query string, so the back button works and a link is shareable
Worth being straight about: there is no test framework and no CI in this project. What it has instead is a long architecture-decision document that records why each of these choices was made, which is what made writing this case study possible a month later.
Built in Production, One Sunday at a Time
The commit history is a list of Sundays. Several messages are literally named for the service they were preparing.
That is the part I would repeat. Nothing in this app was designed in the abstract and then tried out. Every feature exists because something went wrong in a room the week before, and the fix had to work by the following Sunday, in front of an audience, on a laptop with no internet.
Want to talk about this work? Email me.