Trata el contenido como código: un pipeline de siembra de Git a Postgres
Muestra cómo reemplazar un CMS por JSON controlado con Git, validación con Zod y operaciones upsert de Prisma para introducir de forma segura contenido estructurado en Postgres.
Imagínese crear una aplicación de cuestionarios con preguntas de opción múltiple, fragmentos de código incrustados, explicaciones y calificaciones de dificultad. Este tipo de contenido cambia con bastante frecuencia, pero no de una manera que exija ediciones en tiempo real a horas inusuales.
La opción obvia inicial podría ser un CMS como Sanity o Strapi. Pero antes de optar por uno, es útil detallar los requisitos reales:
- Un historial completo de todos los cambios realizados en el contenido
- La posibilidad de revisar los cambios antes de que lleguen a producción
- Una validación que interrumpa el proceso de compilación en lugar de afectar la producción
- No se necesita infraestructura adicional para un MVP
- Un flujo de trabajo que refleje la forma en que ya envía código
Dadas esas necesidades, almacenar el contenido directamente en Git tiene más sentido que añadir un CMS.
El enfoque utiliza archivos JSON, esquemas Zod, un script de semilla Prisma y PostgreSQL para el almacenamiento en tiempo de ejecución. El flujo es el siguiente:
JSON → Zod → Semilla (upsert) → Postgres → API
Se hace intencionadamente poco interesante. Un flujo aburrido es uno en el que se puede confiar.
¿Por qué no “simplemente usar un CMS”?
Las plataformas CMS demuestran su utilidad cuando personas no técnicas publican contenido a diario, cuando se necesitan estados de borrador y roles de permisos, o cuando la estructura de los datos cambia de forma impredecible.
Pero para contenido estructurado que redactan los propios ingenieros — bancos de preguntas, datos de semilla, flujos de onboarding, niveles de precios — incorporar un CMS suele añadir:
- Otro servicio que hay que alojar y proteger
- Otro esquema con el que se debe mantener la coherencia en la aplicación
- Otra brecha por la que pueden colarse datos inválidos
Lo que realmente se necesitaba no era una plataforma de publicación, sino un pipeline de contenido:
autor → validar → revisar → desplegar → sembrar → servir
Git ya gestiona los primeros cuatro pasos. La única pieza que faltaba era una forma fiable de importar contenido a la base de datos.
La arquitectura
content/
questions/
javascript/
easy.json
medium.json
hard.json
html/
easy.json
packages/db/
prisma/schema.prisma
src/seed.ts ← read, validate, upsert
packages/shared/
schemas/question.ts ← Zod contract
scripts/
validate-content.ts ← CI, no DB required
| Layer | Responsibility |
|--------------|-------------------------------------|
| JSON | Human-editable source of truth |
| Zod | Runtime validation + inferred types |
| Prisma seed | Idempotent import into the database |
La regla innegociable aquí: su aplicación nunca debe leer archivos JSON en tiempo de ejecución en producción. JSON solo existe como entrada en el momento del despliegue. Postgres sigue siendo la capa que realmente procesa las consultas.
Esto le brinda los beneficios del flujo de trabajo de Git sin convertir su base de datos en algo que solo proxye archivos.
Paso 1: Comience con Zod, no con JSON
Antes de escribir cualquier contenido, defina el contrato que debe cumplir.
import { z } from 'zod';
export enum Topic {
JavaScript = 'JAVASCRIPT',
HTML = 'HTML',
TypeScript = 'TYPESCRIPT',
}
export enum Difficulty {
Easy = 'EASY',
Medium = 'MEDIUM',
Hard = 'HARD',
}
export const questionSchema = z
.object({
id: z.string().min(1), // stable slug: js-closures-loop-001
topic: z.nativeEnum(Topic),
subtopic: z.string().min(1),
difficulty: z.nativeEnum(Difficulty),
text: z.string().min(1),
codeSnippet: z.string().nullable().optional(),
options: z.array(z.string().min(1)).min(2),
correctOptionIndex: z.number().int().min(0),
explanation: z.string().min(1),
})
.refine((q) => q.correctOptionIndex < q.options.length, {
message: 'correctOptionIndex must point to a valid option',
});
export const questionsFileSchema = z.array(questionSchema);
export type QuestionContent = z.infer<typeof questionSchema>;
Algunas decisiones de diseño intencionales destacan:
- El campo
idse encuentra dentro del propio archivo de contenido; esto es lo que hace seguras las nuevas implementaciones. Las claves primarias generadas por la base de datos son solo detalles de implementación; un identificador estable comojs-closures-loop-001es lo que realmente determina el progreso guardado por el usuario. - Se utilizan enums en lugar de cadenas simples, lo que evita que existan diferencias de mayúsculas y minúsculas como
js,JSojavascripten diferentes archivos. .refine()gestiona las reglas de validación que abarcan varios campos; algo que una simple restricciónmin()no puede expresar, como mantener un índice de respuesta dentro de los límites permitidos.- Cada esquema valida todo un archivo JSON como un único array, y no registro por registro.
El resultado es que su contenido cuenta con un contrato vinculante, y no solo con una convención documentada en algún lugar que nadie lee.
Paso 2: Crear JSON aburrido
[
{
"id": "js-closures-loop-001",
"topic": "JAVASCRIPT",
"subtopic": "closures",
"difficulty": "MEDIUM",
"text": "What will this code log?",
"codeSnippet": "for (var i = 0; i < 3; i++) {\n setTimeout(() => console.log(i), 0);\n}",
"options": ["0 1 2", "3 3 3", "undefined undefined undefined", "0 0 0"],
"correctOptionIndex": 1,
"explanation": "`var` is function-scoped, so by the time the timeouts run, `i` is 3."
}
]
El formato es deliberadamente sencillo: tipos inequívocos, diferencias claras y sin debates sobre casos límite de análisis. Si los autores de contenido eventualmente quieren escribir en Markdown o YAML, puede generar JSON a partir de esos formatos en un paso previo a la compilación; el propio script inicial debe mantenerse simple y predecible.
En cuanto al texto enriquecido, guarde la cadena de origen sin procesar en la base de datos — ya sea en Markdown, texto plano o cualquier formato con el que se sientan cómodos los autores — y réstelo donde la aplicación lo muestre. Renderizarlo como HTML en el momento inicial lo ata a una biblioteca de renderizado específica y genera problemas de migración si decide cambiarla. Mantenga el contenido almacenado tal como está, y réstelo solo donde sea realmente necesario.
Paso 3: Sembrar con upserts, no con borrar todo
Mientras aún no tengas usuarios reales, está bien borrar la tabla con deleteMany y volver a llenarla mediante createMany. Una vez que los registros de usuarios comiencen a hacer referencia a filas de contenido, cambia a upserts basados en un identificador estable.
Un modelo Prisma simplificado:
model Question {
id String @id @default(cuid())
externalId String @unique
topic Topic
subtopic String
difficulty Difficulty
text String
codeSnippet String?
options String[]
correctOptionIndex Int
explanation String
}
El script de sembrado:
import fs from 'node:fs/promises';
import path from 'node:path';
import { PrismaClient } from '@prisma/client';
import { questionsFileSchema, type QuestionContent } from '@myapp/shared';
const prisma = new PrismaClient();
const CONTENT_DIR = path.resolve(__dirname, '../../../content/questions');
async function loadQuestionsFromDisk(): Promise<QuestionContent[]> {
const rows: QuestionContent[] = [];
const topicDirs = await fs.readdir(CONTENT_DIR, { withFileTypes: true });
for (const topicDir of topicDirs) {
if (!topicDir.isDirectory()) continue;
const dirPath = path.join(CONTENT_DIR, topicDir.name);
const files = (await fs.readdir(dirPath)).filter((f) => f.endsWith('.json'));
for (const file of files) {
const raw = await fs.readFile(path.join(dirPath, file), 'utf8');
const questions = questionsFileSchema.parse(JSON.parse(raw));
rows.push(...questions);
}
}
return rows;
}
async function main() {
const questions = await loadQuestionsFromDisk();
let created = 0;
let updated = 0;
for (const q of questions) {
const data = {
topic: q.topic,
subtopic: q.subtopic,
difficulty: q.difficulty,
text: q.text,
codeSnippet: q.codeSnippet ?? null,
options: q.options,
correctOptionIndex: q.correctOptionIndex,
explanation: q.explanation,
};
const existing = await prisma.question.findUnique({
where: { externalId: q.id },
});
if (existing) {
await prisma.question.update({ where: { externalId: q.id }, data });
updated++;
} else {
await prisma.question.create({ data: { externalId: q.id, ...data } });
created++;
}
}
console.log(`Seeded: ${created} created, ${updated} updated`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
Tres detalles son importantes aquí:
questionsFileSchema.parse(...)impide que el JSON mal formado llegue a la base de datos.findUnique({ where: { externalId: q.id } })realiza la búsqueda con el ID propio del contenido, no con la clave primaria interna de la base de datos.- Al actualizar en lugar de borrar, se conservan las respuestas o el progreso que el usuario ya tenga asociados a ese registro.
Puede preguntarse por qué no simplemente llamar a createMany({ skipDuplicates: true }). Esa opción evita filas duplicadas, pero deja el texto obsoleto sin modificar cuando cambia el contenido. Para sincronizar el contenido en el momento del despliegue, escribir inserciones explícitas con actualizaciones es más transparente y funciona en todos los motores de base de datos. Solo recurra a una instrucción INSERT ... ON CONFLICT DO UPDATE sin procesamiento adicional después de que un análisis demuestre que realmente necesita esa velocidad.
Paso 4: Fallar rápidamente en CI
No necesita una base de datos para validar el contenido. Ejecute esto en cada solicitud de integración que modifique la carpeta content/:
// scripts/validate-content.ts
import fs from 'node:fs/promises';
import { glob } from 'glob';
import { questionsFileSchema } from '@myapp/shared';
const files = await glob('content/**/*.json');
let failed = 0;
for (const file of files) {
try {
const raw = await fs.readFile(file, 'utf8');
questionsFileSchema.parse(JSON.parse(raw));
console.log(`✓ ${file}`);
} catch (err) {
console.error(`✗ ${file}`, err);
failed++;
}
}
process.exit(failed > 0 ? 1 : 0);
# .github/workflows/validate-content.yml
name: Validate content
on:
pull_request:
paths: ['content/**']
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- run: pnpm install
- run: pnpm tsx scripts/validate-content.ts
El contenido defectuoso hace que la solicitud de integración falle; nunca se envía en silencio.
El despliegue se realiza de la siguiente manera:
npx prisma migrate deploy
npx prisma db seed
Las actualizaciones de contenido se convierten en un lanzamiento normal: fusión, migración e inicialización.
Paso 5: Cuando cambie su esquema
Tarde o temprano, tu esquema Zod evolucionará. Quizás agregues un campo hint. Quizás cambies el nombre de codeSnippet a code. Quizás options pase de ser un array de cadenas a un array de objetos.
Cualquiera que sea el cambio, tus archivos JSON existentes dejarán de coincidir con el esquema. La script inicial fallará. No entres en pánico: trata los cambios en el esquema del contenido exactamente como lo harías con las migraciones de base de datos.
Comienza versionando el esquema en sí:
// schemas/question.v1.ts — old shape
// schemas/question.v2.ts — new shape
// schemas/question.ts — export latest as `questionSchema`
Luego ejecuta un codemod único sobre los archivos del disco:
// scripts/codemod-questions-v2.ts
import fs from 'node:fs/promises';
import { glob } from 'glob';
import { z } from 'zod';
import { questionSchemaV1 } from '@myapp/shared/schemas/question.v1';
const v1File = z.array(questionSchemaV1);
for (const file of await glob('content/**/*.json')) {
const old = v1File.parse(JSON.parse(await fs.readFile(file, 'utf8')));
const next = old.map((q) => ({
...q,
hint: null,
code: q.codeSnippet,
codeSnippet: undefined,
}));
await fs.writeFile(file, JSON.stringify(next, null, 2));
}
Ejecuta el codemod, guarda los JSON actualizados, dirige la script inicial hacia el esquema v2 y despliega. Ese es todo el proceso: codemod, guardar cambios, actualizar y publicar.
Es la misma disciplina que ya aplicas con prisma migrate, y da los mismos resultados.
Qué ganas
La revisión de contenido se convierte en una revisión de código. “¿Es esta explicación realmente correcta?” pasa a ser una solicitud de integración con una diferencia visible en lugar de un mensaje en Slack.
Obtienes validación de forma gratuita, sin necesidad de crear un analizador personalizado. Zod detecta errores tipográficos en los enumerados, campos obligatorios faltantes e índices fuera del rango; el propio esquema funciona como analizador.
Saltas por completo la complejidad de un CMS. No hay panel de administración que construir, ni autenticación separada para editores, ni destino de despliegue adicional que mantener, especialmente cuando las personas que escriben el contenido son los mismos ingenieros que envían el código.
Los entornos siguen siendo reproducibles. Clona el repositorio, ejecuta las migraciones y la inicialización, y obtendrás siempre un banco de preguntas idéntico en cada máquina.
Los cambios en masa se convierten en scripts en lugar de clics manuales. Cambiar la etiqueta de cuarenta preguntas de MEDIUM a HARD se hace con una orden sed de una sola línea o un script breve, en lugar de realizar cuarenta ediciones individuales a través de la interfaz de administración.
El progreso del usuario se mantiene tras las actualizaciones. Dado que utiliza identificadores estables junto con operaciones de inserción o actualización, puede corregir un error tipográfico en una pregunta sin interrumpir el enlace con las respuestas que los usuarios ya enviaron.
Qué se sacrifica
Vale la pena ser transparente respecto a los compromisos:
- Las personas que no son ingenieros generalmente no disfrutarán trabajando con Git. Si los editores no técnicos necesitan contribuir, será necesario contar con una ruta de importación en CSV, una herramienta interna o un CMS sin interfaz gráfica que exporte a JSON.
- No existe un flujo de trabajo integrado entre borradores y versiones publicadas. Lo que se encuentra en
maines lo que se utiliza como base. Si necesita borradores, tendrá que gestionarlos mediante ramas.
questions.json.Cuándo usarlo
Este patrón es adecuado cuando su contenido está estructurado y repetitivo, su equipo de ingeniería es pequeño, se encuentra entre la fase MVP y la producción inicial, los cambios en el contenido deben ser rastreables, y ya está utilizando Prisma.
No funciona bien cuando hay editores no técnicos que publican a diario, cadenas de aprobación complejas, una gran dependencia de los medios o la necesidad de editar contenido en vivo directamente en producción.
Conclusión
Lo que realmente se necesitaba no era un CMS, sino contenido sometido al mismo estándar que el código: versionado, validado, revisado e implementado a través de una infraestructura ya confiable.
JSON en Git es la fuente de verdad. Zod es el controlador de acceso. El paso seed de Prisma es el cargador. Los IDs estables son lo que mantiene intactos los datos de los usuarios a medida que el contenido cambia debajo de ellos.
Si aún no hay usuarios, comience con un método sencillo de inicialización mediante borrado y recarga. Pásese a las operaciones upsert en cuanto los datos de progreso comiencen a ser importantes. Añada validación CI antes de que un error tipográfico en un enum llegue a la fase de pruebas. Versione su esquema de contenido antes de introducir su segunda modificación que cause problemas.
Toda la configuración está diseñada intencionadamente para no ser llamativa, y ese es precisamente el objetivo. Reserve la emoción para el producto en sí, no para el sistema encargado de almacenar sus preguntas.
Leer más
- Errores comunes en JavaScript y TypeScript que destruyen el código silenciosamente — Explica los problemas sutiles en JavaScript y TypeScript, desde comparaciones con NaN hasta cuestiones de sincronización asíncrona y coerción de tipos, que causan errores a pesar de parecer correctos.
any de TypeScript: Seis patrones seguros de tipos para casos comunes — Aprenda alternativas prácticas y seguras de tipo a any de TypeScript, incluyendo valores desconocidos, genericos, uniones discriminadas y verificaciones exhaustivas, para manejar datos impredecibles.