@squaads/cms-next
v0.5.0
Published
Adaptador de Next.js para el CMS de Squaads. Hacé editable el contenido que vive en tu **código** —una landing a medida, unas páginas legales, una sección fija— sin base de datos ni panel propio: el desarrollador maqueta y anota la página una vez, y el cl
Downloads
67
Readme
@squaads/cms-next
Adaptador de Next.js para el CMS de Squaads. Hacé editable el contenido que vive en tu código —una landing a medida, unas páginas legales, una sección fija— sin base de datos ni panel propio: el desarrollador maqueta y anota la página una vez, y el cliente cambia textos, listas, imágenes y enlaces desde un panel. El cambio llega al HTML que entrega el servidor, así que lo indexa un buscador.
Familia: @squaads/cms-client (agnóstico), @squaads/cms-studio (editor React) y
este adaptador (Next). Requiere Next 15+ con App Router (probado en 16.3).
Qué resuelve y cuándo NO usarlo
El CMS gobierna contenido escrito en código que el cliente solo retoca. No es un CMS de páginas, ni un editor de datos, ni un reemplazo de otro CMS.
| Necesitás… | Herramienta | | --- | --- | | Que el cliente retoque textos/imágenes de una página que vos maquetaste | este CMS ✅ | | Que el cliente cree y ordene páginas nuevas | Un CMS de páginas (con base de datos) | | Editar datos de negocio (precios, stock, un catálogo) | Tu base de datos / tu API | | Subir imágenes desde el editor | Una biblioteca de medios (ver Límites) | | Editar contenido que ya administra otro sistema | Ese sistema — no lo dupliques |
Instalación desde cero
Partís de un proyecto Next App Router estándar (con el alias @/* → src/*,
el que trae create-next-app). Si arrancás de cero, bunx create-next-app
primero. Y necesitás un proyecto en el servicio del CMS: el arquitecto lo crea
y te da un par de claves (lectura + escritura) y el secreto del webhook.
1. Instalá los paquetes
bun add @squaads/cms-client @squaads/cms-studio @squaads/cms-next2. Configurá las variables de entorno
Todas son de servidor: ninguna lleva NEXT_PUBLIC_, así que nunca llegan al
navegador.
| Variable | Para qué | ¿Por proyecto? |
| --- | --- | --- |
| CMS_SERVICE_URL | Origen del servicio del CMS | por entorno |
| CMS_READ_KEY | Clave pública de lectura (pub_…) | sí |
| CMS_WRITE_KEY | Clave de escritura (sec_…) | sí |
| CMS_WEBHOOK_SECRET | Secreto HMAC del webhook de revalidación | sí |
| CMS_AUTH_USER | Usuario del panel | sí |
| CMS_AUTH_PASSWORD | Contraseña del panel (12+ caracteres) | sí |
Sin CMS_AUTH_USER y CMS_AUTH_PASSWORD el panel arranca cerrado — es a
propósito, no un fallo (ver Proteger el panel).
3. Declará tus páginas
// src/lib/cms.config.ts
import type { CmsAdapterConfig } from "@squaads/cms-next";
export const cmsConfig: CmsAdapterConfig = {
projectKey: "mi-proyecto",
locales: ["es"],
defaultLocale: "es",
localePrefix: "as-needed", // "as-needed" | "always" | "never"
pages: [
{
key: "demo",
label: "Demo",
path: "/examples/cms-demo",
metadata: [
{ key: "meta.title", label: "Título SEO" },
{ key: "meta.description", label: "Descripción SEO", type: "multiline" },
],
},
],
};Es datos puros (sin funciones): así un Server Component puede pasárselo al panel sin envoltorio de cliente.
4. Montá las cuatro rutas de API
Cada archivo es una línea. Van bajo app/api/cms/ (o el apiBasePath que
configures).
// app/api/cms/content/[page]/[locale]/route.ts
import { createContentRoute } from "@squaads/cms-next/server";
import { cmsConfig } from "@/lib/cms.config";
export const { GET } = createContentRoute(cmsConfig);// app/api/cms/studio/[...path]/route.ts
import { createStudioProxyRoute } from "@squaads/cms-next/server";
export const { GET, PUT, POST, DELETE } = createStudioProxyRoute();// app/api/cms/revalidate/route.ts
import { createRevalidateRoute } from "@squaads/cms-next/server";
export const { POST } = createRevalidateRoute();// app/api/cms/auth/route.ts
import { createAuthRoute } from "@squaads/cms-next/server";
export const { POST, DELETE } = createAuthRoute();5. Montá el panel y el runtime
// app/panel/page.tsx — el gate comprueba la sesión en el servidor
import { CmsPanelGate } from "@squaads/cms-next/server";
import { cmsConfig } from "@/lib/cms.config";
export const metadata = { robots: { index: false, follow: false } };
export default function Panel() {
return <CmsPanelGate config={cmsConfig} />;
}// app/examples/layout.tsx — el runtime aplica ediciones en vivo y es inerte
// en rutas no registradas
import type { ReactNode } from "react";
import { CmsRuntimeMount } from "@squaads/cms-next";
import { cmsConfig } from "@/lib/cms.config";
export default function ExamplesLayout({ children }: { children: ReactNode }) {
return (
<>
{children}
<CmsRuntimeMount config={cmsConfig} locale="es" />
</>
);
}El runtime tiene que estar montado en un layout que cubra la ruta de tu página
anotada. En este ejemplo el layout está en app/examples/ y la página en
/examples/cms-demo; si tu página cuelga de otra ruta (p. ej. /legal), poné el
<CmsRuntimeMount> en un layout que la cubra.
6. Escribí y anotá la página
Creá el archivo en la ruta que declaraste en cmsConfig.pages — un path de
/examples/cms-demo es app/examples/cms-demo/page.tsx — y anotá cada valor
siguiendo Anotar cada primitiva. El esqueleto es:
// app/examples/cms-demo/page.tsx
import type { Metadata } from "next";
import { resolvePageContent } from "@squaads/cms-next/server";
import { cmsConfig } from "@/lib/cms.config";
export async function generateMetadata(): Promise<Metadata> {
const content = await resolvePageContent(cmsConfig, "demo", "es");
return {
title: content.text("meta.title", "Título por defecto"),
description: content.text("meta.description", "Descripción por defecto."),
};
}
export default async function DemoPage() {
const content = await resolvePageContent(cmsConfig, "demo", "es");
return (
<main>
<h1 data-cms="demo.title">{content.text("demo.title", "Título por defecto")}</h1>
{/* …el resto de las primitivas… */}
</main>
);
}Con CMS_AUTH_* definidas, entrá a /panel, iniciá sesión, editá y publicá.
Qué se puede editar y qué no
El runtime solo reescribe el contenido de un elemento anotado: su texto, el
src/alt de una imagen, el href/label de un enlace, o el HTML de un texto
enriquecido. Nada más.
| Se puede editar | Marcado |
| --- | --- |
| Texto / título / botón | data-cms="k" en el elemento |
| Párrafo (multilínea) | data-cms="k" en un <p> |
| Enlace (label + href) | data-cms="k" en un <a> |
| Imagen (src + alt) | data-cms="k" en un <img> |
| Texto enriquecido (HTML) | data-cms="k" data-cms-type="richtext" |
| Lista / tabla | ver la sección de cada una |
Lo que NO se puede editar — y por qué:
| No editable | Por qué |
| --- | --- |
| Un valor dentro de un atributo cualquiera (placeholder, aria-label, title, class) | El runtime solo toca texto y los atributos src/alt/href; ningún otro. |
| Una cadena con interpolación (un plural, un texto con una variable) | La anotación resuelve a texto plano y borra la variable: rompe la lógica. Nunca la anotes. |
| Datos de negocio (precio, stock, un ítem de catálogo) | No es contenido de marca; vive en tu base o tu API. |
| Contenido que administra otro CMS (p. ej. una base de otro sistema) | Dos sistemas escribiendo lo mismo: gana el que publique último. |
Anotar cada primitiva
El tipo se infiere del elemento: <p> → multilínea, <img> → imagen, <a> →
enlace, data-cms-type="richtext" fuerza texto enriquecido, el resto es texto.
El data-cms se queda (lo usa el editor para descubrir el campo y aplicar
ediciones en vivo); lo que cambia es que el valor sale de content.* con el
default del código como fallback.
Primero resolvé el contenido una vez en el Server Component:
import { resolvePageContent } from "@squaads/cms-next/server";
import { cmsConfig } from "@/lib/cms.config";
const content = await resolvePageContent(cmsConfig, "demo", "es");Texto y multilínea
<h1 data-cms="demo.title">{content.text("demo.title", "Título por defecto")}</h1>
<p data-cms="demo.subtitle">{content.text("demo.subtitle", "Bajada por defecto.")}</p>Enlace (label + href editables)
const cta = content.link("demo.cta", { href: "/panel", label: "Abrir el editor" });
<a data-cms="demo.cta" href={cta.href}>{cta.label}</a>Para un enlace de navegación cuyo href es una ruta del código y solo querés
editar el texto, usá data-cms-link="label":
<a data-cms="cta.button" data-cms-link="label" href="/vehiculos">
{content.text("cta.button", "Ver más")}
</a>Imagen (src + alt)
const image = content.image("demo.image", { src: "/globe.svg", alt: "Ilustración" });
<img data-cms="demo.image" src={image.src} alt={image.alt} />Texto enriquecido — el default usa etiquetas semánticas (<strong>, <a>,
<em>); el saneador quita class, style y on*.
<div
data-cms="demo.note"
data-cms-type="richtext"
dangerouslySetInnerHTML={content.richtext(
"demo.note",
"<p>Un bloque de <strong>texto enriquecido</strong>.</p>",
)}
/>Lista — iterás lo que el CMS resuelve (recuento publicado + tu array como plantilla), no el array del código, así el servidor entrega el número correcto de elementos sin JavaScript. Cada celda se resuelve por campo.
const FEATURES = [
{ title: "Uno", text: "…" },
{ title: "Dos", text: "…" },
];
{content.list("demo.features", FEATURES).map((feature, index) => (
<li key={index}>
<h3 data-cms={`demo.features.${index}.title`}>
{content.text(`demo.features.${index}.title`, feature.title)}
</h3>
<p data-cms={`demo.features.${index}.text`}>
{content.text(`demo.features.${index}.text`, feature.text)}
</p>
</li>
))}Tabla — el <table data-cms-table="rates"> declara el grupo; las cabeceras
son rates.col.<id> y las celdas rates.<fila>.<id>. Las filas iteran
content.list; un renderizado alternativo (móvil) del mismo grupo lleva
data-cms-mirror="rates" y crece en paralelo.
<table data-cms-table="rates">
<thead><tr>
<th data-cms="rates.col.amount">{content.text("rates.col.amount", "Importe")}</th>
<th data-cms="rates.col.rate">{content.text("rates.col.rate", "Tasa")}</th>
</tr></thead>
<tbody>
{content.list("rates", RATES).map((row, i) => (
<tr key={i}>
<td data-cms={`rates.${i}.amount`}>{content.text(`rates.${i}.amount`, row.amount)}</td>
<td data-cms={`rates.${i}.rate`}>{content.text(`rates.${i}.rate`, row.rate)}</td>
</tr>
))}
</tbody>
</table>Resolución en servidor y SEO
resolvePageContent carga el contenido publicado en el Server Component y en
generateMetadata; Next memoiza el fetch, así que la página hace una sola
petición por idioma. El valor por defecto vive en el código y es el fallback.
export async function generateMetadata() {
const content = await resolvePageContent(cmsConfig, "demo", "es");
return {
title: content.text("meta.title", "Título por defecto"),
description: content.text("meta.description", "Descripción por defecto."),
};
}Por qué importa: el contenido publicado llega al HTML que entrega el servidor, no solo tras hidratar. Un buscador que no ejecuta JavaScript lo ve. Y por no-intrusión, si el servicio falla, la página entrega los valores del código: un fallo de lectura nunca la rompe.
El navegador vuelve a aplicar el mismo contenido con las mismas funciones puras que usa el servidor, así que los dos caminos coinciden: lo que ves en el editor es lo que se entrega y lo que indexa Google.
Componentes de cliente
El handle content tiene métodos (funciones), y las funciones no se
serializan hacia un Client Component: si intentás pasar content como prop a un
"use client", Next lo rechaza.
Regla: el padre de servidor resuelve y baja valores planos como props. El componente de cliente conserva su
data-cms(lo necesita el editor) pero recibe el texto ya resuelto, no un default hardcodeado.
// server parent
const content = await resolvePageContent(cmsConfig, "home", "es");
<Testimonials title={content.text("testimonials.title", "Opiniones")} />;
// testimonials.tsx — "use client"
export function Testimonials({ title }: { title: string }) {
return <h2 data-cms="testimonials.title">{title}</h2>;
}Proteger el panel
La autenticación viaja en el paquete y se configura con dos variables. La puerta
que de verdad protege es el proxy de escritura (/api/cms/studio/…), no solo
la página: sin sesión válida, no se escribe ni por el panel ni por la API.
- Sin configurar, se deniega. Sin
CMS_AUTH_USER/CMS_AUTH_PASSWORD(o con contraseña de menos de 12 caracteres), el panel y el proxy quedan cerrados (503), nunca abiertos, y el arranque lo avisa en los logs. - Ninguna credencial llega al navegador. La comprobación es de servidor; lo
único que viaja al cliente es una cookie de sesión firmada (
httpOnly,securefuera de desarrollo,sameSite=lax, 8 h). Su contenido son solo marcas de tiempo, nunca la contraseña. - Comparación en tiempo constante de usuario y contraseña, y límite de intentos (5 fallos por IP bloquean 15 minutos).
Generá la contraseña con openssl rand -base64 24 y guardala en un gestor. Para
cerrar sesión, el panel trae su propio botón.
Por qué el cambio tarda en verse
Entre que publicás y que se ve hay tres cachés apiladas, y solo una la invalida el aviso de publicación (el webhook). Si el webhook no está configurado, se espera al vencimiento — no es un fallo, es la caché.
| Caché | Vive en | La invalida el webhook | Vence sola |
| --- | --- | --- | --- |
| Memo + CDN del servicio | El servicio del CMS | no (el memo se refresca al publicar, mismo proceso) | CDN a los 60 s |
| Caché de datos de Next (revalidate: 300 + tags) | Tu app (servidor) | sí (revalidateTag) | a los 300 s si no hay webhook |
| CDN/HTTP de tu app + navegador | Delante de tu app | no | CDN 60 s; el navegador revalida por ETag |
El aviso de publicación necesita CMS_WEBHOOK_SECRET y que el proyecto tenga
configurada la webhook_url apuntando a tu /api/cms/revalidate. El editor te
avisa cuando publica pero la página aún sirve lo viejo (no te miente diciendo
"publicado" cuando todavía no se ve).
Qué hacer cuando algo no aparece
| Síntoma | Revisá |
| --- | --- |
| El campo no aparece en el editor | ¿El elemento tiene data-cms? ¿El runtime (CmsRuntimeMount) está montado en un layout que cubre esa ruta? ¿La página está en cmsConfig.pages? |
| Publiqué y no lo veo | Es la caché (ver arriba). ¿Está el webhook configurado? Sin él, esperá hasta 300 s. En dev no hay caché de datos, así que ahí se ve al instante. |
| El panel no me deja entrar | ¿Definiste CMS_AUTH_USER y CMS_AUTH_PASSWORD? ¿La contraseña tiene 12+? Sin eso, el panel está cerrado a propósito. |
| Escribir desde el panel da 401/503 | 401 = sin sesión (volvé a iniciar sesión); 503 = falta CMS_SERVICE_URL/CMS_WRITE_KEY o la auth no está configurada. |
| La imagen no cambia | El src publicado tiene que ser una URL válida y de esquema seguro; una externa puede necesitar remotePatterns si usás next/image. |
| La página quedó en blanco al fallar el servicio | No debería: la resolución es no-intrusiva. Si pasa, reportalo. |
Límites conocidos
Lo que el CMS no hace hoy, por su nombre:
- Subida de imágenes desde el editor. El campo imagen edita el
src/altcomo texto (pegás una URL); no hay subida de archivos. Usá la biblioteca de medios de tu template o un almacenamiento propio. - Atributos arbitrarios. Solo son editables el texto, y
src/alt(imagen) yhref/label (enlace). Unplaceholder,aria-labelotitleno. - Colecciones atadas a datos. Una lista/tabla se funde con un array del código como plantilla, no con una fuente de datos (una base, una API). Para eso, resolvé los datos vos y anotá solo el texto editable.
- Cadenas con interpolación. No se anotan (borran la variable).
- Un solo usuario. El panel es de usuario único: sin roles, sin permisos, sin recuperación de contraseña ni doble factor.
- Límite de intentos por instancia. El rate-limit del login es en memoria; un despliegue multi-instancia necesitaría un almacén compartido.
