Construindo um CMS Headless Customizado com Strapi e Next.js
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: '[email protected]',
defaultReplyTo: '[email protected]',
},
},
},
'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!