Fredy Acuna
  • Posts
  • Projects
  • Tools
  • Contact
LinkedInXGitHubMedium

© 2026 Fredy Acuna

Volver a publicaciones
Cómo Construí una Calculadora Bíblica Bilingüe de Distribución Financiera en un Monorepo Next.js 16

Cómo Construí una Calculadora Bíblica Bilingüe de Distribución Financiera en un Monorepo Next.js 16

Fredy Acuna / May 13, 2026 / 12 min read

Una Herramienta Real con Decisiones de Arquitectura Reales

Acabo de lanzar una calculadora bíblica bilingüe de distribución financiera en /tools/distribucion-financiera (ES) y /tools/financial-distribution (EN). Le das un monto, y la calculadora lo reparte en tres categorías de mayordomía bíblica — Personal/Hogar, Negocio Cristiano, e Iglesia/Ministerio — cada una con sus propios sub-porcentajes y referencias bíblicas.

La feature de cara al usuario es simple. La arquitectura que la sostiene es donde está lo interesante: una migración a monorepo Next.js 16, un paquete de cálculo en TS puro, dos rutas físicas para SEO bilingüe, un middleware de locale estilo Amazon, decisiones sobre escrituras de dominio público, y exportación a PDF vectorial sin dependencias.

Este post recorre las decisiones arquitectónicas — qué elegí, qué descarté, y por qué.


De App Única a Monorepo pnpm

El portfolio arrancó como una app Next.js única. Cuando dimensioné esta calculadora, la primera decisión fue estructural: ¿meto la lógica dentro de app/tools/... y listo, o migro todo el repo a un monorepo?

Casi me salteo el monorepo. YAGNI es real, y "future-proofing" es la mentira más cara de nuestra industria. Pero me quedé con una regla: la matemática es genuinamente reutilizable. Las definiciones de categorías, los splits de porcentajes, la función computeDistribution() — nada de eso debería saber de React, Next.js, ni de ningún UI específico. Si mañana quiero una versión CLI, o un test runner, o embeberla en otra app, la lógica no debería importarle.

Así quedó el layout:

/Users/fredhii/Documents/personal/Portfolio
├── apps/
│   └── web/                # La app Next.js 16
├── packages/
│   ├── calculators/        # TS puro — types, schemas Zod, funciones compute
│   └── ui/                 # Helper `cn` compartido (a propósito mínimo)
├── pnpm-workspace.yaml
└── package.json

pnpm-workspace.yaml es el archivo más chico e interesante del repo:

packages:
  - "apps/*"
  - "packages/*"

La app Next.js depende de los paquetes del workspace vía workspace:*:

{
  "dependencies": {
    "@fredhii/calculators": "workspace:*",
    "@fredhii/ui": "workspace:*"
  }
}

Disclosure honesto: packages/ui por ahora solo exporta un helper cn. Consideré borrarlo. Lo mantuve porque en el momento que agregue un segundo primitivo compartido (un <Button />, una <Card />), quiero que la casa para ese código ya exista. Seguro barato — pero si un junior intentara hacer lo mismo "por las dudas" con un paquete sin un segundo caso de uso obvio, lo bajaría a tierra fuerte.


Por Qué packages/calculators Es TypeScript Puro

La regla más importante que me puse para packages/calculators: cero imports de React. Ninguno. Ni un useState, ni un useMemo, ni un solo archivo JSX.

¿Por qué importa esto? Porque en el momento que importás React en un paquete de "lógica", deja de ser un paquete de lógica — pasa a ser un paquete de UI con bigote falso. Todo consumidor de ese paquete ahora tiene que arrastrar React, incluso si solo está corriendo un script de Node.

Esta es la forma real de packages/calculators/src/distribucion-financiera/:

src/distribucion-financiera/
├── data.ts        # Categorías + escrituras hardcodeadas (EN + ES)
├── schemas.ts     # Schemas Zod para validar input
├── compute.ts     # Función pura: amount -> resultado de distribución
├── types.ts       # Tipos inferidos desde Zod
└── index.ts       # Barrel público

compute.ts se ve aproximadamente así:

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

Eso es todo. Función pura, sin side effects, totalmente tree-shakeable. Puedo hacer import { computeDistribution } from '@fredhii/calculators' desde un test de Vitest, una CLI de Bun, un componente Vue, o un Cloudflare Worker. El paquete no sabe — y no necesita saber.

Esto es el principio de Inversión de Dependencias aplicado en el límite del paquete. El UI depende de la calculadora. La calculadora no depende de nada.


Escrituras Hardcodeadas vs Fetch a API

