02Criterio6 min

Mi plantilla es una dependencia más, y la traté como si no lo fuera

Arranco cada proyecto clonando mi propia plantilla. Me ha ahorrado semanas, y también me ha metido el mismo bug en todos los clones durante meses sin que me enterase.

Por Salva Castells

Imagen de portada: Park taeho, CC BY-SA 4.0.
En este post

Durante meses, todos mis proyectos nacieron con una línea en el root layout que les tiraba páginas del índice de Google:

alternates: { canonical: BASE_URL },

Cualquier página que no sobreescribiera su canonical le decía a Google que en realidad era la home. Y se caía del índice. No fallaba nada, no había un solo warning. Estaba en la plantilla de la que clono cada proyecto, así que estaba en todos.

Yo no empiezo de cero nunca. Mi primer commit se llama "Initial commit" y ya trae auth, Postgres, pagos, i18n y un panel de admin. El segundo, muchas veces, se llama "remove useless things". Llevo unos diez proyectos así, y el canonical fue lo que me hizo ver algo que debería haber sido obvio: la plantilla es una dependencia. Solo que no tiene versiones, ni changelog, ni npm update, y se copia en vez de instalarse. Es la dependencia peor mantenida que tengo, y la escribí yo.

Por qué sigo haciéndolo

Que conste que no vengo a quejarme de las plantillas. Un SaaS de gestión de incidencias para una empresa de transporte pasó de cero a producción en tres días. El primero, clonar y quitar lo que sobraba. El segundo, esquema de incidencias, subida de imágenes, un drawer de detalle y caché de listados. El tercero, la landing. Nadie tocó un login en esos tres días.

Lo que te ahorras con la plantilla va más allá de escribir el login. Te ahorras el mes de decisiones pequeñas que vienen detrás: dónde vive la sesión, cómo se protege una ruta nueva, qué pasa cuando caduca la cookie, cómo se declara un idioma. Todo eso ya lo decidí una vez y no quiero volver a decidirlo.

Tampoco lo que se queda es muy sofisticado. Auth con una sola puerta, un layout.tsx en el árbol privado en lugar de un requireSession() que cada página tiene que acordarse de llamar; así una página nueva queda protegida por la carpeta donde la metes. Drizzle con migraciones versionadas. i18n desde el minuto cero, porque meterlo luego es tocar cada string del proyecto y meterlo al principio es una tarde. Y un admin mínimo: usuarios y cuatro métricas.

Una cosa de Drizzle que me costó un rato entender, por si le sirve a alguien que clone plantillas: si los snapshots de la carpeta de migraciones se desincronizan, drizzle-kit generate compara contra una base vacía y te vuelve a emitir CREATE TABLE de todo el esquema. La solución es reconstruir el snapshot inicial hasta que generate diga que no hay cambios. Al principio pensé que me había cargado algo de la base de datos.

Lo que borras en el segundo commit es un bug contra la plantilla

Esto es lo más útil que he sacado de mirar el historial de varios proyectos seguidos. Si borras lo mismo en dos clones distintos, el fallo está en la plantilla, no en esos dos proyectos.

Con Stripe se vio clarísimo. En una app de fichajes, las rutas checkout, portal y webhook se fueron enteras el mismo día del commit inicial, 338 líneas. En una plantilla de visor interno, lo mismo. Dos proyectos, un año de diferencia, el mismo borrado. No lo quité de la plantilla (otros proyectos sí cobran), lo puse detrás de una bandera:

export const USE_STRIPE = process.env.USE_STRIPE === "true";

