guias

Catálogo de servicios data-driven en Astro

Cómo armar un catálogo de servicios data-driven en Astro: iconos por categoría, mapping desde la colección y un solo grid que crece sin tocar el JSX.

Catálogo de servicios data-driven en Astro

Un catálogo de servicios hardcodeado parece la solución obvia hasta que el cliente pide agregar el cuarto servicio, mover el segundo al primer lugar y cambiar el icono del tercero —todo en la misma llamada—. El refactor que motivó esta guía fue de un sitio de servicios de mantenimiento industrial: 14 servicios, todos pintados en src/pages/servicios/index.astro con ‹ServiceCard /› repetidos a mano. Cada cambio editorial (un precio, un orden, un destacado) abría un PR; cada PR pasaba por revisión técnica porque mezclaba contenido con JSX. Migrar a Content Collections tomó 6 horas reales (incluyendo el schema Zod, la migración de 14 frontmatters y la limpieza del index). En el primer mes después del refactor, el cliente editó solo —sin pedir ayuda al equipo técnico— 23 veces; antes pedía dos cambios por semana y esperaba 48 horas.

Esta guía arma un catálogo /servicios que crece con la collection, asigna iconos por categoría con un enum cerrado y separa lo que pertenece al sitio (iconos, layout) de lo que pertenece al contenido (texto, pricing). Reporta las decisiones que duelen al principio (por qué los iconos NO van en frontmatter, por qué el price es string y no number) y el árbol de decisión que aprendimos cuando el catálogo cruzó los 30 servicios.

Por qué este patrón existe

La idea de separar “contenido editable” de “presentación del sistema” tiene raíces viejas. Drupal lo llamó “content types” en 2003; WordPress lo formalizó con custom post types en 2010; Sanity y Contentful lo industrializaron como “structured content” a partir de 2017. La diferencia operativa con Content Collections de Astro (estables desde 4.0 en diciembre 2023, content layer rediseñado en Astro 5 con el glob loader explícito) es que el contenido vive en el repo —no en una base de datos remota— y se valida en build-time con Zod, no en runtime con un servidor.

Esto importa por tres razones que aprendimos en producción. Primera: git blame por línea de Markdown da un historial editorial completo (quién cambió qué precio, cuándo, en qué commit). Segunda: previews de PR muestran el catálogo con los cambios sin tocar staging —el revisor ve la card final antes del merge—. Tercera: los rollback son git revert, no restauraciones de snapshot de base de datos. Las tres ventajas son operativas, no técnicas; aparecen al sexto mes del proyecto, no al primero.

Contexto

El antipatrón D3 del proyecto es claro: las entidades repetibles —productos, servicios, artículos, zonas, casos— viven en Content Collections con esquema Zod .strict(), nunca hardcodeadas en .astro. La razón no es purista: es operativa. Cuando el catálogo tiene tres servicios, hardcodear funciona; cuando llega a doce, agregar uno toca cuatro archivos (la página padre, el grid, el array de iconos, el CSS del nuevo color). Cada toque es una oportunidad de romper algo silenciosamente —un key duplicado, un alt vacío, un href que ya no existe—. La collection elimina la mayoría de estas fricciones con una sola idea: el grid lee del filesystem, valida con Zod en build, y emite el HTML una sola vez.

El componente ServiceCard.astro está diseñado para este flujo. Acepta title, description y href (obligatorias) más icon, image, imageAlt, badge, ctaLabel y whatsapp (opcionales). Las tres obligatorias mapean directo a campos de la collection servicios (title, description, slug → href); las opcionales se calculan o derivan en el padre. La pieza que NO vive en la collection son los iconos: meter SVG inline en frontmatter rompe la legibilidad del Markdown y obliga al autor de contenido a copiar/pegar XML. El patrón canónico separa identidad visual (iconos, paleta) que vive en código, de contenido (texto, datos) que vive en Markdown.

El enum cerrado de categorías es la pieza menos visible pero más importante. El schema declara SERVICE_CATEGORIES = ['instalacion', 'mantenimiento', 'general'] y category: z.enum(SERVICE_CATEGORIES) (content.config.ts:79-83,127). Esto rechaza valores inválidos en build —no en producción, no en el cliente—, así el mapa de iconos ICONS[category] siempre tiene una llave válida y el TypeScript te lo verifica. Sin enum, alguien escribe category: "Instalación" con mayúscula, el grid pinta sin icono y nadie se entera hasta que un visitante reporta la card huérfana. El enum es el contrato entre la collection y el grid.

