npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-next

2. 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, secure fuera 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/alt como 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) y href/label (enlace). Un placeholder, aria-label o title no.
  • 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.