export async function POST(request: NextRequest) {
  if (!USE_STRIPE) {
    return NextResponse.json({ error: "Stripe is not enabled" }, { status: 501 });
  }

Y aquí la primera versión tenía su trampa. Con la bandera apagada, el cliente de Stripe era null as unknown as Stripe. El compilador tan contento, y el primer uso descuidado petaba con un TypeError de esos que no te dicen nada. Ahora es un Proxy que lanza un error explicando qué pasa, más o menos así:

export const stripe: Stripe = USE_STRIPE
  ? new Stripe(process.env.STRIPE_SECRET_KEY!)
  : new Proxy({} as Stripe, {
      get() {
        throw new Error("Stripe está desactivado (USE_STRIPE=false)");
      },
    });

El segundo recorte repetido fue RLS. Las tablas nacían con crudPolicy({ role: authenticatedRole, read: authUid(table.userId) }) de drizzle-orm/neon, y acabé quitando todas las políticas. Sé que esto va a levantar alguna ceja, así que me explico: la app hace todos sus accesos desde el servidor con una conexión de admin, así que las políticas no se evaluaban nunca. Estaban ahí dando una sensación de seguridad que no existía. Tener dos capas de autorización cuando solo una está viva me parece peor que tener una, porque nadie sabe cuál manda y todo el mundo asume que la otra le cubre. Si tu app sí consulta con el rol del usuario, RLS tiene todo el sentido del mundo. La mía no lo hacía.

El tercero fue el admin. La plantilla del equipo fue acumulando meses de panel de administración y gestión de usuarios hasta que un commit lo eliminó entero. Casi ningún hijo lo usaba y todos cargaban con él.

Los bugs que heredas sin enterarte

Volviendo al canonical. La regla que saqué de ahí: que falte el canonical es inofensivo, y un canonical equivocado no lo es. Desde entonces el layout compartido no pone ninguno y cada ruta declara el suyo.

Pero no fue el único bug que viajó de clon en clon:

  • El .gitignore solo ignoraba .env.local. Y lo primero que haces en un clon es cp .env.example .env. El primer commit se llevaba las claves puestas.
  • BASE_URL estaba hardcodeado al dominio de la plantilla. De ahí colgaban metadataBase, los canonicals, el hreflang, las URLs de OG, el sitemap y la validación de mismo origen del checkout de Stripe. En cualquier clon, todo eso apuntaba al sitio equivocado sin un solo error en consola.
  • La detección de idioma era acceptLanguage.includes(locale). Ignora el orden de preferencia que manda el navegador y acierta por coincidencia de substring, porque "sl-SI" contiene "sl". Funcionaba lo bastante bien como para que nadie lo mirase.

Los tres tienen algo en común: ninguno rompe nada visible. Una plantilla nunca te va a colar un bug que peta en local, porque ese lo arreglas el primer día. Te cuela los silenciosos, y te los cuela multiplicados.

Cómo la mantengo ahora

Los arreglos suben del hijo a la madre. Cuando un proyecto descubre algo, la corrección va a la plantilla y el siguiente ya nace con ella. Esto solo aguanta si el arreglo sube con un test que falle al reintroducir el bug. Si no, el siguiente refactor "simplifica" la regla y vuelves a estar donde empezaste.

El último endurecimiento grande fue una PR de 39 puntos: idempotencia del webhook de Stripe con una tabla processed_stripe_events, la identidad siempre sacada de la sesión y nunca pasada por argumento en las server actions, userId en el WHERE de cada lectura de feedback, validación de mismo origen en las URLs de retorno del checkout. Menos 11.421 líneas y más 7.268. Casi todo lo borrado eran tutoriales que describían una estructura que ya no existía, que es otra forma de bug silencioso: documentación que te manda a sitios que ya no están.

El proyecto siguiente empezó con un commit que se llama chore: inicializa desde la plantilla endurecida, y me hizo bastante ilusión escribirlo.

Lo que no tengo resuelto son los hijos que ya existen. Una dependencia normal la actualizas y listo. Una plantilla copiada no tiene ese camino de vuelta, así que cada arreglo que subo a la madre es también una pregunta incómoda sobre cuántos proyectos antiguos siguen teniendo el bug. Llevarlo a mano a cada uno no escala, y convertir la plantilla en un paquete de verdad me parece matar lo que la hace cómoda. Ni idea de cuál es el punto medio. Si alguien lo tiene resuelto, me interesa.