Implementación paso a paso

El schema de la collection servicios declara todos los campos que el grid puede necesitar, sin sobre-modelar. La descripción se valida entre 70 y 280 caracteres, lo mismo que acepta ServiceCard sin romper la rejilla; pricing e includes son opcionales porque la card del grid no los muestra (eso es trabajo de la ficha L4); isHub distingue una página hub de un servicio individual; featured y order controlan jerarquía y orden en el grid:

// src/content.config.ts:121-150 — schema servicios
const servicios = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/servicios' }),
  schema: z
    .object({
      title: z.string().min(10).max(110),
      description: z.string().min(70).max(280),
      category: z.enum(SERVICE_CATEGORIES),     // enum cerrado: instalacion|mantenimiento|general
      image: imagePath,                          // imagen obligatoria
      pricing: z.object({
        min: z.number().optional(),
        max: z.number().optional(),
        unit: z.enum(['pieza','set','evento','hora','dia','mes','servicio']).optional(),
        note: z.string().optional(),
      }).optional(),
      includes: z.array(z.string()).optional(),
      isHub: z.boolean().default(false),         // hub vs servicio individual
      featured: z.boolean().default(false),      // → badge POPULAR en el grid
      order: z.number().default(0),              // orden ASC en el grid
      draft: z.boolean().default(false),         // se filtra antes del map
      ...seoFields,
    })
    .strict(),
})

Un archivo de la collection se ve así. Es el formato real del Markdown que un autor de contenido edita —cero JSX, cero SVG, cero clases CSS—. El category está limitado por el enum, el image validado contra el regex ^/images/, y el resto es texto plano con la disciplina de las cotas Zod:

---
# src/content/servicios/instalacion-demo.md
title: "Servicio de instalación de ejemplo"
description: "Ficha de servicio DEMO en la categoría instalación. Muestra los campos de servicio: pricing transparente, qué incluye y un hero opcional, todo validado por Zod."
category: "instalacion"
image: "/images/servicios/implementacion-deploy-sitio-astro.avif"
pricing:
  min: 0
  max: 0
  unit: "servicio"
  note: "Precios DEMO. Reemplaza por la tarifa real del cliente."
includes:
  - "Diagnóstico inicial de ejemplo"
  - "Instalación documentada paso a paso"
  - "Entrega y verificación final"
featured: true
order: 1
---

El consumidor —la página /servicios/index.astro— lee la collection, filtra drafts, ordena y mapea cada entrada a una ServiceCard. El mapa ICONS traduce el enum de categoría a un SVG string; vive en el frontmatter de la página, no en la collection (los iconos son del sitio, no del contenido). El href se arma con el id del slug; el badge se deriva del flag featured; el icon cae al icono por categoría:

---
// src/pages/servicios/index.astro — catálogo data-driven
import { getCollection } from 'astro:content'
import ServiceCard from '@components/ServiceCard.astro'
import PageLayout from '@layouts/PageLayout.astro'

// SVG inline por categoría — viven en el sitio, no en la collection.
const iconWrench = `<svg viewBox="0 0 24 24" width="28" height="28" fill="none"
  stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
  <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`
const iconShield = `<svg viewBox="0 0 24 24" width="28" height="28" fill="none"
  stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
  <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
  <path d="m9 12 2 2 4-4"/></svg>`
const iconRocket = `<svg viewBox="0 0 24 24" width="28" height="28" fill="none"
  stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
  <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
  <path d="M12 15 9 12a4 4 0 0 1 1.17-2.83l4.93-4.93a1 1 0 0 1 1.41 0l2.25 2.25a1 1 0 0 1 0 1.41l-4.93 4.93A4 4 0 0 1 12 15z"/></svg>`

// Mapa categoría → icono. Llaves alineadas a SERVICE_CATEGORIES del schema.
const ICONS: Record<string, string> = {
  instalacion:   iconWrench,
  mantenimiento: iconShield,
  general:       iconRocket,
}

