Matheus Breguêz (matbrgz)
Construindo um CMS Headless Customizado com Strapi e Next.js
Desenvolvimento

Construindo um CMS Headless Customizado com Strapi e Next.js

Índice

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

  1. Backend (Strapi)
    • API RESTful
    • Autenticação e Autorização
    • Gerenciamento de Conteúdo
    • Upload de Mídia
    • Plugins Personalizados
  2. Frontend (Next.js)
    • Server Components
    • Renderização Híbrida
    • Cache e Revalidação
    • Preview Mode
    • SEO Otimizado
  3. 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:

  1. Flexibilidade: Arquitetura modular e extensível
  2. Performance: Otimização de entrega de conteúdo
  3. Segurança: Controle total sobre a implementação
  4. Escalabilidade: Arquitetura distribuída
  5. Manutenibilidade: Código organizado e bem documentado

Próximos Passos

  1. Implemente recursos avançados
  2. Adicione análise de dados
  3. Expanda integrações
  4. Otimize para escala
  5. Colete feedback dos usuários

Está construindo um CMS headless? Compartilhe suas experiências e dúvidas nos comentários abaixo!

Strapi Next.js CMS Headless TypeScript API

Compartilhe este artigo

Transforme suas ideias em realidade

Vamos trabalhar juntos para criar soluções inovadoras que impulsionem seu negócio.