Em 2025, a demanda por CMSs headless continua crescendo, com empresas buscando soluções flexíveis e escaláveis para gerenciar conteúdo. Neste guia, vamos construir um CMS headless completo usando Strapi como backend e Next.js como frontend, incorporando as últimas práticas e recursos de ambas as tecnologias.
Arquitetura do Sistema
A arquitetura do nosso CMS headless será baseada em microserviços, com separação clara entre backend (Strapi) e frontend (Next.js). Isso permite maior flexibilidade, escalabilidade e manutenibilidade.
Componentes Principais
-
Backend (Strapi)
- API RESTful
- Autenticação e Autorização
- Gerenciamento de Conteúdo
- Upload de Mídia
- Plugins Personalizados
-
Frontend (Next.js)
- Server Components
- Renderização Híbrida
- Cache e Revalidação
- Preview Mode
- SEO Otimizado
-
Infraestrutura
- PostgreSQL
- Redis Cache
- CDN
- Container Orchestration
Implementação do Backend
1. Configuração do Strapi
// 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 Conteúdo
// 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); }, }) );
Implementação do Frontend
1. Setup do 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. API Client
// 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. Pages e Routes
// 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> ); }
Deploy e Infraestrutura
1. Docker Compose
## 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. GitHub Actions
## .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/
Melhores Práticas
1. Performance
- Implementar cache em múltiplas camadas
- Otimizar assets e imagens
- Usar CDN
- Implementar lazy loading
- Monitorar métricas de performance
2. Segurança
- Configurar CORS adequadamente
- Implementar rate limiting
- Usar variáveis de ambiente
- Sanitizar inputs
- Manter dependências atualizadas
3. SEO
- Implementar meta tags dinâmicas
- Gerar sitemap.xml
- Usar URLs amigáveis
- Otimizar performance
- Implementar schema.org
Conclusão
A construção de um CMS headless com Strapi e Next.js oferece:
- Flexibilidade: Arquitetura modular e extensível
- Performance: Otimização de entrega de conteúdo
- Segurança: Controle total sobre a implementação
- Escalabilidade: Arquitetura distribuída
- Manutenibilidade: Código organizado e bem documentado
Próximos Passos
- Implemente recursos avançados
- Adicione análise de dados
- Expanda integrações
- Otimize para escala
- Colete feedback dos usuários
Está construindo um CMS headless? Compartilhe suas experiências e dúvidas nos comentários abaixo!
Leia também
- Headless Commerce: Guia de Arquitetura Desacoplada
- Cache e streaming no Next.js: performance virou decisão de arquitetura
- GraphQL APIs Modernas: Schema Design, Performance e Patterns que Funcionam
- Boas Práticas de Internacionalização (i18n) em React e Next.js em 2025
- Next.js App Router: o Guia para Pensar em Servidor por Padrão
- Server Actions no Next.js: mutações sem manter uma API só para isso