// getCollection: filtra drafts y ordena por data.order ASC.
const servicios = (await getCollection('servicios', ({ data }) => !data.draft))
  .sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0))
---

<PageLayout
  title="Servicios — Catálogo"
  description="Catálogo de servicios profesionales"
  pageType="page"
  breadcrumbs={[{ label: 'Servicios' }]}
>
  <section class="section">
    <div class="container">
      <div class="grid">
        {servicios.map((s) => (
          <ServiceCard
            title={s.data.title}
            description={s.data.description}
            href={`/servicios/${s.id}`}
            icon={ICONS[s.data.category]}
            image={s.data.isHub ? undefined : s.data.image}
            imageAlt={s.data.title}
            badge={s.data.featured ? 'POPULAR' : undefined}
          />
        ))}
      </div>
    </div>
  </section>
</PageLayout>

<style>
  /* Mobile-first: 1 → 2 → 3. Servicios suelen ser 3-9, no auto-fill. */
  .grid { display: grid; grid-template-columns: 1fr; gap: var(--sp-5); }
  @media (min-width: 640px)  { .grid { grid-template-columns: repeat(2, 1fr); } }
  @media (min-width: 1024px) { .grid { grid-template-columns: repeat(3, 1fr); } }
</style>

El detalle que se escapa es la decisión image=❴s.data.isHub ? undefined : s.data.image❵. Los servicios marcados como hub (página índice de una sub-categoría) se pintan en modo icono (cara natural del catálogo); los servicios individuales con foto del trabajo se pintan en modo vitrina (foto 16:9, badge absoluto). El componente acepta ambos modos sin tocar nada: la prop image reemplaza al icono cuando está presente, y el badge se reposiciona solo (ServiceCard.astro:22-33). La política de mostrar foto o icono se decide en el padre, leyendo el flag isHub del frontmatter; el componente no se entera.

Tabla comparativa

AspectoHardcoded en .astroData-driven desde collection
Agregar un servicioTocar JSX + array de iconos + posiblemente CSSCrear un .md con frontmatter validado
Reordenar el catálogoMover bloques manualmente, riesgo de romper clavesCambiar order: N en el frontmatter
Cambiar el icono de una categoríaEditar el .map en la página, repetir en cada consumoEditar el ICONS una vez
Validar formato del títuloDisciplina del desarrollador (o nada)Zod en build: min(10).max(110) rechaza
Marcar un servicio como destacadoHardcodear el badge en el JSXfeatured: true → badge auto
Detectar campo desconocidoIgnorado silenciosamente.strict() rechaza el build
Agregar SEO por servicioOtro array paralelo de metadatosseoTitle/seoDescription en el frontmatter
Coste de mantenimiento al duplicar el sitioReescribir el grid en cada proyectoCambia el contenido, no el código

El cruce que más se subestima es la última fila. Cuando el sitio se replica para otro cliente —un caso real en agencias—, la versión hardcoded obliga a leer 200 líneas de JSX para entender qué cambiar; la versión data-driven solo necesita reemplazar los .md de src/content/servicios/. El esfuerzo de armar la collection una vez se amortiza al segundo proyecto.

Decision matrix: qué patrón elegir según escala

Cuando el catálogo es chico, todas las opciones funcionan; la pregunta es a qué tamaño y velocidad de cambio el patrón sigue sirviendo. La tabla recoge lo aprendido en agencia tras nueve proyectos:

VolumenVelocidad de cambioEditoresPatrón recomendado
1-3 serviciosEstable (cambios trimestrales)1 (desarrollador)Hardcoded en .astro
4-30 serviciosMensual1 (cliente con git)Markdown + glob loader
4-30 serviciosSemanal1-2 (cliente sin git)Markdown + Decap CMS local
30-150 serviciosDiario2-5 (varios roles)Markdown + Sanity/Contentful
150+ serviciosTiempo real (stock)5+ + ERPAPI externa con SSR/ISR
Multilenguaje 4+Mensual2-3 (traductores)i18n collections o headless CMS

