
Fredy Acuna / May 13, 2026 / 12 min read
I just shipped a bilingual biblical financial distribution calculator at /tools/financial-distribution (EN) and /tools/distribucion-financiera (ES). You give it a monetary amount, it splits the money across three biblical stewardship categories — Personal/Home, Christian Business, and Church/Ministry — each one with its own sub-percentages and scripture references.
The user-facing feature is simple. The architecture behind it is where things get interesting: a Next.js 16 monorepo migration, a pure-TS calculator package, two physical routes for bilingual SEO, an Amazon-style locale middleware, public-domain scripture decisions, and a vectorial PDF export with zero dependencies.
This post is a walkthrough of the architectural decisions — what I chose, what I rejected, and why.
The portfolio started as a single Next.js app. When I scoped this calculator, the first decision was structural: do I drop the logic inside app/tools/... and call it a day, or do I migrate the whole repo to a monorepo?
I almost skipped the monorepo. YAGNI is real, and "future-proofing" is the most expensive lie in our industry. But I kept one rule: the math is genuinely reusable. The category definitions, the percentage splits, the computeDistribution() function — none of that should know about React, Next.js, or any specific UI. If tomorrow I want a CLI version, or a unit test runner, or to embed it in another app, the logic should not care.
So the layout ended up like this:
/Users/fredhii/Documents/personal/Portfolio
├── apps/
│ └── web/ # The Next.js 16 app
├── packages/
│ ├── calculators/ # Pure TS — types, Zod schemas, compute fns
│ └── ui/ # Shared `cn` utility (kept minimal on purpose)
├── pnpm-workspace.yaml
└── package.json
pnpm-workspace.yaml is the smallest interesting file in the repo:
packages:
- "apps/*"
- "packages/*"
The Next.js app depends on the workspace packages via workspace:*:
{
"dependencies": {
"@fredhii/calculators": "workspace:*",
"@fredhii/ui": "workspace:*"
}
}
Honest disclosure:
packages/uionly exports acnhelper right now. I considered deleting it. I kept it because the moment I add a second shared primitive (a<Button />, a<Card />), I want the home for it to already exist. Cheap insurance — but I'd push back hard if a junior tried to do the same "just in case" with a package that didn't have an obvious second use case lined up.
packages/calculators Is Pure TypeScriptThe most important rule I set for packages/calculators: zero React imports. None. Not a single useState, not a single useMemo, not a single JSX file.
Why does this matter? Because the moment you import React from a "logic" package, it's no longer a logic package — it's a UI package wearing a fake mustache. Every consumer of that package now has to pull in React, even if they're just running a Node script.
Here's the actual shape of packages/calculators/src/distribucion-financiera/:
src/distribucion-financiera/
├── data.ts # Category definitions + hardcoded scripture (EN + ES)
├── schemas.ts # Zod schemas for input validation
├── compute.ts # Pure function: amount -> distribution result
├── types.ts # Inferred types from Zod
└── index.ts # Public barrel
compute.ts looks roughly like this:
import { z } from 'zod';
import { distributionInputSchema } from './schemas';
import { categories } from './data';
import type { DistributionInput, DistributionResult } from './types';
export function computeDistribution(
input: DistributionInput,
): DistributionResult {
const parsed = distributionInputSchema.parse(input);
return categories.map((category) => {
const categoryAmount = parsed.amount * category.percentage;
return {
id: category.id,
name: category.name,
amount: categoryAmount,
items: category.items.map((item) => ({
id: item.id,
label: item.label,
scripture: item.scripture,
amount: categoryAmount * item.percentage,
})),
};
});
}
That's it. Pure function, no side effects, fully tree-shakeable. I can import { computeDistribution } from '@fredhii/calculators' from a Vitest test, a Bun CLI, a Vue component, or a Cloudflare Worker. The package doesn't know — and doesn't need to.
This is the Dependency Inversion principle applied at the package boundary. The UI depends on the calculator. The calculator depends on nothing.
The first version of this calculator fetched verses from bible-api.com on the client. Each item rendered a <Loading /> state, then a useEffect fired, then the verse appeared. It worked. It also flickered, hit rate limits in dev, and broke in airplane mode.
I switched to hardcoding the scripture text directly in packages/calculators/src/distribucion-financiera/data.ts:
export const categories = [
{
id: 'personal',
percentage: 0.6,
name: { en: 'Personal & Home', es: 'Personal y Hogar' },
items: [
{
id: 'tithe',
percentage: 0.1,
label: { en: 'Tithe', es: 'Diezmo' },
scripture: {
reference: { en: 'Malachi 3:10', es: 'Malaquías 3:10' },
text: {
en: 'Bring ye all the tithes into the storehouse, that there may be meat in mine house...',
es: 'Traed todos los diezmos al alfolí y haya alimento en mi casa...',
},
version: { en: 'KJV', es: 'RV1909' },
},
},
// ...
],
},
// ...
] as const;
Why the switch:
connect-src exception neededThe lesson is broader than this calculator: default to static data, fetch only when the data is truly dynamic. A bible verse from a 400-year-old translation is not dynamic data.
This one is a legal decision, not a technical one — and that's exactly why it belongs in the architecture conversation. Engineers who skip the legal layer ship products that get DMCA-takedown'd six months later.
Quoting a copyrighted translation in a public product without a license is a legal risk for zero technical gain. The user can read RVR1960 on YouVersion if they want. My calculator quotes public-domain translations, attributes them clearly in the UI, and sleeps fine at night.
If you ship anything that quotes scripture, song lyrics, or literary text — check the license before you check anything else.
Here's where I see most devs lose: i18n with query params (?lang=es) or with a single route that flips content based on a cookie. Google indexes both the same way: one URL, one entity, and the second language never ranks.
I went the boring, correct way: two physical Next.js routes.
apps/web/app/tools/
├── financial-distribution/
│ └── page.tsx # EN
└── distribucion-financiera/
└── page.tsx # ES
Each page.tsx exports its own generateMetadata:
export async function generateMetadata(): Promise<Metadata> {
return {
title: 'Biblical Financial Distribution Calculator | Fredhii',
description: '...',
alternates: {
canonical: 'https://fredhii.com/tools/financial-distribution',
languages: {
en: 'https://fredhii.com/tools/financial-distribution',
es: 'https://fredhii.com/tools/distribucion-financiera',
'x-default': 'https://fredhii.com/tools/financial-distribution',
},
},
openGraph: {
locale: 'en_US',
alternateLocale: ['es_ES'],
// ...
},
};
}
On top of hreflang alternates, each page emits a JSON-LD WebApplication block:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'Biblical Financial Distribution Calculator',
url: 'https://fredhii.com/tools/financial-distribution',
inLanguage: 'en',
applicationCategory: 'FinanceApplication',
}),
}}
/>
The payoff: Google treats each URL as a distinct entity. The EN page ranks for English queries. The ES page ranks for Spanish queries. They cross-reference each other via hreflang so the right audience lands on the right URL from the SERP.
A user searching "calculadora distribución bíblica" gets the ES page directly. A user searching "biblical financial distribution calculator" gets the EN page directly. One codebase, two products in Google's eyes.
The boring detail nobody talks about: when a user lands on the root, which language do they see?
Three bad answers:
Amazon's answer (and now mine): read the Accept-Language header on first hit, redirect, and remember the user's manual choice via cookie.
Here's the relevant slice from apps/web/middleware.ts:
import { NextResponse, type NextRequest } from 'next/server';
const LOCALE_COOKIE = 'fredhii-locale';
const SUPPORTED = ['en', 'es'] as const;
const ROUTE_MAP = {
'/tools/financial-distribution': {
en: '/tools/financial-distribution',
es: '/tools/distribucion-financiera',
},
'/tools/distribucion-financiera': {
en: '/tools/financial-distribution',
es: '/tools/distribucion-financiera',
},
} as const;
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value;
// If user manually set a locale, NEVER auto-redirect again
if (cookieLocale) return NextResponse.next();
const accept = req.headers.get('accept-language') ?? '';
const preferred = accept.toLowerCase().startsWith('es') ? 'es' : 'en';
const mapping = ROUTE_MAP[pathname as keyof typeof ROUTE_MAP];
if (mapping && mapping[preferred] !== pathname) {
return NextResponse.redirect(new URL(mapping[preferred], req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/tools/financial-distribution', '/tools/distribucion-financiera'],
};
The <LocaleToggle /> component sets the cookie when the user clicks "EN" or "ES":
document.cookie = `fredhii-locale=${nextLocale}; path=/; max-age=31536000; samesite=lax`;
After that first manual toggle, the middleware sees the cookie and steps aside. The user's explicit choice always wins over the browser's heuristic. That's the Amazon model in roughly 30 lines.
Users want to save their distribution as a PDF. Three options on the table:
@react-pdf/renderer — beautiful API, but pulls in 200KB+ of dependencies, forces me to maintain a parallel JSX template that mirrors the on-screen design, and breaks every time I tweak the UIhtml2pdf.js / jspdf + html2canvas — rasterizes the page into a giant image inside a PDF. The output is fuzzy, the text isn't selectable, the file is hugewindow.print() + CSS @media print — zero dependencies, vectorial output (text is real text), browser-native, works everywhereI went with option 3. The print CSS lives in apps/web/app/globals.css:
@media print {
body {
background: white !important;
color: black !important;
}
.no-print {
display: none !important;
}
.print-only {
display: block !important;
}
@page {
margin: 1.5cm;
size: A4;
}
/* Force vector text rendering, never rasterize */
* {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
The PDF button is literally:
<button onClick={()=> window.print()}>Save as PDF</button>
The browser opens the native print dialog. The user picks "Save as PDF" as the destination. The output is a real, vectorial, searchable PDF. Zero dependencies, zero bundle impact, zero maintenance burden.
Use the platform. The browser team has spent 20 years making window.print() work — let them.
Earlier I mentioned I switched from bible-api.com to hardcoded scripture. The first time I deployed the API-fetch version to production, every verse rendered as "Loading…" forever. The browser console showed:
Refused to connect to 'https://bible-api.com/...' because it violates
the Content Security Policy directive: "connect-src 'self'".
CSP was blocking the fetch. My next.config.mjs had a strict policy with connect-src 'self'. To allow the API I'd have needed:
Content-Security-Policy: connect-src 'self' https://bible-api.com;
I never shipped that fix because I deleted the fetch entirely — but the lesson is universal:
Every time you add an external fetch, check your CSP first. A 200 in dev means nothing if your prod headers block the request.
Strict CSP is one of the highest-leverage security controls you can add to a Next.js app. It also catches sloppy architecture decisions (like "let's fetch a static bible verse on every render") before they hit production. Two birds, one header.
Looking back at this build, the recurring theme is what I chose not to do:
@react-pdf/renderer — I used window.print()<Modal /> for language choice — I used a middleware redirectpackages/calculators depend on React — I kept it pureThe monorepo is the one place I added complexity, and even there I justified it with a concrete reuse case. Every other decision was a "no" to complexity in favor of leveraging the platform.
That's the senior move. Not "what library do I add?" — but "what can I remove and still ship a better product?"
The calculator is live. Plug in your monthly income, see how the biblical distribution plays out, save the result as a PDF, share it with whoever you walk this journey with.
Open the Biblical Financial Distribution Calculator
If you want to see the code patterns I described in action — the pure TS package, the middleware, the print CSS — they all live in this repo. Pull them apart, steal what's useful, and ship something better.