Strapi
Next.js
CMS
Headless
TypeScript
API

Creación de un CMS sin cabeza personalizado con Strapi y Next.js

Creación de un CMS sin cabeza personalizado con Strapi y Next.js

En 2025, la demanda de CMS headless seguirá creciendo y las empresas buscarán soluciones flexibles y escalables para gestionar contenidos. En esta guía, crearemos un CMS headless completo utilizando Strapi como backend y Next.js como frontend, incorporando las últimas prácticas y características de ambas tecnologías.

Arquitectura del sistema

La arquitectura de nuestro CMS headless estará basada en microservicios, con una clara separación entre backend (Strapi) y frontend (Next.js). Esto permite una mayor flexibilidad, escalabilidad y mantenibilidad.

Componentes principales

  1. Backend (Correas)

    • API REST
    • Autenticación y Autorización
    • Gestión de contenidos
    • Carga de medios
    • Complementos personalizados
  2. Interfaz (Next.js)

    • Componentes del servidor
    • Representación híbrida
    • Caché y Revalidación
    • Modo de vista previa
    • SEO optimizado
  3. Infraestructura -PostgreSQL

    • Caché de Redis -CDN
    • Orquestación de contenedores

Implementación de backend

1. Configuración de correa

// config/database.ts export default ({ env }) => ({ connection: { client: 'postgres', connection: { host: env('DATABASE_HOST'), port: env.int('DATABASE_PORT'), database: env('DATABASE_NAME'), user: env('DATABASE_USERNAME'), password: env('DATABASE_PASSWORD'), ssl: env.bool('DATABASE_SSL'), }, pool: { min: 0, max: 10, }, }, }); // config/plugins.ts export default { upload: { config: { provider: 'aws-s3', providerOptions: { accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_ACCESS_SECRET, region: process.env.AWS_REGION, params: { Bucket: process.env.AWS_BUCKET, }, }, }, }, email: { config: { provider: 'sendgrid', providerOptions: { apiKey: process.env.SENDGRID_API_KEY, }, settings: { defaultFrom: 'no-reply@example.com', defaultReplyTo: 'contact@example.com', }, }, }, 'users-permissions': { config: { jwt: { expiresIn: '7d', }, }, }, };

2. Tipos de contenido

// api/article/content-types/article/schema.json { "kind": "collectionType", "collectionName": "articles", "info": { "singularName": "article", "pluralName": "articles", "displayName": "Article" }, "options": { "draftAndPublish": true, "versions": true }, "attributes": { "title": { "type": "string", "required": true, "unique": true }, "slug": { "type": "uid", "targetField": "title" }, "content": { "type": "richtext", "required": true }, "coverImage": { "type": "media", "multiple": false, "required": true }, "categories": { "type": "relation", "relation": "manyToMany", "target": "api::category.category", "inversedBy": "articles" }, "author": { "type": "relation", "relation": "manyToOne", "target": "plugin::users-permissions.user" }, "seo": { "type": "component", "component": "shared.seo", "required": true } } }

3. API personalizada

// api/article/controllers/article.ts export default factories.createCoreController( 'api::article.article', ({ strapi }) => ({ async findBySlug(ctx) { const { slug } = ctx.params; const entity = await strapi.db.query('api::article.article').findOne({ where: { slug }, populate: ['coverImage', 'categories', 'author', 'seo'], }); if (!entity) { return ctx.notFound('Article not found'); } return this.transformResponse(entity); }, async findFeatured(ctx) { const entities = await strapi.db.query('api::article.article').findMany({ where: { featured: true, publishedAt: { $notNull: true }, }, limit: 5, orderBy: { publishedAt: 'desc' }, populate: ['coverImage', 'categories'], }); return this.transformResponse(entities); }, }) );

Implementación de interfaz

1. Configuración de Next.js

// app/layout.tsx import { Inter } from 'next/font/google'; import { Analytics } from '@vercel/analytics/react'; import { Providers } from './providers'; const inter = Inter({ subsets: ['latin'] }); export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en" suppressHydrationWarning> <body className={inter.className}> <Providers> {children} </Providers> <Analytics /> </body> </html> ); }

2. Cliente API

// lib/api/client.ts import qs from 'qs'; const API_URL = process.env.NEXT_PUBLIC_STRAPI_API_URL; async function fetchAPI( path: string, urlParamsObject = {}, options = {} ) { try { const mergedOptions = { next: { revalidate: 60 }, headers: { 'Content-Type': 'application/json', }, ...options, }; const queryString = qs.stringify(urlParamsObject, { encodeValuesOnly: true, }); const requestUrl = `${API_URL}/api${path}${queryString ? `?${queryString}` : ''}`; const response = await fetch(requestUrl, mergedOptions); if (!response.ok) { throw new Error(`API error: ${response.statusText}`); } const data = await response.json(); return data; } catch (error) { console.error(error); throw error; } } export async function getArticles(params = {}) { const data = await fetchAPI('/articles', { populate: ['coverImage', 'categories', 'author'], sort: ['publishedAt:desc'], ...params, }); return data; } export async function getArticle(slug: string) { const data = await fetchAPI(`/articles/${slug}`, { populate: ['coverImage', 'categories', 'author', 'seo'], }); return data; }

