Tus editores escriben posts en el CRM y tu web los muestra. La API del blog te da una lista paginada para la portada y un post completo por su slug para la página del artículo: dos endpoints, y los “¿me lo publicas antes del viernes?” dejan de llegar a tu bandeja de entrada.
Dos endpoints, uno para cada tipo de página que vas a construir:
GET /v1/blog— la lista: títulos, portadas, autores y fechas, página a página. Sin el cuerpo de los posts, así que va ligera.GET /v1/blog/:slug— un post con todo, incluido el cuerpo en HTML.
Listar los posts del blog
/v1/bloghttps://back.sitecog.com/content/v1/blog
Parámetros de consulta
langquerystringopcionalPor defecto: todos los idiomas activostitle e image. Un código, una lista separada por comas (en,de) o el parámetro repetido. Consulta Idiomas.limitquerynumberopcionalPor defecto: tamaño de página del blog en el CRM; si no, 12offsetquerynumberopcionalPor defecto: 0limit para el scroll infinito y los botones de “cargar más”.pagequerynumberopcionaloffset para la paginación numerada. Si llegan los dos, gana page.perPagequerynumberopcionalPor defecto: igual que limitlimit (1–50). Si llegan los dos, gana perPage.x-crm-keyheaderstringobligatorio?key=. Consulta Claves del sitio.Ejemplo de petición
curl "https://back.sitecog.com/content/v1/blog?lang=en&limit=2" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"const res = await fetch('https://back.sitecog.com/content/v1/blog?lang=en&limit=2', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
});
if (!res.ok) throw new Error(`Content API: ${res.status}`);
const blog = await res.json();
console.log(`${blog.total} posts en ${blog.pages} páginas`);
blog.posts.forEach((post) => console.log(post.slug, post.title.en));Ejemplo de respuesta
{
"total": 29,
"limit": 2,
"offset": 0,
"page": 1,
"pages": 15,
"hasMore": true,
"nextOffset": 2,
"posts": [
{
"slug": "how-we-chose-hosting",
"author": "Anton Kravtsov",
"publishedAt": "2026-08-10T00:00:00.000Z",
"title": { "en": "How we chose hosting and got it wrong twice" },
"image": { "en": "https://cdn.example.com/storage/your-site/blog/hosting-cover.jpg" }
},
{
"slug": "noise-cancelling-explained",
"author": null,
"publishedAt": "2026-07-28T00:00:00.000Z",
"title": { "en": "Noise cancelling, explained without the physics lecture" },
"image": { "en": "https://cdn.example.com/storage/your-site/blog/anc-cover.jpg" }
}
]
}Campos de la respuesta
totalnumberlimitnumberoffsetnumberpagenumber | nullnull cuando offset no es múltiplo de limit (por ejemplo, limit=10&offset=5): ahí no hay un número de página honesto.pagesnumberceil(total / limit). 0 si el blog está vacío.hasMorebooleantrue.nextOffsetnumber | nulloffset de la siguiente ventana, o null cuando has llegado al final. Pásalo tal cual en la siguiente petición.postsPost[]posts →
slugstringhow-we-chose-hosting. Úsala en tus URL y para GET /v1/blog/:slug.authorstring | nullnull si nadie firmó el post.publishedAtstring (ISO 8601) | nulltitle{ [lang]: string }image{ [lang]: string }Paginación: scroll infinito o páginas numeradas
La API habla los dos dialectos de paginación, así que no tienes que traducir uno al otro de cabeza. Elige el que encaje con tu diseño.
Scroll infinito y “cargar más”: limit + offset
Pide la primera ventana, muéstrala y, cuando el visitante haga scroll hacia abajo (o pulse el botón), pide la siguiente empezando en nextOffset. Cuando nextOffset sea null, has terminado.
# primera ventana
curl "https://back.sitecog.com/content/v1/blog?lang=en&limit=12" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"
# siguiente ventana: offset = nextOffset de la respuesta anterior
curl "https://back.sitecog.com/content/v1/blog?lang=en&limit=12&offset=12" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"const API = 'https://back.sitecog.com/content/v1';
const KEY = 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40';
let nextOffset = 0;
async function loadMore() {
if (nextOffset === null) return; // fin, no queda nada que cargar
const res = await fetch(`${API}/blog?lang=en&limit=12&offset=${nextOffset}`, {
headers: { 'x-crm-key': KEY },
});
const data = await res.json();
renderPosts(data.posts); // tu función que añade las tarjetas
nextOffset = data.nextOffset; // null cuando ya no queda nada
loadMoreButton.hidden = !data.hasMore;
}
loadMoreButton.addEventListener('click', loadMore);
loadMore();Páginas numeradas: page + perPage
La paginación clásica “1 2 3 … 15”. Envía el número de página y recibe pages para pintar los enlaces. Aquí va la página 3 con 10 posts por página, de un total de 29:
curl "https://back.sitecog.com/content/v1/blog?lang=en&page=3&perPage=10" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"const page = Number(new URLSearchParams(location.search).get('page')) || 1;
const res = await fetch(`https://back.sitecog.com/content/v1/blog?lang=en&page=${page}&perPage=10`, {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
});
const data = await res.json();
// enlaces 1..pages, con la página actual resaltada
const links = Array.from({ length: data.pages }, (_, i) => ({
href: `/blog?page=${i + 1}`,
current: i + 1 === data.page,
}));{
"total": 29,
"limit": 10,
"offset": 20,
"page": 3,
"pages": 3,
"hasMore": false,
"nextOffset": null,
"posts": [
{ "slug": "…", "author": "…", "publishedAt": "…", "title": { "en": "…" }, "image": { "en": "…" } }
]
}Fíjate en que la respuesta siempre describe la ventana en los dos dialectos: page y pages para los enlaces numerados; limit, offset y nextOffset para el scroll.
Cuando se juntan los dos estilos
¿Has enviado limit y perPage a la vez? Gana perPage. ¿offset y page? Gana page. En ningún caso hay error, pero hazte un favor y quédate con un solo estilo por petición.
Qué posts son visibles y en qué orden
La API muestra exactamente lo que debe ver un visitante, y nada de lo que un editor aún tiene entre manos:
- Solo posts publicados. Los borradores nunca salen del CRM, mandes los parámetros que mandes.
- Los posts programados esperan su turno. Si la programación está activada en el CRM, un post con fecha de publicación futura sigue oculto hasta ese momento. Por la caché puede aparecer unos minutos tarde (hasta unos cinco), así que tenlo en cuenta al programar lanzamientos.
- El orden se decide en el CRM. Mandan los ajustes del blog: orden manual (los editores arrastran los posts), por fecha de publicación o por fecha de creación, ascendente o descendente. La API devuelve los posts en ese orden; no hay parámetro de ordenación, así que quien manda es el editor.
Obtener un post
/v1/blog/:slughttps://back.sitecog.com/content/v1/blog/:slug
body, el artículo en sí en HTML.Parámetros
slugpathstringobligatoriohow-we-chose-hosting. Letras latinas minúsculas, dígitos y guiones, hasta 120 caracteres. Cualquier otra cosa da 404 post_not_found.langquerystringopcionalPor defecto: todos los idiomas activosx-crm-keyheaderstringobligatorio?key= si las cabeceras no son una opción.Ejemplo de petición
curl "https://back.sitecog.com/content/v1/blog/how-we-chose-hosting?lang=en,de" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"const res = await fetch('https://back.sitecog.com/content/v1/blog/how-we-chose-hosting?lang=en,de', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
});
if (res.status === 404) {
// no existe ese post o aún no está publicado: muestra tu página 404
}
const { post } = await res.json();
console.log(post.title.de); // "Wie wir Hosting gewählt haben"Ejemplo de respuesta
{
"post": {
"slug": "how-we-chose-hosting",
"author": "Anton Kravtsov",
"publishedAt": "2026-08-10T00:00:00.000Z",
"title": {
"en": "How we chose hosting and got it wrong twice",
"de": "Wie wir Hosting gewählt haben"
},
"image": {
"en": "https://cdn.example.com/storage/your-site/blog/hosting-cover.jpg",
"de": "https://cdn.example.com/storage/your-site/blog/hosting-cover.jpg"
},
"body": {
"en": "<p>Attempt one was the cheapest server we could find.</p><h2>What went wrong</h2><p>Everything, on a Friday night.</p>"
}
}
}¿Ves que falta body.de? El cuerpo en alemán aún no está escrito, y en los mapas del blog las traducciones vacías se omiten en lugar de volver como "". La portada única, en cambio, se repite para los dos idiomas.
Campos de la respuesta
postobjectpost →
slugstringauthorstring | nullpublishedAtstring (ISO 8601) | nullnull. Dale formato para humanos con new Date(post.publishedAt).toLocaleDateString(lang).title{ [lang]: string }image{ [lang]: string }body{ [lang]: string }Renderizar el cuerpo HTML de forma segura
body es HTML listo para usar: títulos, párrafos, listas, enlaces, imágenes. Para meterlo en la página necesitas el interruptor de “sí, de verdad quiero HTML en crudo” de tu framework: dangerouslySetInnerHTML en React, v-html en Vue.
No hay idioma de respaldo en el servidor: si falta una traducción, la clave simplemente no está. Un helper diminuto lo resuelve: el idioma pedido, luego tu idioma por defecto y luego cualquiera que no esté vacío:
const t = (map, lang, fallback = 'en') =>
map?.[lang] || map?.[fallback] || Object.values(map ?? {})[0] || '';
export function Post({ post, lang }) {
return (
<article>
<h1>{t(post.title, lang)}</h1>
{/* Diil sanea el body al guardar el post */}
<div className="prose" dangerouslySetInnerHTML={{ __html: t(post.body, lang) }} />
</article>
);
}<script setup>
import { computed } from 'vue';
const props = defineProps({ post: Object, lang: String });
const t = (map, lang, fallback = 'en') =>
map?.[lang] || map?.[fallback] || Object.values(map ?? {})[0] || '';
const body = computed(() => t(props.post.body, props.lang));
</script>
<template>
<article>
<h1>{{ t(post.title, lang) }}</h1>
<!-- Diil sanea el body al guardar el post -->
<div class="prose" v-html="body" />
</article>
</template>Un blog en Next.js: portada y página del post
Un blog completo con Next.js y un CMS headless en tres archivos: una pequeña capa de datos, la portada con páginas numeradas y la página del post, prerrenderizada para cada slug con generateStaticParams. La clave se queda en el servidor.
// lib/blog.ts — todo lo del blog en un solo sitio
const API = 'https://back.sitecog.com/content/v1';
const LANG = 'en';
const headers = { 'x-crm-key': process.env.CRM_KEY! };
export type LangMap = Record<string, string>;
export type Post = {
slug: string;
author: string | null;
publishedAt: string | null;
title: LangMap;
image: LangMap;
body?: LangMap;
};
export const t = (map: LangMap | undefined, lang = LANG, fallback = 'en') =>
map?.[lang] || map?.[fallback] || Object.values(map ?? {})[0] || '';
export async function getPosts(page = 1, perPage = 12) {
const res = await fetch(`${API}/blog?lang=${LANG}&page=${page}&perPage=${perPage}`, {
headers,
next: { revalidate: 60 },
});
if (!res.ok) throw new Error(`Content API: ${res.status}`);
return (await res.json()) as { total: number; page: number | null; pages: number; posts: Post[] };
}
export async function getPost(slug: string) {
const res = await fetch(`${API}/blog/${slug}?lang=${LANG}`, {
headers,
next: { revalidate: 60 },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Content API: ${res.status}`);
return ((await res.json()) as { post: Post }).post;
}
// limit no pasa de 50, así que recorremos todo el blog con nextOffset
export async function getAllSlugs() {
const slugs: string[] = [];
let offset: number | null = 0;
while (offset !== null) {
const res = await fetch(`${API}/blog?lang=${LANG}&limit=50&offset=${offset}`, { headers });
if (!res.ok) throw new Error(`Content API: ${res.status}`);
const data: { posts: Post[]; nextOffset: number | null } = await res.json();
slugs.push(...data.posts.map((post) => post.slug));
offset = data.nextOffset;
}
return slugs;
}// app/blog/page.tsx — la portada con páginas numeradas
import Link from 'next/link';
import { getPosts, t } from '@/lib/blog';
export default async function Blog({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
const page = Number((await searchParams).page) || 1;
const { posts, pages } = await getPosts(page);
if (!posts.length) return <p>Aún no hay posts. Los autores siguen preparando el café.</p>;
return (
<main>
{posts.map((post) => (
<article key={post.slug}>
{t(post.image) && <img src={t(post.image)} alt="" />}
<h2><Link href={`/blog/${post.slug}`}>{t(post.title)}</Link></h2>
{post.publishedAt && (
<time dateTime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString('en')}</time>
)}
</article>
))}
<nav>
{Array.from({ length: pages }, (_, i) => (
<Link key={i} href={`/blog?page=${i + 1}`} aria-current={i + 1 === page ? 'page' : undefined}>
{i + 1}
</Link>
))}
</nav>
</main>
);
}// app/blog/[slug]/page.tsx — un post, prerrenderizado para cada slug
import { notFound } from 'next/navigation';
import { getAllSlugs, getPost, t } from '@/lib/blog';
type Props = { params: Promise<{ slug: string }> };
export async function generateStaticParams() {
const slugs = await getAllSlugs();
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: Props) {
// el mismo fetch que en la página: Next.js lo deduplica
const post = await getPost((await params).slug);
return { title: post ? t(post.title) : 'Post no encontrado' };
}
export default async function PostPage({ params }: Props) {
const post = await getPost((await params).slug);
if (!post) notFound();
return (
<article>
<h1>{t(post.title)}</h1>
{post.author && <p>Por {post.author}</p>}
<div className="prose" dangerouslySetInnerHTML={{ __html: t(post.body) }} />
</article>
);
}Errores
| Estado | Cuerpo | Qué ha pasado |
|---|---|---|
| 400 | {"message":"invalid_lang", …} | Un código de lang no tiene pinta de código de idioma. |
| 400 | {"message":"unknown_lang", …} | Un idioma de lang no está activo en el sitio. El cuerpo enumera los disponibles. |
| 400 | {"message":"too_many_langs","max":50} | Más de 50 códigos en lang. Impresionante, pero no. |
| 401 | {"message":"invalid_key"} | Clave ausente, mal formada o revocada. |
| 404 | {"message":"post_not_found"} | No hay ningún post visible con este slug: una errata, un slug con caracteres prohibidos o un post que es borrador o aún no se ha publicado. |
| 405 | {"message":"method_not_allowed"} | Cualquier cosa que no sea GET o HEAD. La API es de solo lectura. |
| 429 | {"message":"rate_limit_exceeded"} | Demasiadas peticiones en este minuto. Consulta Límites de peticiones. |
Los valores de paginación incorrectos no están en esta lista a propósito: vuelven a los valores por defecto en lugar de fallar. Todo lo demás está en la página Errores.