Neste artigo
SEO não é meta tag. É arquitetura
A empresa se chama MC — Marketing & Code. O "Marketing" não está no nome por acidente. Quando você entende os dois lados, para de tratar SEO como detalhe de componente e começa a tratar como responsabilidade de sistema.

Existe um momento muito específico no desenvolvimento de SPAs.
Você constrói a página, abre o DevTools, vê tudo renderizando lindo, aí inspeciona o HTML que chegou do servidor:
<head>
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
</body>
Esse é o HTML que o Googlebot lê. Esse é o HTML que o WhatsApp usa pra gerar preview quando alguém compartilha o link. Esse é o HTML que decide se o seu artigo ranqueia ou some.
Uma <div id="app"> vazia.
Não importa quantas vezes você chamou useMeta() no componente. Se não tem SSR, não tem SEO de verdade. Os metadados gerados no JavaScript rodam no browser do usuário — não no crawler. Isso é um fato, não opinião.
Esse é o ponto de partida. E é onde a maioria dos projetos anota "SSR — talvez um dia" e segue em frente.
No MKT Code, não teve "talvez". Mas resolver o SSR foi só metade do problema.
#O segundo problema: SEO espalhado
Resolvido o SSR, aparece o próximo erro. E é onde a maioria para de pensar.
O que você renderiza no servidor?
A resposta mais comum: cada página gerencia os próprios metadados. O componente Vue de PostShow.vue monta título, descrição e og:image diretamente. O de ProjectShow.vue faz a mesma coisa do zero. Cada um com sua própria interpretação de como truncar descrição, formatar título, montar canonical.
Três meses depois, você tem a mesma lógica espalhada por dez lugares diferentes. Quando a regra muda — adicionar JSON-LD, ajustar padrão de título — você caça tudo na mão.
Isso não é SEO. É SEO improvisado.
A decisão foi centralizar essa responsabilidade no backend, dentro de um namespace próprio: app/SEO/. Vou mostrar como funciona, camada por camada.
#A fundação: o DTO
O coração de tudo é o SeoData. Não um array, não uma stdClass — um objeto com contratos explícitos:
// app/SEO/DTO/SeoData.php
class SeoData
{
public function __construct(
public string $title,
public string $description,
public string $image,
public string $url,
public string $type = 'website',
public ?string $canonical = null,
public ?string $publishedAt = null,
public ?string $updatedAt = null,
public ?string $author = null,
public ?array $category = null,
public ?array $tags = null,
public ?array $keywords = null,
public ?string $imageAlt = null,
public ?array $breadcrumbs = null,
public string $robots = 'index, follow',
public string $locale = 'pt_BR',
public bool $noIndex = false,
) {}
public function withoutIndexing(): static
{
$clone = clone $this;
$clone->noIndex = true;
$clone->robots = 'noindex, nofollow';
return $clone;
}
public function toArray(): array
{
$data = array_filter([
'title' => $this->title,
'description' => $this->description,
'image' => $this->image,
'url' => $this->url,
'canonical' => $this->canonical ?? $this->url,
'type' => $this->type,
'publishedAt' => $this->publishedAt,
'updatedAt' => $this->updatedAt,
'author' => $this->author,
'robots' => $this->robots,
'locale' => $this->locale,
// ...demais campos
], fn($value) => ! is_null($value));
// noIndex só entra no payload quando verdadeiro —
// evita ruído desnecessário no frontend em 99% das páginas
if ($this->noIndex) {
$data['noIndex'] = true;
}
return $data;
}
}
Dois detalhes que importam aqui.
O withoutIndexing() retorna um clone — imutabilidade intencional. SeoData não muda depois de criado. Se precisar de uma versão sem indexação, você gera uma nova instância a partir da original. Sem efeitos colaterais silenciosos quando o objeto trafega por camadas diferentes.
O array_filter no toArray() garante que campos nulos não poluam o payload JSON que o Inertia manda para o Vue. Economiza banda e mantém o $page.props limpo no frontend.
#Os contratos
Três interfaces definem os limites de responsabilidade:
// Qualquer classe que sabe construir um SeoData para uma model específica
interface BuildsSeo
{
public function build(): SeoData;
}
// Qualquer Model que expõe SEO para o sistema
interface HasSeo
{
public function getSeo(): SeoData;
}
// Qualquer classe que sabe gerar um segmento do sitemap
interface SitemapProvider
{
public function generate(Sitemap $sitemap): void;
public function filename(): string;
}
O HasSeo é o que amarra tudo ao Eloquent. Um Post que implementa HasSeo declara para o sistema que sabe gerar seus próprios metadados. Sem instanceof espalhados, sem arrays mágicos passando de mão em mão.
#O SeoResolver: convenção como lei
O SeoResolver é a peça central do ecossistema. Ele resolve qual Builder usar via convenção de namespace — e é estrito: se o Builder não existir, lança exceção.
// app/SEO/SeoResolver.php
class SeoResolver
{
public function resolve(HasSeo $model): SeoData
{
$builderClass = str_replace(
'App\\Models\\',
'App\\SEO\\Builders\\',
get_class($model)
) . 'SeoBuilder';
throw_if(
! class_exists($builderClass),
'SeoBuilder not found for model: ' . get_class($model)
);
return app($builderClass)->build($model);
}
}
App\Models\Post vira App\SEO\Builders\PostSeoBuilder. App\Models\Project vira App\SEO\Builders\ProjectSeoBuilder.
A diferença em relação a um mapeamento explícito — um match, um array de configuração, um switch — é que aqui a convenção é a regra. O sistema não precisa ser atualizado quando um novo Builder é criado. Se o arquivo existe no lugar certo, funciona. Se não existe, falha ruidosamente com uma exceção clara.
Isso é OCP — aberto para extensão via convenção, fechado para modificação dos mecanismos centrais.
#Os Builders: onde o SEO de cada conteúdo mora
Cada tipo de conteúdo tem um Builder dedicado. O PostSeoBuilder sabe tudo sobre como gerar SEO de um post. O ProjectSeoBuilder sabe tudo sobre projetos. A lógica fica contida — sem vazar para Model, Controller ou componente Vue.
Novo tipo de conteúdo amanhã? Novo arquivo em app/SEO/Builders/. Nenhum outro arquivo muda.
#O Model: delegando para o Resolver
Com o SeoResolver como ponto central, o Model não precisa conhecer o Builder diretamente. Ele implementa HasSeo e delega:
// app/Models/Post.php
class Post extends Model implements HasMedia, HasSeo, Sitemapable
{
// ...
public function getSeo(): SeoData
{
return app(SeoResolver::class)->resolve($this);
}
}
O Model não sabe que existe um PostSeoBuilder. Ele só sabe que implementa HasSeo e que o SeoResolver vai encontrar o responsável certo. Se um dia o Builder não existir, a exceção do throw_if vai avisar imediatamente — em desenvolvimento, não em produção com meta tag vazia.
#Três formas de entregar SEO
Dependendo do contexto, o sistema entrega SEO de três maneiras diferentes. Todas convergem para o mesmo SeoData no final.
Forma 1 — Model pages via PublicResource
Para páginas de conteúdo com um model associado, o PublicResource base cuida automaticamente:
// app/Http/Resources/Public/PublicResource.php
abstract class PublicResource extends JsonResource
{
public function with(Request $request): array
{
return [
'seo' => $this->seo(),
];
}
protected function seo(): ?array
{
if ($this->resource instanceof HasSeo) {
return app(SeoResolver::class)->resolve($this->resource)->toArray();
}
return null;
}
}
O with() do Resource entrega o SEO como chave separada no payload do Inertia — fora do data, no mesmo nível. O Controller não precisa saber que SEO existe.
Para páginas de detalhe onde o SEO precisa estar junto dos dados do modelo, o Resource específico pode incluí-lo diretamente no toArray():
// app/Http/Resources/Public/PublicPostShowResource.php
class PublicPostShowResource extends PublicResource
{
public function toArray(Request $request): array
{
return [
'title' => $this->title,
'slug' => $this->slug,
'body' => $this->html,
'excerpt' => $this->excerpt ?? $this->plain_text,
'word_count' => $this->word_count,
'reading_time' => $this->reading_time,
'published_at' => optional($this->published_at)->toIso8601String(),
'author' => $this->whenLoaded('author', fn (): array => [
'name' => $this->author->name,
'username' => $this->author->username,
'avatar' => $this->author?->profile_photo_url,
'location' => $this->author->location,
'social_links' => $this->author->social_links,
]),
'category' => $this->whenLoaded('category', fn (): array => [
'name' => $this->category->name,
'slug' => $this->category->slug,
]),
'tags' => $this->whenLoaded('tags', fn (): array => $this->tags->map(
fn (Tag $tag): array => ['name' => $tag->name, 'slug' => $tag->slug]
)->all()),
'cover' => $this->cover(),
'seo' => $this->seo(), // SEO embutido nos dados do post
];
}
}
Forma 2 — Coleções via SeoService
Para páginas de listagem sem um model específico, o SeoService é injetado diretamente na Collection:
// app/Http/Resources/Public/PublicProjectCollection.php
class PublicProjectCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => PublicProjectResource::collection($this->collection),
];
}
public function with(Request $request): array
{
return [
'seo' => app(SeoService::class)->forPage(
route: 'public.projects',
title: 'Projetos',
),
];
}
}
O Controller instancia a Collection e retorna. Nenhuma linha de SEO no Controller.
Forma 3 — Controller direto via SeoService
Para casos onde o Controller precisa de controle mais fino — breadcrumbs customizados, robots específicos, título dinâmico — o SeoService é injetado diretamente:
// app/Http/Controllers/Public/PostController.php
class PostController extends Controller
{
public function index(Request $request, SeoService $seo): Response
{
$posts = Post::published()
->with(['author', 'category', 'media', 'tags'])
->latest('published_at')
->paginate(12);
return Inertia::render('public/blog/Index', [
'posts' => new PublicPostCollection($posts),
'seo' => $seo->forPage(
route: 'public.blog.index',
title: 'Blog',
),
]);
}
public function show(Post $post, SeoService $seo): Response
{
$post->load(['author.media', 'category', 'media', 'tags']);
return Inertia::render('public/blog/Show', [
'post' => PublicPostShowResource::make($post)->resolve(),
'seo' => $seo->forPost($post),
]);
}
}
O SeoService centraliza os Builders com métodos expressivos: forPage(), forPost(), forProject(), forUser(). O Controller declara o que precisa — não como gera.
#A fonte da verdade global: GeneralSettings
Builders precisam de fallbacks. Qual imagem usar quando um post não tem cover? Qual descrição usar quando a página não define a sua?
A resposta não está em config/. Está no banco de dados, via spatie/laravel-settings:
// app/Settings/GeneralSettings.php
class GeneralSettings extends Settings
{
public string $site_name;
public string $site_description;
public ?string $site_keywords;
public ?string $og_image;
public ?array $social_links;
public string $site_author;
public string $site_locale;
public function parsedKeywords(): array
{
if (blank($this->site_keywords)) {
return [];
}
return array_values(array_filter(
array_map('trim', explode(',', $this->site_keywords))
));
}
public function ogImageUrl(): string
{
return ! blank($this->og_image)
? asset('storage/' . $this->og_image)
: asset('images/logo.png');
}
}
O administrador pode alterar nome do site, descrição global, imagem OG e social links diretamente no painel Filament — sem tocar código. Toda página sem metadados explícitos herda esses valores automaticamente.
Esses dados são compartilhados globalmente pelo Inertia via HandleInertiaRequests:
'site' => [
'name' => $settings->site_name,
'url' => route('home'),
'description' => $settings->site_description,
'og_image' => $settings->ogImageUrl(),
'keywords' => $settings->parsedKeywords(),
'author' => $settings->site_author,
'locale' => $settings->site_locale,
'social_links' => $settings->activeSocialLinks(),
],
O frontend recebe $page.props.site em toda requisição. O composable useSeo.ts monta a cascata: props da página → $page.props.site → valores padrão. Nunca uma meta tag vazia.
#O sitemap: não um arquivo, um sistema
A maioria dos projetos gera um sitemap.xml monolítico. Aqui funciona diferente.
O SitemapGenerator orquestra providers independentes, cada um responsável por um segmento:
// app/SEO/SitemapServiceProvider.php
$this->app->singleton(SitemapGenerator::class, function () {
return new SitemapGenerator([
new PageSitemapProvider,
new PostSitemapProvider,
new ProjectSitemapProvider,
new UserSitemapProvider,
]);
});
Cada provider implementa SitemapProvider, gera seu arquivo segmentado. O SitemapGenerator produz um índice sitemap.xml que aponta para cada segmento.
O Post model vai além: implementa Sitemapable do Spatie e declara sua própria entrada no sitemap — incluindo a imagem de cover quando existe:
// app/Models/Post.php
public function toSitemapTag(): Url|string|array
{
$url = Url::create(route('public.blog.show', $this->slug))
->setLastModificationDate($this->updated_at)
->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY)
->setPriority(0.8);
$cover = $this->getFirstMedia('cover');
if ($cover) {
$url->addImage($cover->getUrl(), $this->seo_title ?? $this->title);
}
return $url;
}
O ganho prático: no Google Search Console, você monitora a indexação de "Blog" separada de "Projetos" separada de "Usuários". Quando um segmento específico apresenta problema, você sabe exatamente onde olhar.
#O fluxo completo
Da requisição ao Googlebot, o caminho de um post:
Request chega em
/blog/meu-artigoPostController::show()carrega o post com relacionamentos, retornaPublicPostShowResource::make($post)->resolve()O
toArray()do Resource chama$this->seo(), herdado dePublicResourceO
seo()verifica quePostimplementaHasSeoe chamaSeoResolver::resolve($post)O
SeoResolvermapeiaApp\Models\Post→App\SEO\Builders\PostSeoBuildervia convenção de namespace. Se o Builder não existe,throw_iflança exceção imediatamenteO
PostSeoBuilderconstrói oSeoDatacom título, descrição,og:image,publishedAt, tags como keywords, canonical absolutoO
HandleInertiaRequestsinjetasitecom dados globais doGeneralSettingsem paralelo, em toda requisiçãoO Inertia SSR renderiza o Vue no servidor. O
SeoHead.vuelêpost.seoe$page.props.site, monta a cascata e preenche o<head>completoO HTML chega no browser com
<title>,<meta>, Open Graph,article:published_time, JSON-LD, canonical — tudo resolvido no servidorO Googlebot indexa. O WhatsApp gera preview. O usuário compartilha com contexto.
Sem <div id="app"> vazia. Sem SEO prometido e não entregue.
#Resumindo
SEO sem SSR é ilusão. Se o crawler não recebe HTML completo, não adianta nenhuma meta tag.
SEO espalhado em componente é dívida. Quando cada página cuida dos próprios metadados, você tem versões do mesmo problema repetido sem controle central.
Convenção de namespace é extensibilidade real. Novo tipo de conteúdo — novo Builder no lugar certo. Nenhum arquivo existente muda. Nenhum mapeamento para registrar.
throw_if em vez de fallback silencioso. Builder ausente deve falhar ruidosamente em desenvolvimento — não gerar meta tag vazia em produção sem ninguém perceber.
Três formas de entregar, um único formato de saída. Resource base, Collection, Controller direto — todos convergem para o mesmo SeoData. O frontend não sabe de onde veio.
Settings no banco, não em config/. O administrador controla os fallbacks globais sem abrir código.
Quando o nome da empresa é Marketing & Code, as duas disciplinas precisam funcionar juntas. Não basta o site existir. Ele precisa ser encontrado.
Esse post faz parte da série de insights do blog da MC — pensamentos diretos sobre desenvolvimento, arquitetura e o que aprendemos construindo sistemas reais.
Está construindo algo que precisa ranquear de verdade? Fala comigo.
