VERSESEE

How it's built

VerseSee is a scripture-first exhibit app, not a CMS with a Bible plugin. I built it to show what happens when you treat selected passages as interactive experiences while keeping the biblical text at the center. If you are evaluating my work as a developer, this page is the behind-the-scenes tour.

On this page

Philosophy

  • Solve the real problem. I shipped four polished vertical slices (Genesis 1, Exodus 14, Mark 4, John 1) instead of scaffolding a generic Bible platform nobody asked for.
  • Complexity is a cost. No ORM, no MDX pipeline, no vector search layer yet. Each dependency has to earn its place.
  • Pages render; services decide. App routes call services, services call repositories. Business logic stays on the server.
  • Server-first. Most pages are Server Components. Client JavaScript is reserved for real interactivity: storm effects, Greek word study overlays, Leaflet maps, and search typeahead.

Did I use "AI"?

Overview

Yes. It is 2026. I used large language models on this project, and I would not pretend otherwise.

What I did not do is blindly type things into a chat box and burn tokens until things started to "sorta" work. That approach produces code that looks finished and falls apart the first time you read it.

Instead, I started with detailed plans: architecture, data models, and scoped vertical slices before writing much code. From there I mixed hand coding and agentic coding, with thorough reviews at each step. I kept standards in place (docs/engineering-principles.md, Zod at boundaries, services over repositories in routes) and kept iterating until the result matched what I intended, not just what the model guessed.

The LLM is a tool in the workflow, not the workflow itself. I remain responsible for every architectural choice on this page.

Guardrails

How I kept the model on a leash (docs, rules, and review habits)

The docs folder is not README fluff. I wrote it so both I and the model had a shared source of truth before any file changed.

Documentation files

docs/product-brief.md
Product vision and scope. Keeps the model from turning VerseSee into a generic Bible app or feature checklist.
docs/engineering-principles.md
How to think about tradeoffs: solve the real problem, complexity is a cost, optimize for the next developer.
docs/architecture.md
Layer boundaries and server-first rules. Pages render, services decide, repositories own MongoDB only.
docs/coding-standards.md
Short, enforceable conventions: Zod at boundaries, ObjectId serialization, no drive-by refactors.
docs/project-structure.md
Where files belong: layout vs exhibit vs exhibits/<slug> vs features/<slug>. Stops folder sprawl.
docs/database.md
Collection shapes, exhibit documents, triggerVerse, ObjectId boundary, and cross-exhibit links.
docs/tech-stack.md
Approved stack only. No surprise ORMs, CMS layers, or speculative vector search.
docs/deployment.md
Boring production path: Cloudflare, Apache, systemd. No Kubernetes cosplay.
docs/github-actions.md
CI expectations on pull requests: typecheck, lint, build before merge.
docs/roadmap.md
Phased scope. A reminder to finish vertical slices before opening new fronts.

Agent rules (Cursor)

I used Cursor on this project, so these live in .cursor/rules/ and load automatically on every relevant turn. The same idea works elsewhere: Claude Code with a CLAUDE.md, Copilot instructions, or any setup where persistent project context rides along instead of getting re-pasted each session.

.cursor/rules/versesee-core.mdc
Always-on guardrails: read the docs first, services over repositories, validate at boundaries.
.cursor/rules/versesee-react.mdc
React and Next.js patterns for src/: client components only where needed, storm/word-study splits.
.cursor/rules/no-em-dash.mdc
Copy consistency across UI, docs, and seed data. Small rule, but it catches sloppy generated text.

Review habits

  • Wrote a plan before asking the agent to touch code, then worked in scoped chunks instead of one giant prompt.
  • Pointed the agent at the docs and project rules so generated code had to fit existing patterns.
  • Reviewed every diff: deleted unused exports, rejected placeholder buttons, and pushed back on speculative abstractions.
  • Validated at boundaries with Zod so bad model output failed at parse time, not in the UI.
  • Mixed hand coding and agentic coding. I kept the parts that need taste and judgment.
  • Re-ran lint and build after non-trivial changes. Green locally before moving on.