La trampa que se repite: saltar a Sanity/Contentful en el segundo proyecto «porque escalará». El costo de Sanity ($99-499/mes desde 2025), la latencia de fetch (200-400 ms en build) y el lock-in del API no se pagan hasta que el equipo editorial tiene cuello de botella —que casi nunca pasa con catálogos de menos de 50 servicios—. La decisión correcta suele ser la conservadora: empezar en Markdown, migrar cuando duela.

Edge cases y debugging

Cinco situaciones que nos costaron tiempo:

Categoría nueva sin icono asignado. Si agregas 'auditoria' a SERVICE_CATEGORIES pero olvidas la entrada en ICONS, el TypeScript del padre te avisa con Object is possibly 'undefined' solo si tienes strict: true y noUncheckedIndexedAccess: true en tsconfig.json. Sin la segunda flag, el bug pasa silencioso y la card del nuevo servicio se pinta sin icono. Receta: activa noUncheckedIndexedAccess en tsconfig.json desde el día uno; el costo es agregar ?? ICONS.general en algunos casts, el beneficio es no perder iconos en producción.

Slug con mayúscula o tilde. Astro 6 normaliza el id del slug en getCollection() (a kebab-case, sin tildes), pero el image del frontmatter no se normaliza. Si tu .md se llama Implementación.md, el id será implementacion pero la URL del archivo en /src/content/servicios/ mantiene la tilde y Cloudflare Pages puede servir 404 al imagen relativa. Renombra los archivos a kebab-case sin tildes desde el principio y ahorras horas de debug.

draft: true ignorado en getCollection(). El filtro de drafts —el callback que recibe data y devuelve !data.draft— se aplica solo si lo pasas; sin él, los drafts aparecen en producción. Lo aprendimos cuando un servicio borrador con título «PRUEBA - NO PUBLICAR» apareció dos días en la home. Regla operativa: el getCollection() SIEMPRE lleva el filtro de drafts, aunque no haya drafts hoy.

Reorden de order con números secuenciales. Si todos los servicios tienen order: 1, 2, 3, ..., 14, intercalar uno nuevo en posición 5 obliga a renumerar 10 archivos. Patrón que escala: usa saltos de 10 (10, 20, 30, ...) y deja 15, 25 libres para intercalar sin renombrar. Es el truco viejo de BASIC line numbers; sigue funcionando.

Content layer cache stale. Astro 6 cachea el resultado de getCollection() entre builds incrementales. Si cambias el schema en content.config.ts, debes correr npx astro sync antes del build para regenerar tipos. Síntoma del cache stale: campo nuevo en el schema, el TypeScript lo conoce, el .md lo declara, pero getCollection() lo devuelve undefined. Solución: borra .astro/ y rebuild.

Performance: medición real

Lighthouse 12.0.2 sobre /servicios con 14 servicios reales del cliente, Pixel 5 emulado, throttling Slow 4G, mediana de tres corridas:

ConfiguraciónLCPCLSINPTiempo de build
Hardcoded en .astro2.3 s0.0180 ms3.8 s
Markdown + glob loader2.3 s0.0180 ms4.2 s
Markdown + sin width/height2.7 s0.1880 ms4.2 s
Markdown + 50 cards2.6 s0.0292 ms5.1 s

El runtime no cambia entre hardcoded y data-driven: el HTML resultante es idéntico, los KB en wire son idénticos, las imágenes son las mismas. La única diferencia medible es el build time (+0.4 s por leer el glob + validar Zod), aceptable hasta varios cientos de items. El CLS de 0.18 sin width/height en la imagen es el recordatorio anual: la card NO funciona sin las dimensiones explícitas en ‹img›.

Casos donde NO usar este patrón

Una landing con un solo servicio destacado. Si el sitio vende UN servicio (consultoría, agencia boutique, profesional independiente), un Markdown loader es overkill. Hardcodear el ‹ServiceCard /› en la home con sus props es suficiente y más legible. La regla: collections cuando hay 4+ entidades comparables, no para uno solo.

Servicios con configuración por cliente (multitenant). Si cada cliente del SaaS tiene su propio catálogo de servicios, no estás haciendo un sitio: estás haciendo una aplicación. Las collections viven en build-time; un catálogo multitenant pide base de datos y SSR. Astro lo permite (output: 'server'), pero el patrón cambia radicalmente —getCollection() deja de funcionar como aquí—.

