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"?

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.

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
Cloudflare, Apache reverse proxy, systemd on Linux. Boring infrastructure on purpose.

Architecture

Browser

React client components only where needed

On a passage page, the route calls getExhibitPageData(slug), which loads the exhibit document and joins it with scripture verses from the chunks collection at read time.

Database

I split scripture and exhibits into separate MongoDB collections because they have different lifecycles and shapes.

chunks holds the full NET Bible, pre-processed into logical chunks with structured verses[] arrays. This is bulk, stable, searchable data.

exhibits holds handcrafted editorial documents with polymorphic sections[], linked to scripture by book, chapter, and verse range. These are curated, typed, and evolve independently of the Bible text.

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": "6852f910721a57de6b539490",
"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

The service layer joins exhibit metadata with scripture at read time, then serializes for the client.

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 });
}

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,
},
};
}

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;
};

Exhibit sections are typed and polymorphic. Each kind has its own Zod content schema and React component. This is a structured content model, not a CMS blob.

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.

Authoring pipeline

I author exhibits in TypeScript at src/data/*-exhibit.ts, validate with Zod at the MongoDB boundary, and seed with npm run seed:exhibits. TypeScript is the source of truth, not a CMS admin panel.

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

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

getChapterVerses looks up chunks with 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.

Jesus said "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.