
Verbum is a Catholic study platform that puts the Bible, the daily liturgy, Laudes, the Catechism and personal notes in one workspace. It is a React app with a Fastify API, PostgreSQL, Redis and a nightly Trigger.dev worker that prepares each day's content. The post covers the decisions behind it: parsing liturgical references, keeping search in Postgres, making the nightly jobs reliable and treating reading history as sensitive data.
Turned three scattered morning habits (a Bible app, a liturgy website and loose notes) into one study workspace that prepares itself every night.
Every morning I had three tabs open. The Bible in one app. The daily liturgy on a website. My notes in a third place, pasted by hand, with references I had to type again every time. None of them knew about the others. A note about Sunday's Gospel had no link to the Gospel itself.
Verbum is my answer to that. It is a Catholic study platform where the text you read, the liturgy of the day and the notes you write live in the same workspace and point to each other. I built it for myself first, around the way I actually study.
You read the Bible by book and chapter, select verses and save them as passages. Notes are rich text and can link to several passages and to the readings of a given day. The liturgy and Morning Prayer (Laudes) show up on their own every morning, with a short daily reflection. Around that core there is the Catechism of the Catholic Church, searchable by term or by paragraph number, a timeline of the Old and New Testaments, a collection of Catholic prayers, an examination of conscience, and a page that shows how much of the Bible you have read so far.




Verbum is a monorepo with three apps. A React single page app talks to a Fastify API. The API owns PostgreSQL and uses Redis for caching and usage limits. A worker on Trigger.dev runs the nightly jobs and talks to the API the same way the browser does, over HTTP, with its own secret.
I kept the worker away from the database on purpose. It fetches and parses pages, then hands the result to the API. All writes go through the same rules, whether a person or a job sends them.
The liturgy arrives as text, and its references look like this: Sl 95(96),1-2.3 (R. 3). That one line holds a book, two chapter numbers, a list of verses and a refrain. The two numbers exist because the Brazilian lectionary follows the Greek Septuagint numbering for the Psalms and puts the Hebrew number in parentheses. My Bible data uses the Hebrew numbering. If I read the first number, I show the wrong Psalm, and nothing crashes to warn me.
So the parser prefers the number in parentheses when it is numeric, and drops it when it is a label like 147B. It also strips the half-verse letters (2a, 2b) that the lectionary uses, splits ranges and lists, and pulls the refrain out as its own verse. The result is a plain value with no database access, which makes it easy to reason about.
The cost is that I am encoding someone else's conventions. When the source changes its format, the parser is the place that breaks. I accepted that because the alternative was storing the reading text as is, and then a note could never point to a real verse.
People search the Bible the way they talk. In Portuguese that means accents, and many people skip them when typing. A search for "graca" has to find "graça", and "pecados" should find "pecado".
I could have added a search engine or embeddings. I chose Postgres full text search instead, with a custom Portuguese configuration that removes accents before stemming. A trigger keeps a search column up to date, and a GIN index serves the queries. When the Catechism arrived, it reused the same setup, so one decision covered two features. Repeated Catechism searches are cached in Redis for an hour.
The cost is clear. This is keyword search. It does not know that "forgiveness" and "mercy" are close in meaning. For a reader who remembers a few words of a verse, that is fine, and it adds no new service to run, pay for or keep in sync.
The liturgy, Laudes and the daily reflection need to be ready before people wake up, and nobody is awake to fix things when they fail. The sources are websites I do not control. They can be slow, change their layout or time out. So I assumed from the start that every job would fail sometimes and would run more than once.
Each job retries with backoff and some randomness, so a bad minute on the source does not cost the whole day. The jobs are also chained: the reflection only starts after the liturgy for that day is saved, because it is written from those readings. To make retries safe, the API answers with a conflict when that day's content already exists. The worker reads that answer as "already done" and stops, so a rerun never writes the same day twice.
The cost is that the API has to know what "the same day" means for each kind of content, and every new job has to follow the same rule. In return, I can rerun any night by hand without thinking about duplicates.
At some point I noticed what the app was really storing. A list of chapters someone read, the liturgies they opened and the notes they wrote can reveal their faith. Under Brazil's data protection law (LGPD), religious conviction is sensitive data and needs explicit consent.
So consent became part of the product. Before using the app, each person sees what is stored, why, and how to leave. Consent is recorded with its type and version, so I know which terms someone agreed to. Analytics is a separate choice, off by default. Deleting an account first emails the person a JSON export of their data, then removes it. The examination of conscience stores nothing at all. Login is passwordless: magic links whose tokens exist in the database only as SHA-256 hashes, are single use, and are purged every week once they expire.
The cost was time spent on screens that do not look like features. I think it was the right call. People trust a study tool with very personal things, and the design should respect that.
Use cases throw domain errors, and a single base class turns them into an Either at the boundary, so controllers map them to HTTP codes and anything unexpected goes to Sentry. Notes export to PDF through a headless browser with JavaScript turned off, after the HTML is sanitized on the server, and each user gets five exports a month, counted in Redis and reset at the start of each month. Refresh tokens rotate with a single conditional update, so the same token cannot be used twice.
Verbum is live, with access by invitation or request and an admin area for users and usage stats. Next on my list are study suggestions on the home page and moving the consent check from the client into the API, so the rule lives where the data does.Verbum: one place to read, pray and study
Every morning I had three tabs open. The Bible in one app. The daily liturgy on a website. My notes in a third place, pasted by hand, with references I had to type again every time. None of them knew about the others. A note about Sunday's Gospel had no link to the Gospel itself.
Verbum is my answer to that. It is a Catholic study platform where the text you read, the liturgy of the day and the notes you write live in the same workspace and point to each other. I built it for myself first, around the way I actually study.
You read the Bible by book and chapter, select verses and save them as passages. Notes are rich text and can link to several passages and to the readings of a given day. The liturgy and Morning Prayer (Laudes) show up on their own every morning, with a short daily reflection. Around that core there is the Catechism of the Catholic Church, searchable by term or by paragraph number, a timeline of the Old and New Testaments, a collection of Catholic prayers, an examination of conscience, and a page that shows how much of the Bible you have read so far.