Stack

Next.js 16 + React 19
App Router with Server Components for data-heavy pages and file-based routing I can reason about.
TypeScript + Zod 4
Types at compile time, Zod at every boundary: env vars, API query params, and MongoDB documents.
MongoDB Atlas (native driver)
Two distinct document shapes (scripture chunks and curated exhibits) plus Atlas Search, without standing up a separate search service.
Tailwind CSS 4
Fast iteration and consistent styling across pages without a component library.
Leaflet
Lightweight maps for the Exodus 14 route comparison exhibit.
Deployment
GitHub Actions, SSH to EC2, PM2. Cloudflare and Apache in front. Boring infrastructure on purpose.

Architecture

Browser

React client components only where needed

When you open an exhibit, the route asks a service for the page data. That service loads the exhibit document from MongoDB, pulls the matching scripture verses from the chunks collection, and returns a serialized payload the page can render.

Database

Why MongoDB

When you open a chapter on VerseSee, you are reading scripture, not updating rows in an orders table. The app loads whole passages and curated exhibit pages in a few predictable reads.

A relational database could absolutely run that. I picked MongoDB because the data I fetch already looks like documents, and I'm a MongoDB Champion who has shipped both models.

Co-location

Let's say you open Mark 4 in the reader. The app needs every verse in that section in one trip to the database, not rows pulled from separate tables and stitched together at read time. I try to store together what gets read together.

An exhibit works the same way. The storm narrative, cross-references, and map are one editorial package I author and ship together.

I still load scripture separately from exhibit metadata when their lifecycles differ. The Bible text changes slowly; exhibits evolve as I refine the experience.

Boundaries

Think about what actually belongs in MongoDB for this app:

  • The whole Bible does not go in one document.
  • Exhibits do not get merged into scripture chunks.

Book and chapter names live in static JSON because that metadata is stable and shared across the site. MongoDB holds two collections: bulk scripture text and handcrafted exhibits. Validation catches bad shapes at the boundary, and search runs against verse text inside each chunk.

Collections

MongoDB holds two collections. One stores scripture, one stores exhibits. They change at different speeds and look nothing alike on the inside.

The chunks collection is the full NET Bible, split into readable sections. A Genesis 1 chunk carries the verses for that passage in one document. Bulk data, rarely edited, built for search.

The exhibits collection holds pages like the Mark 4 storm: intro copy, section cards, cross-references, and links back to scripture by book and verse range. I refine these as the experience evolves. See Example documents below for what each shape looks like in JSON.

Example documents

Scripture chunk (Genesis 1:1–2 excerpt)
{
"book": "Genesis",
"title": "Creation of the Heavens and the Earth",
"reference": "1:1–2",
"start": { "chapter": 1, "verse": 1 },
"end": { "chapter": 1, "verse": 2 },
"fullText": "1. In the beginning God created...",
"verses": [
{
"book": "Genesis",
"chapter": 1,
"verse": 1,
"text": "In the beginning God created the heavens and the earth.",
"reference": "Genesis 1:1"
},
{
"book": "Genesis",
"chapter": 1,
"verse": 2,
"text": "Now the earth was without shape and empty...",
"reference": "Genesis 1:2"
}
],
"testament": "OT"
}
Exhibit document (trimmed Mark 4 storm)
{
"slug": "mark-4-storm",
"title": "Jesus Calms the Storm",
"reference": "Mark 4:35–41",
"experienceType": "cinematic",
"triggerVerse": {
"chunkId": "507f1f77bcf86cd799439011",
"verse": 39,
"phrase": "Be quiet! Calm down!"
},
"sections": [
{
"id": "genesis-echo",
"title": "Echoes of Genesis 1",
"kind": "cross-reference",
"content": {
"body": "Mark's storm scene echoes the opening of Scripture...",
"links": [
{
"reference": "Genesis 1:1–5",
"href": "/exhibit/genesis-1-beginning",
"note": "Darkness was over the surface of the deep waters..."
}
]
}
}
]
}
Atlas Search index (scripts/atlas-search/chunks-verse-text.index.json)
{
"mappings": {
"dynamic": false,
"fields": {
"verses": {
"type": "document",
"fields": {
"text": [
{ "type": "string" },
{
"type": "autocomplete",
"tokenization": "edgeGram",
"minGrams": 2,
"maxGrams": 20
}
],
"chapter": { "type": "number" },
"verse": { "type": "number" },
"reference": { "type": "token" }
}
},
"book": { "type": "token" },
"testament": { "type": "token" }
}
}
}

