SEO
The core SEO kit every project ships with: one config/seo.php as the single source of truth, a master indexability switch, dynamic robots.txt, a named-route sitemap, full Open Graph / Twitter cards with a share image, canonical URLs, and JSON-LD structured data. All server-rendered — crawlers and link-preview scrapers get everything without executing a line of JavaScript.
The master switch — SEO_INDEXABLE
The single most useful idea here: one env flag controls whether the site exists to search engines.
// config/seo.php (top of file)
'indexable' => env('SEO_INDEXABLE', false),
- Default false — pre-launch, staging, and demo sites emit
<meta name="robots" content="noindex, nofollow">on every page androbots.txtdisallows all crawling. You can still verify the domain in Search Console (verification doesn't require indexing). - Set
SEO_INDEXABLE=truein the production.envonly at launch — that one change opens crawling + indexing site-wide.
Danger — Staging is the classic leak: an indexable staging clone competes with production in search results. The
falsedefault means a forgotten env key fails closed, never open.
config/seo.php
Everything meta lives here — site defaults, OG copy, structured-data fields, and a per-route override map:
<?php
return [
'indexable' => env('SEO_INDEXABLE', false),
// Site-wide fallbacks
'site_name' => 'My App',
'default_title' => 'My App — What It Does, Where',
'default_description' => 'One or two sentences. This is your search snippet — write it like ad copy, 150–160 chars.',
// Open Graph / Twitter (the social headline can differ from <title>)
'og_title' => 'The marketing headline for shares',
'og_description' => 'The share-card blurb.',
'og_image' => '/images/og/default.png',
'twitter_handle' => '@myapp',
'locale' => 'en_GB',
// Structured data (schema.org)
'logo' => '/images/favicon.png',
'areas_served' => ['City One', 'City Two'],
'same_as' => array_values(array_filter([
env('SEO_FACEBOOK_URL'),
env('SEO_INSTAGRAM_URL'),
env('SEO_LINKEDIN_URL'),
])),
// Per-route meta, keyed by route name
'pages' => [
'static.about' => [
'title' => 'About My App',
'description' => 'Page-specific snippet.',
],
'static.contact' => [
'title' => 'Contact My App',
'description' => '…',
],
],
];
Meta resolution order (implemented in the layout): ① explicit prop/page value → ② seo.pages map by current route name → ③ site-wide defaults. Static pages never touch a controller to get correct meta — naming the route is enough.
The <head> block
In an Inertia app this belongs in the root Blade template (resources/views/app.blade.php) — link-preview scrapers (WhatsApp, Slack, X, Facebook) don't run JS, so OG tags rendered by React are invisible to them. Use Inertia's <Head> component only for the client-side <title> on navigation.
@php
$routeName = request()->route()?->getName();
$pageMeta = config("seo.pages.{$routeName}", []);
$metaTitle = $title ?? $pageMeta['title'] ?? config('seo.default_title');
$metaDescription = $description ?? $pageMeta['description'] ?? config('seo.default_description');
$ogTitle = $title ?? $pageMeta['title'] ?? config('seo.og_title');
$ogDescription = $description ?? $pageMeta['description'] ?? config('seo.og_description');
$canonicalUrl = url()->current();
$ogImageUrl = url(config('seo.og_image'));
@endphp
@unless(config('seo.indexable'))
<meta name="robots" content="noindex, nofollow">
@endunless
<title>{{ $metaTitle }}</title>
<meta name="description" content="{{ $metaDescription }}">
<link rel="canonical" href="{{ $canonicalUrl }}">
<meta property="og:title" content="{{ $ogTitle }}">
<meta property="og:description" content="{{ $ogDescription }}">
<meta property="og:image" content="{{ $ogImageUrl }}">
<meta property="og:image:alt" content="{{ config('seo.site_name') }} — preview">
<meta property="og:url" content="{{ $canonicalUrl }}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{ config('seo.site_name') }}">
<meta property="og:locale" content="{{ config('seo.locale') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="{{ config('seo.twitter_handle') }}">
<meta name="twitter:title" content="{{ $ogTitle }}">
<meta name="twitter:description" content="{{ $ogDescription }}">
<meta name="twitter:image" content="{{ $ogImageUrl }}">
The share image (og/default.png)
The image behind every social share. Specs that matter:
| Property | Value |
|---|---|
| Size | 1200 × 630 px (the summary_large_image / OG standard, ~1.91:1) |
| Format | PNG or JPG; keep it well under ~1 MB (some scrapers cap at 300 KB — aim there) |
| Location | public/images/og/default.png — a stable path; scrapers cache aggressively |
| URL | Must be absolute (url(...) in the layout handles it) |
| Content | Logo + headline readable at thumbnail size; safe margins ≥ 60 px (platforms crop edges) |
| Alt | Always ship og:image:alt |
A pragmatic default: screenshot the polished homepage at 1200×630 and overlay the wordmark. Per-page share images (e.g. per product/article) follow the same resolution order — an explicit prop overriding the default.
Tip — After changing the image, force-refresh the scraper caches: Facebook Sharing Debugger, LinkedIn Post Inspector, and X card validator. Otherwise the old card can linger for days.
Dynamic robots.txt
Serve it from a route, not a static file, so the master switch controls it too:
// routes/web.php
Route::get('/robots.txt', function () {
$lines = config('seo.indexable')
? ['User-agent: *', 'Disallow:', '', 'Sitemap: '.url('/sitemap.xml')]
: ['User-agent: *', 'Disallow: /'];
return response(implode("\n", $lines), 200)
->header('Content-Type', 'text/plain');
})->name('robots');
Delete public/robots.txt — the physical file would shadow the route.
Sitemap
A small controller over named routes (they're already the app's URL source of truth via Ziggy), rendered through a Blade view to XML:
// app/Http/Controllers/SitemapController.php
class SitemapController extends Controller
{
public function index(): Response
{
$routes = [
['url' => route('home'), 'priority' => '1.0', 'changefreq' => 'weekly'],
['url' => route('static.about'), 'priority' => '0.8', 'changefreq' => 'monthly'],
['url' => route('static.contact'), 'priority' => '0.6', 'changefreq' => 'yearly'],
['url' => route('static.privacy'), 'priority' => '0.3', 'changefreq' => 'yearly'],
];
return response(view('sitemap', ['routes' => $routes])->render(), 200)
->header('Content-Type', 'application/xml');
}
}
{{-- resources/views/sitemap.blade.php --}}
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@foreach ($routes as $r)
<url>
<loc>{{ $r['url'] }}</loc>
<priority>{{ $r['priority'] }}</priority>
<changefreq>{{ $r['changefreq'] }}</changefreq>
</url>
@endforeach
</urlset>
Only list public, indexable pages — never auth, dashboards, or anything behind login. For dynamic content (products, articles), append a DB query's URLs to the array; if that grows past a few thousand URLs, graduate to spatie/laravel-sitemap with a scheduled generation command.
Structured data (JSON-LD)
One <script type="application/ld+json"> in the same layout, built from config so it can't drift from the meta:
@php
$structuredData = array_filter([
'@context' => 'https://schema.org',
'@type' => 'LocalBusiness', // or Organization / WebSite per project
'name' => config('seo.site_name'),
'url' => url('/'),
'logo' => url(config('seo.logo')),
'description' => config('seo.default_description'),
'areaServed' => config('seo.areas_served'),
'sameAs' => config('seo.same_as'),
], fn ($v) => $v !== [] && $v !== null);
@endphp
<script type="application/ld+json">
{!! json_encode($structuredData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
</script>
Validate with Google's Rich Results Test. Pick the @type per project — LocalBusiness for location-based services, Organization or WebSite otherwise.
Launch checklist
SEO_INDEXABLE=truein the production.envonlyog/default.pngin place (1200×630), share card verified in the Facebook debugger/robots.txtshowsDisallow:(empty) + the sitemap line/sitemap.xmlrenders and lists only public pages- Canonical URLs use the production domain (
APP_URLcorrect) - JSON-LD passes the Rich Results Test
- Domain verified in Search Console; sitemap submitted
Next
- Back to Overview