Verbum is a monorepo with three apps. A React single page app talks to a Fastify API. The API owns PostgreSQL and uses Redis for caching and usage limits. A worker on Trigger.dev runs the nightly jobs and talks to the API the same way the browser does, over HTTP, with its own secret.
I kept the worker away from the database on purpose. It fetches and parses pages, then hands the result to the API. All writes go through the same rules, whether a person or a job sends them.
The liturgy arrives as text, and its references look like this: Sl 95(96),1-2.3 (R. 3). That one line holds a book, two chapter numbers, a list of verses and a refrain. The two numbers exist because the Brazilian lectionary follows the Greek Septuagint numbering for the Psalms and puts the Hebrew number in parentheses. My Bible data uses the Hebrew numbering. If I read the first number, I show the wrong Psalm, and nothing crashes to warn me.`
So the parser prefers the number in parentheses when it is numeric, and drops it when it is a label like 147B. It also strips the half-verse letters (2a, 2b`) that the lectionary uses, splits ranges and lists, and pulls the refrain out as its own verse. The result is a plain value with no database access, which makes it easy to reason about.
The cost is that I am encoding someone else's conventions. When the source changes its format, the parser is the place that breaks. I accepted that because the alternative was storing the reading text as is, and then a note could never point to a real verse.
People search the Bible the way they talk. In Portuguese that means accents, and many people skip them when typing. A search for "graca" has to find "graça", and "pecados" should find "pecado".
I could have added a search engine or embeddings. I chose Postgres full text search instead, with a custom Portuguese configuration that removes accents before stemming. A trigger keeps a search column up to date, and a GIN index serves the queries. When the Catechism arrived, it reused the same setup, so one decision covered two features. Repeated Catechism searches are cached in Redis for an hour.
The cost is clear. This is keyword search. It does not know that "forgiveness" and "mercy" are close in meaning. For a reader who remembers a few words of a verse, that is fine, and it adds no new service to run, pay for or keep in sync.
The liturgy, Laudes and the daily reflection need to be ready before people wake up, and nobody is awake to fix things when they fail. The sources are websites I do not control. They can be slow, change their layout or time out. So I assumed from the start that every job would fail sometimes and would run more than once.
Each job retries with backoff and some randomness, so a bad minute on the source does not cost the whole day. The jobs are also chained: the reflection only starts after the liturgy for that day is saved, because it is written from those readings. To make retries safe, the API answers with a conflict when that day's content already exists. The worker reads that answer as "already done" and stops, so a rerun never writes the same day twice.
The cost is that the API has to know what "the same day" means for each kind of content, and every new job has to follow the same rule. In return, I can rerun any night by hand without thinking about duplicates.
At some point I noticed what the app was really storing. A list of chapters someone read, the liturgies they opened and the notes they wrote can reveal their faith. Under Brazil's data protection law (LGPD), religious conviction is sensitive data and needs explicit consent.
So consent became part of the product. Before using the app, each person sees what is stored, why, and how to leave. Consent is recorded with its type and version, so I know which terms someone agreed to. Analytics is a separate choice, off by default. Deleting an account first emails the person a JSON export of their data, then removes it. The examination of conscience stores nothing at all. Login is passwordless: magic links whose tokens exist in the database only as SHA-256 hashes, are single use, and are purged every week once they expire.
The cost was time spent on screens that do not look like features. I think it was the right call. People trust a study tool with very personal things, and the design should respect that.
Use cases throw domain errors, and a single base class turns them into an Either at the boundary, so controllers map them to HTTP codes and anything unexpected goes to Sentry. Notes export to PDF through a headless browser with JavaScript turned off, after the HTML is sanitized on the server, and each user gets five exports a month, counted in Redis and reset at the start of each month. Refresh tokens rotate with a single conditional update, so the same token cannot be used twice.
Verbum is live, with access by invitation or request and an admin area for users and usage stats. Next on my list are study suggestions on the home page and moving the consent check from the client into the API, so the rule lives where the data does.