La primera versión de esta calculadora hacía fetch de los versículos a bible-api.com desde el cliente. Cada item renderizaba un <Loading />, después se disparaba un useEffect, después aparecía el versículo. Funcionaba. También parpadeaba, pegaba contra el rate limit en dev, y se rompía en modo avión.

Cambié a hardcodear el texto de la escritura directamente en 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;

Por qué el cambio:

  • Cero problemas de CSP: no necesito una excepción en connect-src
  • Sin rate limits: no dependo de una API gratuita que podría throttlear, cambiar su schema, o desaparecer
  • Sin flash de loading: el versículo renderiza sincrónicamente en el server junto al resto de la página
  • Funciona offline: la calculadora es una experiencia estática service-worker-eable
  • Suficientemente tree-shakeable: el texto de las escrituras suma quizá 8KB gzipped — completamente aceptable por la mejora de UX

La lección es más amplia que esta calculadora: default a data estática, fetcheá solo cuando la data sea realmente dinámica. Un versículo de una traducción de hace 400 años no es data dinámica.


Por Qué RV1909 y KJV (y No RVR1960, NVI, o NTV)

Esta decisión es legal, no técnica — y por eso mismo pertenece a la conversación de arquitectura. Los ingenieros que se saltean la capa legal terminan shippeando productos que reciben un DMCA-takedown seis meses después.

  • King James Version (1611) — dominio público en toda jurisdicción que me interesa
  • Reina-Valera 1909 — dominio público en toda jurisdicción que me interesa
  • RVR1960, NVI, NTV, ESV, NIV — con copyright. Sociedades Bíblicas Unidas es dueña de RVR1960. Tyndale es dueña de NTV. Crossway es dueña de ESV. Sociedad Bíblica Internacional es dueña de NVI.

Citar una traducción con copyright en un producto público sin licencia es un riesgo legal a cambio de cero ganancia técnica. El usuario puede leer RVR1960 en YouVersion si quiere. Mi calculadora cita traducciones de dominio público, las atribuye claramente en el UI, y duerme tranquila de noche.

Si shippeás cualquier cosa que cite escrituras, letras de canciones, o texto literario — revisá la licencia antes de revisar cualquier otra cosa.


SEO Bilingüe: Dos Rutas Físicas, No Query Params

Acá es donde veo perder a la mayoría de los devs: i18n con query params (?lang=es) o con una sola ruta que cambia el contenido según una cookie. Google indexa ambas igual: una URL, una entidad, y el segundo idioma jamás rankea.

Fui por el camino aburrido y correcto: dos rutas físicas de Next.js.

apps/web/app/tools/
├── financial-distribution/
│   └── page.tsx           # EN
└── distribucion-financiera/
    └── page.tsx           # ES

Cada page.tsx exporta su propio generateMetadata:

export async function generateMetadata(): Promise<Metadata> {
  return {
    title: 'Calculadora Bíblica de Distribución Financiera | Fredhii',
    description: '...',
    alternates: {
      canonical: 'https://fredhii.com/tools/distribucion-financiera',
      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: 'es_ES',
      alternateLocale: ['en_US'],
      // ...
    },
  };
}

Encima de los hreflang alternates, cada página emite un bloque JSON-LD de WebApplication:

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
    __html: JSON.stringify({
      '@context': 'https://schema.org',
      '@type': 'WebApplication',
      name: 'Calculadora Bíblica de Distribución Financiera',
      url: 'https://fredhii.com/tools/distribucion-financiera',
      inLanguage: 'es',
      applicationCategory: 'FinanceApplication',
    }),
  }}
/>

El payoff: Google trata a cada URL como una entidad distinta. La página EN rankea para queries en inglés. La página ES rankea para queries en español. Se referencian cruzadas vía hreflang para que la audiencia correcta caiga en la URL correcta desde la SERP.

Un usuario buscando "calculadora distribución bíblica" cae directo en la página ES. Un usuario buscando "biblical financial distribution calculator" cae directo en la EN. Un solo codebase, dos productos a los ojos de Google.


Middleware de Locale Estilo Amazon

El detalle aburrido del que nadie habla: cuando un usuario cae en la raíz, ¿qué idioma ve?

Tres respuestas malas:

  1. Siempre EN — discrimina a la audiencia ES
  2. Siempre el idioma del browser sin override — le saca el control al usuario
  3. Modal preguntando "¿qué idioma?" — UX horrible, te destruye el LCP

La respuesta de Amazon (y ahora la mía): leer el header Accept-Language en la primera visita, redirigir, y recordar la elección manual del usuario vía cookie.

