React Helmet is great. It's also too much for a SPA with twelve dynamic blog routes. Here's what I shipped instead.
I run Inithouse, a portfolio of about 14 web products. All built as React SPAs. Every single one needs proper meta tags for SEO: title, description, Open Graph, Twitter cards, JSON-LD. The usual stuff.
I started with React Helmet. Worked fine for the first app. By app three, I was copy-pasting the same boilerplate helmet component across repos, fighting with nested helmet instances, and wondering why my bundle had an extra 12KB just to set a page title.
So I wrote a hook. About 20 lines. It replaced Helmet across every project.
The hook
import { useEffect } from 'react';
interface SEOProps {
title: string;
description?: string;
image?: string;
url?: string;
type?: string;
jsonLd?: Record<string, unknown>;
}
export function useSEO({ title, description, image, url, type = 'website', jsonLd }: SEOProps) {
useEffect(() => {
document.title = title;
const setMeta = (name: string, content: string) => {
if (!content) return;
let el = document.querySelector(\`meta[name="\${name}"], meta[property="\${name}"]\`);
if (!el) {
el = document.createElement('meta');
el.setAttribute(name.startsWith('og:') || name.startsWith('article:') ? 'property' : 'name', name);
document.head.appendChild(el);
}
el.setAttribute('content', content);
};
if (description) {
setMeta('description', description);
setMeta('og:description', description);
setMeta('twitter:description', description);
}
setMeta('og:title', title);
setMeta('twitter:title', title);
setMeta('og:type', type);
if (image) {
setMeta('og:image', image);
setMeta('twitter:image', image);
setMeta('twitter:card', 'summary_large_image');
}
if (url) {
setMeta('og:url', url);
const link = document.querySelector('link[rel="canonical"]') || document.createElement('link');
link.setAttribute('rel', 'canonical');
link.setAttribute('href', url);
if (!link.parentNode) document.head.appendChild(link);
}
if (jsonLd) {
let script = document.getElementById('json-ld-seo');
if (!script) {
script = document.createElement('script');
script.id = 'json-ld-seo';
script.type = 'application/ld+json';
document.head.appendChild(script);
}
script.textContent = JSON.stringify(jsonLd);
}
return () => {
document.getElementById('json-ld-seo')?.remove();
};
}, [title, description, image, url, type, jsonLd]);
}
That's it. No provider, no context, no side-channel for server rendering. Just a hook that touches the DOM.
How I use it
Every page or route component calls it at the top:
function BlogPost({ post }) {
useSEO({
title: \`\${post.title} | My App\`,
description: post.excerpt,
image: post.heroImage,
url: \`https://myapp.com/blog/\${post.slug}\`,
type: 'article',
jsonLd: {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
author: { '@type': 'Person', name: 'Jakub' },
datePublished: post.publishedAt,
image: post.heroImage,
},
});
return <article>...</article>;
}
Blog index, product landing, about page, FAQ. Same pattern everywhere. The hook handles the meta tag creation, update, and JSON-LD injection in one call.
Why not React Helmet?
Couple of reasons that mattered for my setup:
Bundle size. React Helmet pulls in react-side-effect and handles nested instances with a priority queue. For a SPA where only one component sets meta at a time, that's dead code.
SSR complexity. Helmet has a whole server rendering story with Helmet.renderStatic(). If you're doing SSR, great. My apps are client-rendered SPAs deployed to static hosting. I don't need server extraction. Google crawls JavaScript fine for most content.
Mental model. Helmet feels like a component you render. The hook feels like a side effect you declare. For meta tags (which are side effects), the hook model clicks better with how React works now.
Edge cases I hit
Route changes. React Router triggers a re-render, the hook runs again, meta updates. Clean. But if you navigate from a page with JSON-LD to one without, the old script tag stays unless you clean up. The return cleanup in the useEffect handles this.
Multiple hooks on one page. If two components both call useSEO, the last one wins. That's usually fine. The page-level component should be the one setting meta. If you need nested overrides, you need Helmet. I never did.
Missing description. Some crawlers penalize pages without a meta description. The hook only sets it if you pass one. I added a lint rule: every route component must pass at least title + description.
Dynamic content. Blog posts loaded from an API. The hook runs on mount with empty strings, then re-runs when data arrives. Brief flash of wrong meta. Not great for social share previews if a bot hits it before data loads. I prerender critical pages with a simple build step for this.
JSON-LD per route
This was the real win. Each product in the portfolio has different structured data needs. Be Recommended needs SoftwareApplication schema. Magical Song needs Product + MusicComposition. Blog posts need BlogPosting.
With Helmet, I'd nest JSON-LD scripts inside JSX which felt wrong. With the hook, JSON-LD is just another parameter. Type-safe, cleaned up on unmount, one script tag in the head.
When you should NOT use this
If you need SSR meta extraction, use Helmet or a framework-level solution (Next.js Head, Remix meta). If you have deeply nested components that all want to contribute meta tags, use Helmet's priority system.
For client-rendered SPAs where one component per route owns the page meta? A hook is simpler, smaller, and easier to reason about.
The pattern scales
I've shipped this across Audit Vibe Coding, Watching Agents, Voice Tables, and about ten other projects. Copy the hook file, import it, done. No package to install, no version to track.
Sometimes the boring solution is the right one.
Building 14 products at Inithouse. Writing about what actually ships.
Top comments (0)