Master SEO in Inertia.js: A Clear, Practical Guide

Inertia.js apps render on the client, which can make SEO tricky. This guide shows how to add proper SEO using a reusable component.
Why SEO Matters for Inertia.js Apps
Even though Inertia isn’t a full-blown SPA framework, it still renders pages dynamically and relies heavily on JavaScript. For marketing sites, blogs, landing pages, SaaS dashboards, or any content that needs ranking, proper SEO ensures:
- Your content gets indexed correctly
- Titles and descriptions reflect each page
- Social media previews display cleanly
- Google understands your structure and relevance
How Inertia.js Handles SEO
Inertia.js does not render HTML on the client side like classic SPAs. Instead:
- Your backend (Laravel, Rails, etc.) still controls routes and HTML.
- Pages load via JSON responses and dynamic component replacement.
- Metadata must be manually handled—especially titles, descriptions, and social tags.
The solution: a reusable SEO component that sets meta tags for each page.
Building the SEO Component
Create a component that handles all meta tags.
// resources/js/components/seo.tsximport { Head, usePage } from '@inertiajs/react';import { SharedData } from '@/types'; interface SEOProps { title?: string; description?: string; image?: string; imageAlt?: string; url?: string; type?: 'website' | 'article'; publishedTime?: string; modifiedTime?: string; author?: string; keywords?: string[]; noindex?: boolean; nofollow?: boolean;} export default function SEO({
title = 'Mountaga Diao Leye Diop | モンタガ', description = 'Fullstack Web Developer crafting high-performance web applications with Laravel & React JS. Passionate about clean code, scalable solutions, and bringing ideas to life through technology.', image, imageAlt, url, type = 'website', publishedTime, modifiedTime, author = 'Mountaga Diao Leye Diop', keywords = [], noindex = false, nofollow = false,}: SEOProps) { const page = usePage<SharedData>(); const appUrl = page.props.appUrl ? String(page.props.appUrl) : ''; const siteUrl = appUrl || (typeof window !== 'undefined' ? String(window.location.origin) : 'http://mdld.test'); const fullUrl = url ? String(`${siteUrl}${url}`) : String(siteUrl); const fullImage = image ? (image.startsWith('http') ? String(image) : String(`${siteUrl}${image}`)) : String(`${siteUrl}/favicon.png`); const robotsContent = String([ noindex ? 'noindex' : 'index', nofollow ? 'nofollow' : 'follow', ].join(', ')); const titleString = String(title); const descriptionString = String(description); const typeString = String(type); const authorString = String(author); const imageAltString = imageAlt ? String(imageAlt) : ''; return ( <Head> <title>{titleString}</title> <meta head-key="description" name="description" content={descriptionString} /> {keywords.length > 0 && ( <meta head-key="keywords" name="keywords" content={String(keywords.join(', '))} /> )} <meta head-key="author" name="author" content={authorString} /> <meta head-key="robots" name="robots" content={robotsContent} /> <meta head-key="og:type" property="og:type" content={typeString} /> <meta head-key="og:url" property="og:url" content={fullUrl} /> <meta head-key="og:title" property="og:title" content={titleString} /> <meta head-key="og:description" property="og:description" content={descriptionString} /> <meta head-key="og:image" property="og:image" content={fullImage} /> {imageAltString && ( <meta head-key="og:image:alt" property="og:image:alt" content={imageAltString} /> )} <meta head-key="og:site_name" property="og:site_name" content="Mountaga Diao Leye Diop | モンタガ" /> <meta head-key="og:locale" property="og:locale" content="en_US" /> {type === 'article' && publishedTime && ( <meta head-key="article:published_time" property="article:published_time" content={String(publishedTime)} /> )} {type === 'article' && modifiedTime && ( <meta head-key="article:modified_time" property="article:modified_time" content={String(modifiedTime)} /> )} {type === 'article' && author && ( <meta head-key="article:author" property="article:author" content={authorString} /> )} <meta head-key="twitter:card" name="twitter:card" content="summary_large_image" /> <meta head-key="twitter:url" name="twitter:url" content={fullUrl} /> <meta head-key="twitter:title" name="twitter:title" content={titleString} /> <meta head-key="twitter:description" name="twitter:description" content={descriptionString} /> <meta head-key="twitter:image" name="twitter:image" content={fullImage} /> {imageAltString && ( <meta head-key="twitter:image:alt" name="twitter:image:alt" content={imageAltString} /> )} <meta head-key="twitter:creator" name="twitter:creator" content="@MDLD_" /> <link head-key="canonical" rel="canonical" href={fullUrl} /> </Head> );}
What This Component Does
- Accepts
propswith defaults so you only pass what changes. - Builds full URLs for
SSRand client-side. - Handles absolute and relative image paths.
- Uses
head-keyso Inertia updates tags on navigation. - Supports basic SEO, Open Graph, Twitter, and article tags.
Understanding the Code
URL Handling
const siteUrl = page.props.appUrl || window.location.origin;const fullUrl = url ? `${siteUrl}${url}` : siteUrl;
In SSR, use appUrl from props. On the client, fall back to window.location.origin. Always build full URLs for social sharing.
Image Handling
const fullImage = image ? (image.startsWith('http') ? image : `${siteUrl}${image}`) : `${siteUrl}/favicon.png`;
Social platforms need absolute URLs. If the image is already absolute, use it; otherwise prepend the site URL. If no image is provided, use the favicon.
The head-key Attribute
<meta head-key="description" name="description" content={description} />
Inertia uses head-key to identify and update meta tags on navigation. Without it, tags may not update when navigating between pages.
Default Values
title = 'Your Default Title',description = 'Fullstack Web Developer...'
Defaults values let you skip props on simple pages. Override them when needed.
Using the Component
Example 1: Home Page
import SEO from '@/components/seo'; export default function Home() { return ( <MainLayout> <SEO title="Home" description="Welcome to my website" url="/" /> {/* Your page content */} </MainLayout> );}
Explanation: Only pass what differs from defaults. The component handles the rest.
Example 2: Blog Post
export default function PostShow({ post }) { return ( <MainLayout> <SEO title={post.title} description={post.excerpt} image={post.image ? `/storage/${post.image}` : ''} imageAlt={post.image_alt || post.title} url={`/posts/${post.slug}`} type="article" publishedTime={post.published_at} /> {/* Post content */} </MainLayout> );}
Explanation:
type="article"tells platforms this is a blog post.publishedTimehelps with article-specific features.- Image and alt text improve social previews.
Example 3: List Page
export default function PostsIndex() { return ( <MainLayout> <SEO title="Posts" description="All blog posts" url="/posts" keywords={['blog', 'posts', 'articles']} /> {/* Posts list */} </MainLayout> );}
Explanation: Keywords are optional. Use them for list/category pages if helpful.
Understanding Meta Tags
Basic SEO Tags
<title>: Page title in browser tabs and search results.<meta name="description">: Snippet in search results (keep it under 160 characters).<meta name="keywords">: Optional; use sparingly.
Open Graph Tags (Facebook, LinkedIn)
og:type:websiteorarticle.og:url: Full URL of the page.og:title: Title for social shares.og:description: Description for social shares.og:image: Image for social shares (1200x630px recommended).
Open Graph tags control how links appear when shared on Facebook, LinkedIn, and similar platforms.
Twitter Card Tags
twitter:card:summary_large_imageshows a large image.twitter:title,twitter:description,twitter:image: Content for Twitter.
Twitter uses its own tags. If Open Graph tags are present, Twitter may use them, but explicit Twitter tags give more control.
Article-Specific Tags
article:published_time: When the article was published (ISO format like 2024-01-15T10:00:00Z).
Why: Helps search engines and platforms understand the content better.
Canonical URL
<link rel="canonical" href={fullUrl} />
Tells search engines which URL is the primary version, avoiding duplicate content issues.
Real Examples from my Blog
<SEO title={post.title} description={post.excerpt || post.title} image={postImage || ''} imageAlt={post.image_alt || post.title} url={postUrl} type="article" publishedTime={post.published_at} modifiedTime={post.published_at}/>
type="article" tells platforms this is a blog post, enabling article-specific features and the fallback description is there if there’s no excerpt to use the title as the description.
Conclusion: That’s It!
Inertia.js is powerful, flexible, and modern—but SEO isn’t automatic. Thankfully, you don’t need heavy SSR engines or complex setups. A few simple steps are enough to make your app discoverable:
- Manage titles and metadata
- Use clean URLs
- Add structured data when relevant
- Optimize performance
- Ensure your pages can be crawled
Follow these guidelines, and your Inertia.js app will not only feel fast—it will be visible, discoverable, and ranking.