Code spotlights

Exhibit service

When you open the Mark 4 exhibit, the service loads the exhibit document, fetches the scripture verses it references, and hands the page one clean payload.

src/server/services/exhibit-service.ts
export async function getExhibitPageData(slug: string) {
const exhibit = await findExhibitBySlug(slug);
if (!exhibit) return null;

const verses = await findVersesInRange(
scriptureRangeFromExhibit(exhibit),
);

return serializeExhibitPageData({ exhibit, verses });
}

ObjectId boundary

MongoDB ObjectId values stay on the server. Hex strings cross the Server Component to client component boundary. I parse with _bsontype, not instanceof, because Turbopack can bundle duplicate bson copies.

src/lib/serialize-for-client.ts
export function serializeExhibit(exhibit: Exhibit): ClientExhibit {
if (!exhibit.triggerVerse) {
return { ...exhibit, triggerVerse: undefined };
}

return {
...exhibit,
triggerVerse: {
chunkId: objectIdToHex(exhibit.triggerVerse.chunkId),
verse: exhibit.triggerVerse.verse,
phrase: exhibit.triggerVerse.phrase,
},
};
}

Trigger verse

Cinematic exhibits declare a triggerVerse: a chunk, verse number, and exact phrase. The Mark 4 storm builds intensity as you scroll, then calms when that phrase appears in the text. No arbitrary timers.

src/types/exhibit.ts
export type TriggerVerse = {
chunkId: ObjectId;
verse: number;
phrase: string;
};

Polymorphic sections

Each exhibit section has a kind: intro, scripture panel, map, and so on. Every kind gets its own Zod schema for content and its own React component to render it.

src/components/exhibit/sections/ExhibitSection.tsx
export function ExhibitSection({ section }: ExhibitSectionProps) {
switch (section.kind) {
case "word-study":
return <WordStudySection ... />;
case "cross-reference":
return <CrossReferenceSection ... />;
case "comparison":
return <ComparisonSection ... />;
case "timeline":
return <TimelineSection ... />;
case "map":
return <ExodusRouteMap ... />;
case "text":
case "reflection":
return <TextSection ... />;
default:
return <TextSection ... />;
}
}

Four exhibits, four approaches

PassageexperienceTypeCustom UI
Mark 4cinematicStorm darkening, rain/wind audio, wave animation
Genesis 1literaryIntro synced to "Let there be light"
Exodus 14historyLeaflet route map, timeline, comparison views
John 1languageInline Greek word study overlays

Experience-specific components live in src/components/exhibits/<slug>/ and src/features/, not in the data model itself.

Reading mode

VerseSee is a reading app first. The gear menu in the header exposes four preferences that follow you from the chapter reader into exhibit scripture panels: theme, distraction-free layout, font size, and typeface.

  • Dark mode swaps warm paper tones for deep charcoal. Light mode stays the default.
  • Distractionless Reading strips reference apparatus for immersive reading: hides verse numbers, collapses section headers into flowing prose, and replaces chapter gaps with ornamental dividers. Exhibit links stay, tucked to the side.
  • Font size (S / M / L) scales scripture text and chapter headings together.
  • Font offers four deliberate typeface choices. See the table for why I picked each one.
