React 19 supports rendering <title>, <meta>, and <link> tags directly in
components. These tags are automatically hoisted to the document <head> during
rendering.
export default function BlogPost({ loaderData }: RouteProps) {
return (
<>
<title>{loaderData.post.title} | My Blog</title>
<meta name="description" content={loaderData.post.excerpt} />
<article>
<h1>{loaderData.post.title}</h1>
<p>{loaderData.post.content}</p>
</article>
</>
);
}This eliminates the need for third-party helmet libraries.
Set the page title using the <title> element:
// Static title
export default function About() {
return (
<>
<title>About Us | My App</title>
<h1>About Us</h1>
</>
);
}
// Dynamic title from loader data
export default function BlogPost({ loaderData }: RouteProps) {
return (
<>
<title>{loaderData.post.title}</title>
<article>{/* content */}</article>
</>
);
}
// Title with fallback
export default function Product({ loaderData }: RouteProps) {
const title = loaderData.product?.name || "Product";
return (
<>
<title>{title} | Store</title>
{/* content */}
</>
);
}Add meta tags for SEO and social sharing:
export default function BlogPost({ loaderData }: RouteProps) {
const { post } = loaderData;
return (
<>
{/* Basic SEO */}
<title>{post.title}</title>
<meta name="description" content={post.excerpt} />
<meta name="keywords" content={post.tags.join(", ")} />
{/* Robots */}
<meta name="robots" content="index, follow" />
{/* Canonical URL */}
<link rel="canonical" href={`https://example.com/blog/${post.slug}`} />
<article>{/* content */}</article>
</>
);
}Common meta tags:
// Prevent indexing (for private pages)
<meta name="robots" content="noindex, nofollow" />
// Viewport (usually in root layout)
<meta name="viewport" content="width=device-width, initial-scale=1" />
// Character encoding
<meta charSet="utf-8" />
// Author
<meta name="author" content="Your Name" />
// Theme color (for mobile browsers)
<meta name="theme-color" content="#10b981" />Add Open Graph tags for rich social media previews:
export default function BlogPost({ loaderData }: RouteProps) {
const { post } = loaderData;
const url = `https://example.com/blog/${post.slug}`;
return (
<>
<title>{post.title}</title>
<meta name="description" content={post.excerpt} />
{/* Open Graph */}
<meta property="og:type" content="article" />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.excerpt} />
<meta property="og:url" content={url} />
<meta property="og:image" content={post.coverImage} />
<meta property="og:site_name" content="My Blog" />
{/* Article-specific */}
<meta property="article:published_time" content={post.publishedAt} />
<meta property="article:author" content={post.author.name} />
{post.tags.map((tag) => (
<meta key={tag} property="article:tag" content={tag} />
))}
{/* Twitter Card */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={post.title} />
<meta name="twitter:description" content={post.excerpt} />
<meta name="twitter:image" content={post.coverImage} />
<article>{/* content */}</article>
</>
);
}Add JSON-LD structured data for rich search results:
export default function BlogPost({ loaderData }: RouteProps) {
const { post } = loaderData;
const structuredData = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.excerpt,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
"@type": "Person",
name: post.author.name,
},
};
return (
<>
<title>{post.title}</title>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<article>{/* content */}</article>
</>
);
}Common structured data types:
// Organization
const orgData = {
"@context": "https://schema.org",
"@type": "Organization",
name: "My Company",
url: "https://example.com",
logo: "https://example.com/logo.png",
};
// Product
const productData = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
image: product.images,
description: product.description,
offers: {
"@type": "Offer",
price: product.price,
priceCurrency: "USD",
},
};
// Breadcrumb
const breadcrumbData = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: "/" },
{ "@type": "ListItem", position: 2, name: "Blog", item: "/blog" },
{ "@type": "ListItem", position: 3, name: post.title },
],
};Set default metadata in your root layout and override in child routes:
// routes/main.tsx - Default metadata
export default function Main() {
return (
<>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="My awesome application" />
<link rel="icon" href="/favicon.ico" />
<title>My App</title>
<Outlet />
</>
);
}// routes/blog/index.tsx - Override for blog section
export default function BlogList() {
return (
<>
<title>Blog | My App</title>
<meta name="description" content="Read our latest blog posts" />
{/* Blog list content */}
</>
);
}// routes/blog/[id]/index.tsx - Dynamic metadata
export default function BlogPost({ loaderData }: RouteProps) {
return (
<>
<title>{loaderData.post.title} | My App</title>
<meta name="description" content={loaderData.post.excerpt} />
{/* Post content */}
</>
);
}React 19 automatically handles duplicate tags - the last rendered value wins, allowing child routes to override parent metadata.
Next: Database - Deno KV and other databases
Related topics:
- Routing - File-based routing and data loading
- Styling - CSS and TailwindCSS integration
- Static Files - Serving static assets