Sincronización con un ERP de servicios en tiempo real. Cuando el catálogo refleja disponibilidad de un calendario o stock de horarios, la fuente de verdad es el ERP, no el Markdown. Usa API externa con fetch en server route o ISR (revalidación cada X minutos). Astro 6 soporta hybrid output; mezcla rutas estáticas (/servicios/[slug]) con server routes (/api/disponibilidad/[slug]).

Patrones avanzados

Iconos por categoría con fallback explícito. El ICONS[s.data.category] funciona porque el enum garantiza que s.data.category es una de las llaves esperadas. Aun así, conviene el patrón ICONS[category] ?? ICONS.general cuando se agregan categorías nuevas al enum y los iconos no se actualizan de inmediato —el fallback evita una card sin visual mientras el equipo termina el SVG—. La regla práctica: el fallback es a la categoría general, nunca a null o a un placeholder gris, porque eso señaliza un bug al visitante (la categoría existe pero falta el icono).

Hub vs servicio individual: dos formas de pintar la misma card. Un hub (isHub: true) es una página índice de sub-categoría —«Mantenimiento» que agrupa cinco servicios específicos—; un servicio individual es la ficha L4 con pricing y includes. En el grid principal, los hubs se pintan en modo icono (cara natural, cero foto, comunica «sección»); los servicios individuales con foto del trabajo se pintan en modo vitrina (16:9, refuerza el resultado). El consumidor decide con un ternario; el componente no necesita una prop nueva. Esto separa la decisión editorial (cuándo es hub) del componente (cómo se pinta cada modo).

Featured + order: priorización sin tocar JSX. El featured: true agrega el badge POPULAR en el grid; el order: N controla la posición. Combinados, dejan al equipo editorial decidir la jerarquía sin pedirle al desarrollador que mueva bloques. La convención del proyecto: order: 0 para el servicio destacado, order: 1-9 para el catálogo principal, order: 99+ para servicios secundarios. El .sort() los respeta y el padre nunca toca el orden. Cuando se quiere despromover un servicio, basta cambiar el número; el grid se repinta en el siguiente build.

Cross-link tipado con reference(). El schema declara relatedServices: z.array(reference('servicios')).optional() (content.config.ts:140). Cada servicio puede declarar sus relacionados por slug —el Zod verifica que los slugs existan en la collection en build—. La ficha L4 los hidrata con getEntry() y los pinta vía RelatedLinks (no vía ServiceCard —evita el bucle «card de catálogo dentro de ficha de catálogo»—). El cross-link es texto plano en el .md, no en JSX:

---
title: "Consultoría de seguridad"
relatedServices:
  - implementacion-llave-en-mano
  - mantenimiento-preventivo
---

Checklist de implementación

  • Confirmar que el SERVICE_CATEGORIES del schema coincide con las categorías reales del cliente (sin duplicados tipográficos)
  • Validar que TODOS los .md de src/content/servicios/ tienen category dentro del enum y image bajo /images/
  • Verificar que el mapa ICONS cubre cada llave del enum (TypeScript te avisa si falta)
  • Probar el .sort con tres servicios con distintos order: el grid debe pintarlos ASC
  • Validar que draft: true los excluye del grid (probar con un servicio de prueba)
  • Confirmar que el grid es mobile-first (1 columna en móvil, 2 a ≥640px, 3 a ≥1024px) —no auto-fill—
  • Verificar que los SVG usan stroke="currentColor" para heredar el color de marca (no stroke="#000")
  • Probar build con un campo desconocido en un .md (hero_image: por ejemplo): Zod .strict() debe rechazarlo

Preguntas frecuentes

¿Por qué los iconos viven en código y no en el frontmatter?

Porque los iconos son identidad visual del sitio, no contenido. Meterlos en frontmatter rompe la legibilidad del Markdown (cada archivo arrancaría con 8 líneas de XML), obliga al autor de contenido a entender SVG y, peor, acopla el contenido al diseño actual —si cambias el ancho del stroke, hay que editar 30 archivos—. El mapa ICONS en la página padre es la frontera correcta: el contenido declara su categoría con una palabra, el sitio decide cómo pintarla.

¿Cómo agrego un servicio nuevo sin tocar código?

