Magento 2 headless com Next.js: arquitetura do storefront
Next.js é uma escolha comum para um storefront headless de Magento porque entrega renderização no servidor, geração estática e cache num framework só, e é fácil de contratar. Este artigo mostra como as peças principais se encaixam com o GraphQL do Magento 2.4.8: um cliente GraphQL pequeno, a resolução das URLs de SEO do Magento, páginas de produto e categoria, o cart e a invalidação de cache. Uso o App Router do Next.js com JavaScript puro para manter os exemplos curtos. Não é um storefront completo, mas cobre as decisões que moldam um.
Se você ainda não decidiu por headless, leia antes PWA Studio vs headless sob medida. A decisão de verdade é o custo de construir e manter uma segunda aplicação, não o framework.
Arquitetura
- Magento roda como sempre, com Varnish na frente do
/graphql. O tema do storefront não é mais usado pelos clientes, mas o admin e as APIs são. - Next.js renderiza as páginas no servidor e chama o GraphQL do Magento a partir de server components. O navegador conversa com o Next.js, e não direto com o Magento, na maioria das leituras.
- O estado de cart e customer é identificado por um cart ID e um token de customer opcional, guardados em cookies HTTP only definidos pelo Next.js.
Chamar o Magento pelo lado do servidor tem duas vantagens. Você evita CORS na maioria das requisições, porque o Magento não envia headers CORS por padrão, e mantém os tokens fora do JavaScript do cliente.
Um cliente GraphQL mínimo
Você não precisa do Apollo para um storefront renderizado no servidor. O fetch com as opções de cache do Next.js basta. Queries de leitura vão como GET para o Varnish poder fazer cache, e mutations vão como POST.
// lib/magento.js
const ENDPOINT = process.env.MAGENTO_GRAPHQL_URL;
export async function magentoQuery(query, variables = {}, options = {}) {
const url = new URL(ENDPOINT);
url.searchParams.set('query', query);
url.searchParams.set('variables', JSON.stringify(variables));
const response = await fetch(url, {
headers: { Store: options.store || 'default' },
next: { revalidate: options.revalidate ?? 300, tags: options.tags || [] }
});
return parseResponse(response);
}
export async function magentoMutation(query, variables = {}, options = {}) {
const headers = {
'Content-Type': 'application/json',
Store: options.store || 'default'
};
if (options.token) {
headers.Authorization = 'Bearer ' + options.token;
}
const response = await fetch(ENDPOINT, {
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
cache: 'no-store'
});
return parseResponse(response);
}
async function parseResponse(response) {
const json = await response.json();
if (json.errors && json.errors.length) {
throw new Error(json.errors.map(e => e.message).join(', '));
}
return json.data;
}
Duas coisas para notar. A opção next.tags permite invalidar dados em cache por tag depois, que é como as atualizações de produto chegam ao storefront. E queries específicas de customer não podem usar o cache compartilhado. Passe cache: 'no-store' para tudo que usa um token.
Erros de GraphQL do Magento voltam com HTTP 200 e um array errors. Sempre verifique. Alguns erros são esperados, por exemplo um cart expirado, e devem ser tratados em vez de lançados.
Resolvendo as URLs do Magento
O Magento é dono da estrutura de URLs por meio dos URL rewrites. As URLs de produto e categoria são configuradas no admin e podem mudar. O storefront não deve adivinhar. Use uma rota catch all e pergunte ao Magento o que é aquele path com a query route.
// app/[...slug]/page.js
import { notFound, permanentRedirect, redirect } from 'next/navigation';
import { magentoQuery } from '@/lib/magento';
import ProductPage from '@/components/ProductPage';
import CategoryPage from '@/components/CategoryPage';
const ROUTE_QUERY = `
query Route($url: String!) {
route(url: $url) {
__typename
relative_url
redirect_code
... on CategoryInterface { uid name }
... on ProductInterface { uid sku name }
}
}`;
export default async function Page({ params }) {
const { slug } = await params;
const path = slug.join('/');
const data = await magentoQuery(ROUTE_QUERY, { url: path }, { tags: ['route'] });
const route = data.route;
if (!route) {
notFound();
}
if (route.redirect_code === 301) {
permanentRedirect('/' + route.relative_url);
}
if (route.redirect_code === 302) {
redirect('/' + route.relative_url);
}
if (route.__typename.endsWith('Product')) {
return <ProductPage sku={route.sku} />;
}
if (route.__typename === 'CategoryTree') {
return <CategoryPage uid={route.uid} />;
}
notFound();
}
O arquivo usa JSX, então precisa do suporte a JSX que o Next.js traz por padrão. Os tipos de produto voltam como SimpleProduct, ConfigurableProduct, BundleProduct e assim por diante. Categorias resolvem para CategoryTree. Páginas CMS resolvem para CmsPage, que você pode adicionar do mesmo jeito. Respeitar os códigos de redirect do Magento mantém as URLs antigas funcionando depois de uma migração, o que pesa muito no SEO.
Páginas de produto e categoria
Busque só o que a página renderiza. Uma página de produto precisa de muito mais do que um card de produto numa página de categoria, então mantenha dois fragments.
query ProductBySku($sku: String!) {
products(filter: { sku: { eq: $sku } }) {
items {
uid
sku
name
stock_status
description { html }
media_gallery { url label }
price_range {
minimum_price {
final_price { value currency }
regular_price { value currency }
}
}
... on ConfigurableProduct {
configurable_options { attribute_code label values { uid label } }
}
}
}
}
Marque a requisição com algo específico, por exemplo product-24-MB01 montado a partir do SKU, para poder revalidar só aquele produto depois. Para listagens de categoria use a query products com filter: { category_uid: { eq: $uid } }, mais aggregations para a layered navigation e page_info para a paginação.
A descrição é HTML vindo do admin, possivelmente com markup do Page Builder. Renderize como HTML e sanitize se os editores puderem colar conteúdo arbitrário. Se a loja depende muito do Page Builder, reserve tempo para renderizar e estilizar os content types dele no novo frontend.
Imagens
As URLs de mídia apontam para o host do Magento. Se você usa next/image, adicione o host de mídia do Magento em images.remotePatterns no next.config.js. O Magento já redimensiona e faz cache das imagens de produto, então decida se quem cuida do redimensionamento é o Next.js ou o Magento. Fazer nos dois desperdiça CPU e armazenamento.
Cart e checkout
Operações de cart são mutations. Use server actions ou route handlers do Next.js para que o cart ID e o token fiquem em cookies HTTP only.
// app/actions/cart.js
'use server';
import { cookies } from 'next/headers';
import { magentoMutation } from '@/lib/magento';
export async function addToCart(sku, quantity) {
const cookieStore = await cookies();
let cartId = cookieStore.get('cart_id')?.value;
const token = cookieStore.get('customer_token')?.value;
if (!cartId) {
const created = await magentoMutation('mutation { createGuestCart { cart { id } } }');
cartId = created.createGuestCart.cart.id;
cookieStore.set('cart_id', cartId, { httpOnly: true, secure: true, sameSite: 'lax', path: '/' });
}
const data = await magentoMutation(
`mutation Add($cartId: String!, $items: [CartItemInput!]!) {
addProductsToCart(cartId: $cartId, cartItems: $items) {
cart { total_quantity }
user_errors { code message }
}
}`,
{ cartId, items: [{ sku, quantity }] },
{ token }
);
return data.addProductsToCart;
}
Para um customer logado, pegue o cart ID pela query customerCart em vez de criar um guest cart, e faça o merge de qualquer guest cart depois do login com mergeCarts. A sequência completa do cart até o pedido está em mutations GraphQL de cart e checkout, e login e tokens estão em autenticação de customer no GraphQL.
O pagamento é onde os projetos headless desaceleram. Cada provedor tem o próprio SDK de frontend e a própria forma de passar um token de pagamento ao Magento por setPaymentMethodOnCart. Confira se o seu provedor documenta um fluxo headless ou GraphQL antes de se comprometer com uma data de lançamento.
Mantendo os dados atualizados
Com revalidate: 300, uma mudança de preço aparece em até cinco minutos. Para muitas lojas isso serve para conteúdo, mas não para preço e estoque. Use revalidação por tag nesses casos. Um route handler aceita uma requisição assinada e invalida as tags.
// app/api/revalidate/route.js
import { revalidateTag } from 'next/cache';
export async function POST(request) {
const secret = request.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ ok: false }, { status: 401 });
}
const { tags } = await request.json();
for (const tag of tags || []) {
revalidateTag(tag);
}
return Response.json({ ok: true });
}
Do lado do Magento, um módulo pequeno publica os eventos de save de produto e mudança de estoque numa queue, e um consumer chama esse endpoint com as tags afetadas. Não chame o endpoint de forma síncrona num observer de save, porque aí um save no admin ou uma importação ficaria esperando chamadas HTTP para o frontend. O padrão de queue está descrito em consumers de RabbitMQ no Magento 2.
Checklist de SEO para o frontend
- Renderize o conteúdo de produto e categoria no servidor.
- Use os campos de meta title, meta description e canonical URL do Magento via GraphQL.
- Devolva um status 404 de verdade para paths desconhecidos e 301 de verdade a partir dos URL rewrites.
- Gere os dados estruturados do produto com preço e disponibilidade a partir da mesma resposta que a página usa.
- Gere um sitemap a partir dos dados do Magento, ou faça proxy do sitemap do Magento se ele usar as mesmas URLs.
Acerte isso antes do lançamento. É difícil de perceber nos testes e caro de corrigir depois que o ranking cai.
FAQ
O Next.js deve chamar o GraphQL do Magento pelo navegador ou pelo servidor?
Principalmente pelo servidor. Isso evita CORS, mantém os tokens de customer em cookies HTTP only e deixa o Next.js fazer cache das respostas. Chamadas pelo cliente servem para partes interativas pequenas, se o CORS estiver configurado.
Como mapear as URLs do Magento para páginas do Next.js?
Use uma rota catch all e a query route do GraphQL. Ela devolve o tipo da entidade, os identificadores e o código de redirect dos URL rewrites do Magento.
Como as atualizações de produto chegam a uma página do Next.js em cache?
Marque os fetches com tags específicas do produto e chame revalidateTag num route handler. O Magento dispara isso de forma assíncrona por um consumer de queue depois de saves ou mudanças de estoque.
Posso manter o checkout do Magento e construir só o catálogo no Next.js?
É possível, mas aí o cart precisa ser compartilhado entre os dois, o que exige o mesmo domínio ou uma passagem de sessão bem cuidada. A maioria dos times constrói o checkout no frontend ou usa uma página de pagamento hospedada.
Atendo como freelancer em Magento e Adobe Commerce, incluindo projetos headless. Se você precisa de ajuda com isso, fale comigo.
Artigos relacionados
Login de cliente, tokens e segurança no GraphQL do Magento 2
Autenticação de cliente com o GraphQL do Magento 2: tokens de login, lifetime, logout, cadastro, reset de senha, armazenamento seguro do token, expiração e reCAPTCHA.
PWA Studio vs headless customizado no Magento: visão de dev
PWA Studio vs um frontend headless customizado no Magento: arquitetura, custos reais, lacunas do GraphQL, SEO, checkout e quando um theme server rendered vence.
Cache e performance do GraphQL no Magento 2: guia prático
Deixe o GraphQL do Magento 2 rápido: queries GET, cache no Varnish, X-Magento-Cache-Id, identities do @cache em resolvers customizados, batching de N+1 e limites de complexidade.
Checkout GraphQL no Magento 2: das mutations do carrinho ao pedido
Fluxo completo de checkout via GraphQL no Magento 2: carrinhos de visitante e cliente, addProductsToCart, endereços, frete, pagamento, placeOrder, merge de carrinhos e erros comuns.