3. Componentes

// components/ArticleCard.tsx import Image from 'next/image'; import Link from 'next/link'; import { format } from 'date-fns'; interface ArticleCardProps { article: { attributes: { title: string; slug: string; excerpt: string; publishedAt: string; coverImage: { data: { attributes: { url: string; alternativeText: string; }; }; }; }; }; } export function ArticleCard({ article }: ArticleCardProps) { const { attributes } = article; return ( <Link href={`/articles/${attributes.slug}`} className="group block" > <article className="space-y-4"> <div className="aspect-w-16 aspect-h-9 overflow-hidden rounded-lg"> <Image src={attributes.coverImage.data.attributes.url} alt={attributes.coverImage.data.attributes.alternativeText} fill className="object-cover transition group-hover:scale-105" /> </div> <div className="space-y-2"> <h3 className="text-xl font-semibold line-clamp-2"> {attributes.title} </h3> <p className="text-gray-600 line-clamp-3"> {attributes.excerpt} </p> <time dateTime={attributes.publishedAt} className="text-sm text-gray-500" > {format( new Date(attributes.publishedAt), 'dd MMM, yyyy' )} </time> </div> </article> </Link> ); }

4. Páginas y Rutas

// app/articles/page.tsx import { Suspense } from 'react'; import { getArticles } from '@/lib/api/client'; import { ArticleCard } from '@/components/ArticleCard'; import { Pagination } from '@/components/Pagination'; interface ArticlesPageProps { searchParams: { page?: string; category?: string; }; } export default async function ArticlesPage({ searchParams, }: ArticlesPageProps) { const page = Number(searchParams.page) || 1; const { data: articles, meta } = await getArticles({ pagination: { page, pageSize: 9, }, filters: searchParams.category ? { categories: { slug: searchParams.category, }, } : undefined, }); return ( <div className="space-y-8"> <h1 className="text-4xl font-bold">Articles</h1> <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3"> {articles.map((article) => ( <ArticleCard key={article.id} article={article} /> ))} </div> <Pagination currentPage={page} totalPages={meta.pagination.pageCount} baseUrl="/articles" /> </div> ); }

Implementación e infraestructura

1. Redacción de Docker

## docker-compose.yml version: '3' services: strapi: build: ./backend environment: DATABASE_CLIENT: postgres DATABASE_HOST: postgres DATABASE_NAME: strapi DATABASE_USERNAME: strapi DATABASE_PASSWORD: strapi NODE_ENV: production depends_on: - postgres ports: - '1337:1337' postgres: image: postgres:15 environment: POSTGRES_DB: strapi POSTGRES_USER: strapi POSTGRES_PASSWORD: strapi volumes: - postgres-data:/var/lib/postgresql/data redis: image: redis:7 ports: - '6379:6379' nextjs: build: ./frontend environment: NEXT_PUBLIC_STRAPI_API_URL: http://strapi:1337 ports: - '3000:3000' depends_on: - strapi volumes: postgres-data:

2. Acciones de GitHub

## .github/workflows/deploy.yml name: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '20' - name: Install dependencies run: | cd backend && yarn install cd ../frontend && yarn install - name: Build run: | cd backend && yarn build cd ../frontend && yarn build - name: Deploy env: DIGITALOCEAN_ACCESS_TOKEN: $ run: | doctl kubernetes cluster kubeconfig save cms-cluster kubectl apply -f k8s/

Mejores prácticas

1. Rendimiento

  • Implementar el almacenamiento en caché en múltiples capas.
  • Optimizar activos e imágenes.
  • Usar CDN
  • Implementar carga diferida
  • Supervisar las métricas de rendimiento.

2. Seguridad

  • Configurar CORS apropiadamente
  • Implementar limitación de velocidad.
  • Utilizar variables de entorno.
  • Higienizar insumos
  • Mantener las dependencias actualizadas.

3. SEO

  • Implementar metaetiquetas dinámicas.
  • Generar mapa del sitio.xml
  • Utilice URL amigables
  • Optimizar el rendimiento
  • Implementar esquema.org

Conclusión

La creación de un CMS sin cabeza con Strapi y Next.js ofrece:

  1. Flexibilidad: Arquitectura modular y extensible
  2. Rendimiento: optimización de la entrega de contenido
  3. Seguridad: control total sobre la implementación
  4. Escalabilidad: arquitectura distribuida
  5. Mantenibilidad: código organizado y bien documentado

Próximos pasos

  1. Implementar funciones avanzadas
  2. Agregar análisis de datos
  3. Ampliar las integraciones
  4. Optimizar para escalar
  5. Recopile comentarios de los usuarios

¿Estás creando un CMS sin cabeza? ¡Comparta sus experiencias y preguntas en los comentarios a continuación!

Lea también