Crear src/content/servicios/‹slug›.md con el frontmatter requerido (title, description, category, image) y el cuerpo Markdown opcional. Zod valida en npm run build; si pasa, el grid lo pinta en el siguiente despliegue. El único caso donde tocas código es cuando agregas una categoría NUEVA al enum: editas SERVICE_CATEGORIES en content.config.ts y ICONS en la página padre. Cada cambio toca un solo archivo.

¿Y si tengo 50 servicios? ¿No es lento renderizar todos?

No, porque el catálogo es estático: Astro lo compila en build y sirve HTML plano. 50 cards son ~150 KB de HTML —menos que una foto grande—. El cuello de botella es siempre las imágenes, no el HTML; la solución es paginar visualmente con un patrón móvil (scroll horizontal con snap, carrusel con dots, ver el módulo /modulos/service-card), no quitar cards del grid. Si el catálogo crece a 200+, conviene partirlo por sub-categoría (rutas tipo /servicios/consultoria/, /servicios/implementacion/) y reusar el mismo grid en cada hub.

¿getCollection se ejecuta en cada request o solo en build?

Solo en build. Astro es SSG por defecto: getCollection corre una vez, en npm run build, y el HTML resultante se sirve estático desde el CDN (Cloudflare Pages, Vercel, Netlify). En desarrollo se recarga con HMR cuando editas un .md. Esto significa que cambiar un servicio en producción requiere un rebuild —que en Cloudflare Pages dispara automáticamente al hacer push—. Para sitios con miles de cambios diarios, conviene SSR o ISR; para un catálogo de servicios, SSG sobra.

¿Cómo combino este grid con el CTA dual (WhatsApp en algunas cards)?

Agregando un campo nuevo al schema o derivando del existente. La opción más limpia: agregar whatsappOnly: z.boolean().default(false) a la collection, y en el .map derivar el href con un ternario —cuando el servicio es whatsappOnly, el href apunta a waUrl(WA_MESSAGES.cotizar); en caso contrario, a /servicios/[slug]—. La opción sin tocar schema: usar featured para señalar los consultivos y derivar el modo en el padre. Ver «Service card con CTA dual: ficha vs WhatsApp» para la decisión editorial completa.

¿Cómo se compara con Shopify Sections o con los Bloques de Sanity?

Shopify Sections es excelente para temas de tienda: cada section es un componente Liquid con su schema declarado, y el merchant arma la home arrastrando sections. La diferencia con nuestro patrón es que Sections es para LAYOUT (qué bloques entran en la home), no para CONTENIDO (qué servicios existen); aquí estamos hablando de catálogo de entidades, no de constructor visual de página. Sanity Blocks (Portable Text) son para texto rico con embeds; sirve para el cuerpo de un servicio (cuando lo cargas como ficha L4 con MDX), no para listar servicios. La frontera es clara: Sections para arrastrar bloques visuales; Markdown + Zod para listar entidades estructuradas.

¿Y si necesito ordenar el catálogo por popularidad real (clics, no order manual)?

Tres opciones, de menos a más infra. Opción A: mantén order manual y actualízalo trimestralmente con datos de analytics (revisión humana, control editorial). Opción B: agrega popularity: number al schema y un script de build que lo lea de un JSON exportado de Plausible/GA cada semana (CI corre el script, el merge respeta el orden calculado). Opción C: SSR con fetch a un endpoint que devuelva el ranking en vivo (Astro server route, latencia 50-200 ms). Para sitios donde el orden cambia poco, opción A; para los que cambian con tendencias, opción B; para tiempo real, opción C —raramente necesaria—.

Un catálogo data-driven no es más rápido de armar que uno hardcoded —los dos toman tarde la primera vez—. La diferencia aparece al sexto cambio: el data-driven cuesta un archivo Markdown, el hardcoded cuesta una tarde de buscar y reemplazar. Y la diferencia se vuelve abismal cuando el sitio se replica para otro cliente: la collection es portable, el JSX no. La inversión inicial se amortiza siempre —no es cuestión de si, sino de cuándo—.

Sigue leyendo

¿Listo para dar el siguiente paso?

Cuéntanos qué necesitas y te respondemos hoy mismo.

¿Necesitas ayuda?