Acá está el slice relevante de 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;

  // Si el usuario seteó manualmente un locale, NUNCA vuelvas a auto-redirigir
  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'],
};

El componente <LocaleToggle /> setea la cookie cuando el usuario clickea "EN" o "ES":

document.cookie = `fredhii-locale=${nextLocale}; path=/; max-age=31536000; samesite=lax`;

Después de ese primer toggle manual, el middleware ve la cookie y se hace a un lado. La elección explícita del usuario siempre le gana a la heurística del browser. Ese es el modelo Amazon en unas 30 líneas.


Print-to-PDF vía window.print() (y Por Qué Descarté las Alternativas)

Los usuarios quieren guardar su distribución como PDF. Tres opciones sobre la mesa:

  1. @react-pdf/renderer — API hermosa, pero arrastra 200KB+ de dependencias, me fuerza a mantener un template JSX paralelo que duplique el diseño en pantalla, y se rompe cada vez que toco el UI
  2. html2pdf.js / jspdf + html2canvas — rasterizan la página en una imagen gigante adentro de un PDF. El output es borroso, el texto no se puede seleccionar, el archivo pesa muchísimo
  3. window.print() + CSS @media print — cero dependencias, output vectorial (el texto es texto real), nativo del browser, funciona en todas partes

Fui por la opción 3. El print CSS vive en 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;
  }

  /* Forzar render vectorial de texto, nunca rasterizar */
  * {
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }
}

El botón de PDF es literalmente:

<button onClick={()=> window.print()}>Guardar como PDF</button>

El browser abre el diálogo nativo de impresión. El usuario elige "Guardar como PDF" como destino. El output es un PDF real, vectorial, searchable. Cero dependencias, cero impacto en el bundle, cero deuda de mantenimiento.

Usá la plataforma. El equipo del browser pasó 20 años puliendo window.print() — dejá que trabajen ellos.


El Gotcha de CSP (Vale la Pena Aprenderlo Aunque Saqué el Fetch)

Antes mencioné que cambié de bible-api.com a escrituras hardcodeadas. La primera vez que deployé la versión con fetch a producción, todos los versículos renderizaban "Loading…" para siempre. La consola del browser mostraba:

Refused to connect to 'https://bible-api.com/...' because it violates
the Content Security Policy directive: "connect-src 'self'".

CSP estaba bloqueando el fetch. Mi next.config.mjs tenía una policy estricta con connect-src 'self'. Para permitir la API necesitaría:

Content-Security-Policy: connect-src 'self' https://bible-api.com;

Nunca shippeé ese fix porque borré el fetch completo — pero la lección es universal:

Cada vez que agregues un fetch externo, revisá tu CSP primero. Un 200 en dev no significa nada si tus headers de prod bloquean el request.

Un CSP estricto es uno de los controles de seguridad de más alto leverage que podés agregar a una app Next.js. También caza decisiones de arquitectura flojas (como "hagamos fetch a un versículo estático en cada render") antes de que lleguen a producción. Dos pájaros, un header.


Reflexión Final: Arquitectura Es Contención

Mirando este build hacia atrás, el tema recurrente es lo que elegí no hacer:

  • No shippeé @react-pdf/renderer — usé window.print()
  • No hice fetch de las escrituras a una API — hardcodeé texto de dominio público
  • No usé una sola ruta con cookie — armé dos URLs físicas
  • No shippeé un <Modal /> pesado para elegir idioma — usé un middleware redirect
  • No hice que packages/calculators dependa de React — lo mantuve puro

El monorepo es el único lugar donde sumé complejidad, e incluso ahí lo justifiqué con un caso de reuso concreto. Toda otra decisión fue un "no" a la complejidad en favor de aprovechar la plataforma.

Esa es la jugada de senior. No "¿qué librería agrego?" — sino "¿qué puedo sacar y aún así shippear un mejor producto?"


Probala

La calculadora está al aire. Metele tu ingreso mensual, mirá cómo se reparte la distribución bíblica, guardá el resultado como PDF, compartilo con quien camines este recorrido.

Abrir la Calculadora Bíblica de Distribución Financiera

Si querés ver los patterns de código que describí en acción — el paquete TS puro, el middleware, el print CSS — todos viven en este repo. Desarmalos, robá lo que te sirva, y shippeá algo mejor.


Recursos Relacionados

  • Docs del App Router de Next.js 16
  • pnpm Workspaces
  • Guía de hreflang de Google
  • Schema.org WebApplication

Subscribe to my newsletter

Get notified when I publish new posts.

We care about your data. Read our privacy policy.