SettingTypefaceWhy I chose it
ClassicSource Serif 4Default. Feels like a printed study Bible: familiar serifs, comfortable for long sessions.
SimpleGeist SansFor readers who prefer clean sans-serif on screens. UI chrome already uses Geist; scripture can match that clarity.
DyslexicOpenDyslexic (self-hosted)Weighted letterforms that reduce confusion (b/d, p/q). Accessibility without treating it as an afterthought.
ReformedIM Fell EnglishA 17th-century letterpress face. Evokes Reformation-era print culture when scripture was translated, printed, and put into ordinary hands.

Preferences persist in the browser and apply before first paint, so dark mode and font choice do not flash on load. Only scripture prose gets the reading typeface; site chrome stays Geist. The code blocks below show storage, validation, and CSS wiring.

src/types/reading-preferences.ts + src/lib/scripture-fonts.ts
export const scriptureFontLabels = {
serif: { label: "Classic", description: "Traditional book serif" },
sans: { label: "Simple", description: "Clean sans-serif alternative" },
clear: { label: "Dyslexic", description: "Weighted shapes, less letter confusion" },
reformed:{ label: "Reformed", description: "Hand-set letterpress type from the 1600s" },
};

export const scriptureFontClassNames = {
serif: sourceSerif.className, // Source Serif 4
sans: geistSans.className, // Geist Sans
clear: openDyslexic.className, // OpenDyslexic (local)
reformed: imFellEnglish.className, // IM Fell English
};
src/app/globals.css + src/features/reading-preferences/reading-preferences-storage.ts
/* globals.css */
[data-distraction-free="true"] .verse-number {
display: none;
}

// layout.tsx <head>: applies saved prefs before React hydrates
root.dataset.theme = preferences.theme;
root.dataset.scriptureSize = preferences.scriptureFontSize;
root.dataset.distractionFree = String(preferences.distractionFree);

Authoring pipeline

I author exhibits in TypeScript seed files, one per passage. When the content is ready, I run a seed script that validates each document with Zod and upserts it into MongoDB.

That keeps exhibit copy in version control next to the code that renders it. I edit text in my editor, not in an admin panel.

Questions about my work? Find me on LinkedIn, or explore the exhibits.

CI/CD

GitHub Actions

I wanted one pipeline that proves a change is safe before it hits production. A single GitHub Actions workflow runs on every pull request and on pushes to main.

Three jobs run in sequence: Validate (lint + build), then E2E (Playwright), then Deploy (main only). Pull requests stop after E2E. Deploy uses a production environment gate and a concurrency group so two releases never overlap.

.github/workflows/deploy.yml (trimmed)
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:

jobs:
validate: # lint + build
e2e: # needs: validate
deploy: # needs: [validate, e2e]; if: not pull_request

Production deploy

Production is deliberately boring: Cloudflare in front, Apache as a reverse proxy, Next.js on a single EC2 box, MongoDB Atlas for data. No Kubernetes cosplay.

When deploy runs, GitHub Actions SSHes to the server, resets to main, and runs a shell script that installs dependencies, builds the app, and restarts the PM2 process. Paths and process names live in the repo script; the trimmed excerpt below is abbreviated.

scripts/deploy.sh (trimmed)
cd "$APP_DIR"
git fetch origin main && git reset --hard origin/main

npm install
npm run build
pm2 startOrRestart "$ECOSYSTEM_FILE" --only "$APP_NAME" --update-env

Playwright tests

E2E tests hit the real app against a read-only Atlas dev cluster, not mocked scripture responses. If chapter navigation or an exhibit intro breaks, CI should catch it before deploy.

Twenty-eight tests cover three areas: smoke (homepage and header), reading (chapter nav, verse links, book picker), and all four exhibit vertical slices. Playwright boots the dev server automatically. CI retries flaky runs, prefers visible text and ARIA roles over test IDs, and skips database-dependent specs locally when MongoDB is not configured.

Failed CI runs keep a Playwright report as a GitHub Actions artifact and link it from the job summary on a private report host. Locally: npm run test:e2e, npm run test:e2e:ui, or filter by project (for example exhibits).

playwright.config.ts (trimmed)
export default defineConfig({
testDir: "tests/e2e",
retries: process.env.CI ? 2 : 0,
failOnFlakyTests: !!process.env.CI,
projects: [
{ name: "smoke", testMatch: /smoke\// },
{ name: "reading", testMatch: /read\// },
{ name: "exhibits", testMatch: /exhibits\// },
],
webServer: {
command: "npm run dev",
url: "http://localhost:3333",
reuseExistingServer: !process.env.CI,
},
});
tests/e2e/exhibits/mark-4.spec.ts
test("calms the storm when the trigger phrase is activated", async ({
page,
}) => {
await page.goto(exhibitPath("mark-4-storm"));

await exhibitSection(page, "Read")
.getByRole("button", { name: /Speak: Be quiet! Calm down!/i })
.click();

await expect
.poll(async () => page.locator('[data-storm-motion="idle"]').count())
.toBeGreaterThan(0);
});

Funny things that happened

Song of Solomon had no verses

I opened /read/song-of-solomon/1 and got a perfectly normal page: correct slug, correct chapter count, HTTP 200. The scripture panel was empty. Proverbs, Ecclesiastes, and Isaiah on either side loaded fine. MongoDB had 43 chunks for the book. The text was there. The query just could not find it.

One book, centuries of English titles

This is not a data pipeline failure. It is a naming fork that runs through Bible translation, Protestant print culture, and how English readers learned to say book names out loud. The Hebrew title is a superlative: the greatest song, not a song about Solomon. English later named the book after the king named in its opening line, and that KJV-era label stuck hard enough that an LLM helping me scaffold bible-structure.json reached for it without thinking.

SourceEnglish titleWhy it matters here
Hebrewשִׁיר הַשִּׁירִיםShir HashirimLiterally "Song of Songs." Hebrew repeats a word for the superlative, the same pattern as "holy of holies." The book never titles itself after Solomon.
KJV (1611)Song of SolomonProtestant England fixed this name in English culture. The opening superscription names Solomon; the title followed.
ESVSong of SolomonSame KJV-era convention carried into modern evangelical publishing. Familiar, author-attributed, uncontroversial on a bookshelf.
NETThe Song of SongsTranslators chose a title closer to the Hebrew original. VerseSee uses this name in bible-structure.json, the book picker, and MongoDB chunk queries.

Two layers, two naming traditions

VerseSee ended up with both names in production because two unrelated sources each made a reasonable choice. Chapter counts in bible-structure.json come from a KJV JSON file (scripts/generate-bible-structure.mjs). The LLM and I labeled the book the way most English readers know it: Song of Solomon. The scripture text in MongoDB was pre-processed from the NET Bible, which prints The Song of Songs. Neither side is wrong. They were never forced to agree until a reader tried to open chapter 1.

Same book, different strings at the MongoDB boundary
// bible-structure.json before the fix (KJV-influenced label)
"name": "Song of Solomon"

// MongoDB chunks collection (NET Bible text)
"book": "The Song of Songs"

What actually broke

The chapter loader looks up chunks using the book name from static metadata. MongoDB stores the NET title on every chunk and nested verse. Exact string equality, zero fuzzy matching, zero verses returned. No error thrown. Just a quiet empty chapter on an otherwise healthy route.

Of all 66 books, this was the only one where static metadata and chunk documents disagreed on the English name. A bug shaped like a footnote in translation history: KJV culture on one side, NET literalism on the other, and a string compare in the middle. Kind of perfect. The fix was to standardize on the NET title, The Song of Songs, in bible-structure.json and everywhere the picker and reader resolve book names.

Tap Be quiet! Calm down!

While tuning the Mark 4 storm exhibit, an agent prepended Tap inside the scripture span so users would know the phrase was clickable. The verse rendered as "Tap Be quiet! Calm down!"inside Jesus' quotation. That is not curation. That is editing the text.

VerseSee never replaces English scripture for UI hints. Exhibit copy belongs in curator intro and section cards. Inline terms get styling only: color, weight, underline. The fake word was removed immediately. The underline stayed.