@hiprax/use-seo
v0.3.1
Published
A production-ready React hook for managing SEO and social meta tags with full TypeScript support
Maintainers
Readme
use-seo
A production-ready React hook for managing SEO meta tags, Open Graph, Twitter Cards, structured data (JSON-LD), and more. Fully typed with TypeScript and optimized for all React versions (16.8+).
View on NPM | GitHub Repository
Table of Contents
- Features
- Installation
- Quick Start
- Module Formats (ESM / CJS)
- API Reference
- Basic SEO
- Title Formatting
- Open Graph
- Open Graph Video
- Open Graph Audio
- Article Extensions
- Twitter Cards
- Twitter Player Card
- Article Metadata
- Robots Directives
- International SEO (Hreflang)
- Pagination
- Structured Data (JSON-LD)
- Additional Custom Tags
- Tag Precedence (built-in vs additional)
- Common Recipes
- Advanced Options
- Lifecycle / Cleanup
- Performance Notes
- Hook Return Methods
- TypeScript Support
- SSR Considerations
- Constants
- Best Practices
- Development
- Coverage
- Browser Support
- Contributing
- License
Features
- 🎯 Complete SEO Management - Title, description, keywords, canonical URLs, and more
- 📱 Open Graph Support - Full OG tag support including multiple images and locale alternates
- 🐦 Twitter Cards - Summary, summary_large_image, app, and player cards
- 🤖 Robots Control - Flexible robots meta with Googlebot-specific directives
- 🌍 International SEO - Hreflang alternates for multi-language sites
- 📊 Structured Data - JSON-LD support for rich search results
- ⚡ Performance Optimized - Minimal re-renders with change detection
- 🔒 SSR Safe - Works with Next.js, Remix, and other SSR frameworks
- 📝 Fully Typed - Complete TypeScript support with IntelliSense
- 🧪 High test coverage - See the Coverage section for the latest report
Installation
npm install @hiprax/use-seoyarn add @hiprax/use-seopnpm add @hiprax/use-seoPeer dependencies: react >= 16.8.0 and react-dom >= 16.8.0
Quick Start
import { useSEO } from '@hiprax/use-seo';
// or: import useSEO from '@hiprax/use-seo';
function ProductPage() {
useSEO({
title: 'Amazing Product',
titleSuffix: 'My Store',
description:
'The best product you will ever find. High quality and great value.',
canonical: 'https://mystore.com/products/amazing-product',
ogImage: 'https://mystore.com/images/product.jpg',
});
return <div>Product content</div>;
}Module Formats (ESM / CJS)
The package is published as a dual-format bundle (dist/index.js for ESM, dist/index.cjs for CJS) with TypeScript declarations for both. Both ergonomic forms below work in either format.
// ESM (modern bundlers, Vite, Webpack 5, Rollup, esbuild, Next.js, Remix, ...)
import { useSEO } from '@hiprax/use-seo';
import useSEO from '@hiprax/use-seo'; // default re-export of the same function
// CommonJS (Node scripts, classic toolchains)
const { useSEO } = require('@hiprax/use-seo');
const useSEO = require('@hiprax/use-seo'); // also works — see note belowCJS interop note: Earlier alpha builds of
@hiprax/use-seoexposed the hook only as a.defaultproperty when consumed viarequire(), soconst useSEO = require('@hiprax/use-seo')returned an object instead of the function. Starting with0.2.3the CJS bundle uses a small interop footer that re-pointsmodule.exportsto the default export and re-attaches every named export as a property on it, so all three ofconst useSEO = require(...),const { useSEO } = require(...), andrequire(...).defaultresolve to the same callable hook. The TypeScript declarations underdist/index.d.ts(CJS) anddist/index.d.mts(ESM) describe the same shape; the package'sexportsfield maps each consumer to the right one automatically undermoduleResolution: "node16" | "nodenext" | "bundler".
API Reference
Basic SEO
useSEO({
// Page title (recommended: 30-60 characters)
title: 'Page Title',
// Meta description (recommended: 120-160 characters)
description: 'A compelling description of your page content.',
// Meta keywords (comma-separated, max 10 recommended)
keywords: 'react, seo, meta tags',
// Canonical URL
canonical: 'https://example.com/page',
// Auto-generate canonical from current URL (default: true)
autoCanonical: true,
// Page language (sets <html lang="">)
language: 'en',
// Content author
author: 'John Doe',
});Title Formatting
// With suffix (result: "Contact | My Site")
useSEO({
title: 'Contact',
titleSuffix: 'My Site',
});
// With prefix (result: "My Site | Contact")
useSEO({
title: 'Contact',
titlePrefix: 'My Site',
});
// With template using %s placeholder
useSEO({
title: 'Contact',
titleTemplate: '%s - My Website',
});
// With template using {title} placeholder
useSEO({
title: 'Contact',
titleTemplate: '{title} | Brand',
});
// With custom separator (default: ' | ')
useSEO({
title: 'Contact',
titleSuffix: 'My Site',
titleSeparator: ' - ',
// Result: "Contact - My Site"
});Open Graph
useSEO({
// OG type (default: 'website')
ogType: 'article',
// Site name
ogSiteName: 'My Website',
// OG title (falls back to formatted title)
ogTitle: 'Article Title',
// OG description (falls back to description)
ogDescription: 'Article description for social sharing.',
// OG URL (falls back to canonical)
ogUrl: 'https://example.com/article',
// Locale
ogLocale: 'en_US',
// Alternate locales
ogLocaleAlternates: ['en_GB', 'de_DE', 'fr_FR'],
// Single image (simple)
ogImage: 'https://example.com/og-image.jpg',
ogImageWidth: 1200,
ogImageHeight: 630,
ogImageAlt: 'Description of the image',
// Multiple images with full metadata (preferred)
ogImages: [
{
url: 'https://example.com/image1.jpg',
width: 1200,
height: 630,
alt: 'Primary image',
type: 'image/jpeg',
secureUrl: 'https://example.com/image1.jpg', // optional, auto-inferred for https URLs
},
{
url: 'https://example.com/image2.png',
width: 800,
height: 600,
alt: 'Secondary image',
},
],
});Open Graph Video
Typed support for og:video and its sub-properties. Use ogVideos for full
metadata or ogVideo for a quick single-URL shorthand.
useSEO({
ogVideos: [
{
url: 'https://example.com/video.mp4',
type: 'video/mp4',
width: 1280,
height: 720,
alt: 'Demo video',
// secureUrl is auto-inferred when `url` starts with https:
secureUrl: 'https://example.com/video.mp4',
},
],
});
// Single-video shorthand (mirrors the legacy `ogImage` form)
useSEO({
ogVideo: 'https://example.com/video.mp4',
});Emits <meta property="og:video">, og:video:secure_url, og:video:type,
og:video:width, og:video:height, and og:video:alt. Stale tags are
cleaned up on re-render when the prop is removed (parity with ogImages).
Open Graph Audio
Typed support for og:audio and its sub-properties.
useSEO({
ogAudios: [
{
url: 'https://example.com/audio.mp3',
type: 'audio/mpeg',
},
],
});
// Single-audio shorthand
useSEO({
ogAudio: 'https://example.com/audio.mp3',
});Emits <meta property="og:audio">, og:audio:secure_url, and
og:audio:type. URLs are validated against validateUrls.
Article Extensions
When ogType: 'article', the OG Article extension supports several typed
properties for the article's authorship and topical metadata:
useSEO({
ogType: 'article',
// Single author (URL or identifier)
articleAuthor: 'https://example.com/authors/jane',
// Or multiple authors — each emits its own meta tag
// articleAuthor: [
// 'https://example.com/authors/jane',
// 'https://example.com/authors/john',
// ],
// Section / category
articleSection: 'Technology',
// Topic tags — each emits its own meta tag
articleTags: ['React', 'TypeScript', 'SEO'],
// Existing typed dates
publishedTime: '2024-01-15T10:30:00Z',
modifiedTime: '2024-02-01T14:20:00Z',
expirationTime: '2025-12-31T23:59:59Z',
});articleAuthor accepts either a single string or string[]. URL-shaped
values (https?://…) are validated against validateUrls; plain text
identifiers (e.g., "Jane Doe") pass through unchanged. Multi-value tags
follow the same cleanup-on-removal pattern as ogImages and
ogLocaleAlternates.
Twitter Cards
useSEO({
// Card type (default: 'summary_large_image')
twitterCard: 'summary_large_image',
// Twitter title (falls back to ogTitle or title)
twitterTitle: 'Tweet-optimized Title',
// Twitter description (falls back to ogDescription or description)
twitterDescription: 'Description for Twitter.',
// Twitter image (falls back to ogImage or first ogImages entry)
twitterImage: 'https://example.com/twitter-image.jpg',
twitterImageAlt: 'Image description',
// Twitter handles (include @)
twitterCreator: '@author_handle',
twitterSite: '@site_handle',
});Twitter Player Card
Typed support for the Twitter / X Player Card, which lets you embed an
in-timeline media player. Use these fields together with
twitterCard: 'player'.
useSEO({
twitterCard: 'player',
// HTTPS URL of the player iframe
twitterPlayer: 'https://example.com/player',
twitterPlayerWidth: 640,
twitterPlayerHeight: 360,
// Optional raw stream for direct timeline playback
twitterPlayerStream: 'https://example.com/stream.mp4',
twitterPlayerStreamContentType: 'video/mp4',
});Emits <meta name="twitter:player">, twitter:player:width,
twitter:player:height, twitter:player:stream, and
twitter:player:stream:content_type. URL fields are validated against
validateUrls.
Article Metadata
useSEO({
ogType: 'article',
// Publication date (ISO 8601)
publishedTime: '2024-01-15T10:30:00Z',
// Last modification date (ISO 8601)
modifiedTime: '2024-02-01T14:20:00Z',
// Expiration date (ISO 8601)
expirationTime: '2025-12-31T23:59:59Z',
});Robots Directives
// Simple string format
useSEO({
robots: 'noindex,nofollow',
});
// Detailed object format
useSEO({
robots: {
index: true,
follow: true,
noarchive: false,
nosnippet: false,
noimageindex: false,
maxSnippet: 150,
maxImagePreview: 'large',
maxVideoPreview: 30,
// Time-limited de-indexing — RFC 850 or ISO 8601 datetime
unavailableAfter: '2025-12-31T23:59:59Z',
// Googlebot-specific directives
googlebot: {
index: true,
follow: true,
maxVideoPreview: 0,
},
},
});Note: The deprecated boolean props
noindex,nofollow,noarchive,nosnippet, andnoimageindexare still supported for backwards compatibility, but using therobotsobject format above is preferred.
Precedence: When both the
robotsprop and the deprecated boolean flags are passed at the same time,robotswins — the deprecated flags are only consulted whenrobotsisundefined. To explicitly emit the positive directivesindexandfollow(for example, to override a parent<meta name="robots" content="noindex">injected by Tag Manager or by a layout component), passrobots: { index: true, follow: true }; the hook will emit the literal stringindex,followrather than producing an empty robots tag.Tri-state semantics for
index/follow:
true→ emit the positive directive (index/follow)false→ emit the negative directive (noindex/nofollow)undefined→ omit the directive entirely (the search-engine default applies)
International SEO (Hreflang)
useSEO({
hreflangs: [
{ href: 'https://example.com/', hrefLang: 'x-default' },
{ href: 'https://example.com/en/', hrefLang: 'en' },
{ href: 'https://example.com/en-gb/', hrefLang: 'en-GB' },
{ href: 'https://example.com/de/', hrefLang: 'de' },
{ href: 'https://example.com/es/', hrefLang: 'es' },
],
});Pagination
useSEO({
prev: 'https://example.com/posts?page=1',
next: 'https://example.com/posts?page=3',
});Structured Data (JSON-LD)
// Single schema
useSEO({
structuredData: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'Article Title',
author: {
'@type': 'Person',
name: 'John Doe',
},
datePublished: '2024-01-15',
dateModified: '2024-02-01',
image: ['https://example.com/image.jpg'],
},
});
// Multiple schemas
useSEO({
structuredData: [
{
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'Article Title',
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: 'https://example.com/',
},
{
'@type': 'ListItem',
position: 2,
name: 'Blog',
item: 'https://example.com/blog',
},
],
},
],
});Additional Custom Tags
useSEO({
// Custom meta tags
additionalMetaTags: [
{ name: 'theme-color', content: '#000000' },
{ name: 'format-detection', content: 'telephone=no' },
{ property: 'fb:app_id', content: '123456789' },
{ httpEquiv: 'content-language', content: 'en' },
],
// Custom link tags
additionalLinkTags: [
{ rel: 'icon', href: '/favicon.ico', type: 'image/x-icon' },
{
rel: 'apple-touch-icon',
href: '/apple-touch-icon.png',
sizes: '180x180',
},
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preload',
href: '/fonts/main.woff2',
as: 'font',
type: 'font/woff2',
crossOrigin: 'anonymous',
},
],
});Tag Precedence (built-in vs additional)
When the same <head> element is targeted by BOTH a typed built-in prop
(e.g. description, ogTitle, twitterImage, canonical) AND by an
additionalMetaTags / additionalLinkTags entry whose name,
property, or httpEquiv matches the built-in, the
additionalMetaTags / additionalLinkTags entry wins. The same rule
applies between two additionalMetaTags entries that share an
identifier — the LAST one in array order is the value that ends up on
the element.
This is a deliberate consequence of two implementation details:
- The hook applies built-in props FIRST, then walks
additionalMetaTagsandadditionalLinkTagsin order. - With the default
preventDuplicates: true, the helper that creates a meta/link tag will REUSE any existing element with a matching key (whether the hook created it on the same render, the hook created it on a previous render, or the user authored it directly in the HTML) and overwrite itscontent/href. So the additional-tag pass mutates the very same element the built-in pass just emitted.
Practical consequences:
- You can override a built-in field on a per-page basis without losing
the typed convenience for the other fields:
useSEO({ description: 'Default description', additionalMetaTags: [ // Wins over `description` above on this page. { name: 'description', content: 'Promotional description' }, ], }); - You can use the additional-tag arrays to inject variants the typed
props don't directly model (e.g. a
media-scopedtheme-color) without competing with the built-in pass. - If you author a
<meta name="description">directly in your HTML shell AND passdescriptionto the hook, the hook will mutate your static element on the first render —clearSEOTags()will NOT remove it later because the hook only removes elements that carry thedata-use-seo="true"marker (set ONLY on the hook's create path).
If you instead want the typed prop to win, omit the matching entry from
additionalMetaTags (or pass preventDuplicates: false, which causes
the additional-tag pass to APPEND a duplicate element rather than
mutating the built-in element — both will be present in the DOM, which
is rarely what you want).
Common Recipes
The hook covers the most-used SEO surface area with first-class typed
props. For everything else (PWA chrome, AI-crawler directives, social
proof for Facebook / LinkedIn, App-Store deep links, etc.), use the
additionalMetaTags and additionalLinkTags arrays. URL-shaped
properties are validated automatically when validateUrls: true.
PWA chrome (theme color, viewport, status bar)
useSEO({
additionalMetaTags: [
{ name: 'theme-color', content: '#000000' },
{
name: 'theme-color',
content: '#ffffff',
// Use additionalMetaTags multiple times for media-query variants if
// you need light/dark theme-color overrides; the hook will emit each.
},
{ name: 'color-scheme', content: 'light dark' },
{ name: 'application-name', content: 'My App' },
{ name: 'format-detection', content: 'telephone=no' },
// Apple-specific (iOS Safari home-screen install)
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{
name: 'apple-mobile-web-app-status-bar-style',
content: 'black-translucent',
},
{ name: 'apple-mobile-web-app-title', content: 'My App' },
],
additionalLinkTags: [
{ rel: 'manifest', href: '/manifest.webmanifest' },
{
rel: 'apple-touch-icon',
href: '/apple-touch-icon.png',
sizes: '180x180',
},
{ rel: 'mask-icon', href: '/safari-pinned-tab.svg' },
],
});AI / LLM crawler directives
useSEO({
additionalMetaTags: [
// Block major AI crawlers from training on this page
{ name: 'robots', content: 'noai, noimageai' },
// OpenAI / GPTBot
{ name: 'GPTBot', content: 'noindex,nofollow' },
// Google-Extended (Bard / Gemini training opt-out)
{ name: 'Google-Extended', content: 'noindex' },
// Anthropic (ClaudeBot)
{ name: 'ClaudeBot', content: 'noindex' },
// Common Crawl (used by many model trainers)
{ name: 'CCBot', content: 'noindex' },
],
});Note: when you also want a typed
robotsdirective on the same page, prefer setting it viarobots: { … }and useadditionalMetaTagsonly for the AI-specific named bots above.
Referrer / format-detection / no-translate
useSEO({
additionalMetaTags: [
{ name: 'referrer', content: 'strict-origin-when-cross-origin' },
{ name: 'format-detection', content: 'telephone=no, email=no' },
{ name: 'google', content: 'notranslate' },
{ name: 'google-site-verification', content: 'YOUR-VERIFICATION-TOKEN' },
],
});Facebook App ID / LinkedIn
useSEO({
additionalMetaTags: [
{ property: 'fb:app_id', content: '123456789' },
{ property: 'fb:pages', content: '987654321' },
// LinkedIn picks up `og:title` / `og:description` / `og:image`
// automatically — there's no LinkedIn-specific tag family.
],
});Twitter App Card (deep links to native apps)
For the App Card, use additionalMetaTags with the dotted name family.
useSEO({
twitterCard: 'app',
additionalMetaTags: [
{ name: 'twitter:app:name:iphone', content: 'My App' },
{ name: 'twitter:app:id:iphone', content: '1234567890' },
{ name: 'twitter:app:url:iphone', content: 'myapp://path/to/page' },
{ name: 'twitter:app:name:ipad', content: 'My App' },
{ name: 'twitter:app:id:ipad', content: '1234567890' },
{ name: 'twitter:app:url:ipad', content: 'myapp://path/to/page' },
{ name: 'twitter:app:name:googleplay', content: 'My App' },
{
name: 'twitter:app:id:googleplay',
content: 'com.example.myapp',
},
{ name: 'twitter:app:url:googleplay', content: 'myapp://path/to/page' },
],
});Profile / Book / Music OG types
OG profile/book/music metadata uses simple property namespacing —
delegate to additionalMetaTags:
useSEO({
ogType: 'profile',
additionalMetaTags: [
{ property: 'profile:first_name', content: 'Jane' },
{ property: 'profile:last_name', content: 'Doe' },
{ property: 'profile:username', content: 'janedoe' },
{ property: 'profile:gender', content: 'female' },
],
});
useSEO({
ogType: 'book',
additionalMetaTags: [
{ property: 'book:author', content: 'https://example.com/authors/jane' },
{ property: 'book:isbn', content: '978-3-16-148410-0' },
{ property: 'book:release_date', content: '2024-03-15' },
{ property: 'book:tag', content: 'fiction' },
],
});Advanced Options
useSEO({
// Prevent duplicate meta tags (default: true)
preventDuplicates: true,
// Enable development warnings (default: true in dev, false in prod)
enableWarnings: true,
// Validate URLs in meta/link tags (default: true)
validateUrls: true,
// Remove all hook-created head elements when this component unmounts
// (default: false — see "Lifecycle / Cleanup" below)
clearOnUnmount: false,
});Caveat for
preventDuplicates: false: when this option isfalse, the hook will not deduplicate meta tags — repeatedupdateMetaTagcalls (or updates to multi-value props that pass throughgetOrCreateMeta) can leave several<meta>elements with the samename/propertyin the document. Subsequent mutations through the same key will only see the first match, so values can appear "stuck" or "stale" because the hook is updating one element while another duplicate carries the previous value. Stick to the default (true) unless you have a concrete reason to allow duplicates, and pair this option withclearOnUnmount: true(or an explicitclearSEOTags()call) so duplicate elements do not accumulate over the component's lifetime.
Automatic Behavior
The hook automatically handles several things behind the scenes:
- Essential meta tags: Ensures
<meta charset="UTF-8">and<meta name="viewport" content="width=device-width, initial-scale=1.0">exist in the document head, creating them if missing. - Change detection: Uses JSON serialization to skip DOM updates when the configuration has not changed between renders.
- Element tracking: All elements created by the hook are marked with a
data-use-seoattribute for identification and cleanup. - Image MIME type inference: Automatically infers
og:image:typefrom the image URL file extension (supports jpg, png, gif, webp, svg, ico, avif, tiff, heic, heif, bmp, apng, jxl). - Secure URL inference: Automatically sets
og:image:secure_urlwhen the image URL starts withhttps:. - Language normalization: Validates and normalizes BCP 47 language tags using
Intl.getCanonicalLocaleswhen available.
Lifecycle / Cleanup
The hook creates <head> elements during its main effect and, by default,
leaves them in place across component unmounts. This is intentional: in
single-page apps the meta tags should persist across route transitions to
avoid flicker between pages, and the next instance of the hook will mutate
them in place.
When that default does not match what you want, the hook offers two opt-ins:
Option 1 — clearOnUnmount: true (declarative)
Pass clearOnUnmount: true and the hook will clean up its own elements
when the component unmounts. Pre-existing user-authored elements that the
hook merely mutated are not removed — only elements that carry the
data-use-seo="true" marker (i.e., elements the hook actually created)
are taken down.
function ShareModal() {
useSEO({
ogTitle: 'Share this content',
ogImage: 'https://example.com/share.jpg',
twitterCard: 'summary_large_image',
clearOnUnmount: true, // remove on unmount
});
return <div>...</div>;
}The latest value of clearOnUnmount wins: toggling it from false to
true in a re-render before unmount will trigger cleanup; toggling it
back to false will skip cleanup.
Option 2 — clearSEOTags() (imperative)
The returned clearSEOTags() method does the same job at any moment of
your choosing. Useful from a useEffect cleanup, an event handler, or
inside an error boundary.
function MyComponent() {
const { clearSEOTags } = useSEO({ title: 'Live View' });
useEffect(() => {
return () => clearSEOTags(); // explicit per-effect cleanup
}, [clearSEOTags]);
return <div>...</div>;
}What is preserved
Both code paths only remove elements that carry the data-use-seo="true"
marker. If your application authored a <meta name="description"> or
<link rel="canonical"> in the document <head> before the hook mounted
(e.g., via Next.js metadata, a static HTML template, or a separate React
tree), that element will be preserved. The hook may have mutated its
attributes during its lifetime, but it will never delete it.
Performance Notes
How change detection works
On every render the hook builds an internal config snapshot, serializes it
with JSON.stringify, and compares the result to the previous render's
serialization. When the strings match, the entire DOM-mutating block is
skipped.
JSON.stringify is order-sensitive
JSON.stringify walks an object's keys in insertion order. Two
objects with the same data but a different key order produce different
strings:
JSON.stringify({ a: 1, b: 2 }); // '{"a":1,"b":2}'
JSON.stringify({ b: 2, a: 1 }); // '{"b":2,"a":1}'Inside the hook this means: passing a fresh props literal each render with the same data but different key order will be detected as "changed" and re-run the effect — a wasted no-op rather than a correctness bug, but worth avoiding on hot render paths.
The same applies to nested objects (ogImages: [{ url, alt, width }] vs.
ogImages: [{ alt, url, width }]) and to arrays whose ELEMENT order
changes even when the SET of values is unchanged.
Recommendations
Stabilize the props object with
useMemoso the same reference is reused across renders that don't actually change SEO data:const seoProps = useMemo( () => ({ title: page.title, description: page.description, canonical: page.canonical, ogImages: page.images, // stable reference per `page` change }), [page] ); useSEO(seoProps);Keep the key order stable when you do build the props object inline. Don't conditionally insert a key in different positions across renders.
Prefer top-level primitives over nested objects when both work, since primitive equality short-circuits faster than full object serialization.
The
enableWarnings,validateUrls, andpreventDuplicatesoptions are read on every render, so swapping them between renders is fine — their cost is constant.
Hook Return Methods
The hook returns an object with methods for programmatic tag management:
const { updateMetaTag, updateLinkTag, clearSEOTags, getCurrentSEO } = useSEO({
title: 'My Page',
});
// Update a meta tag programmatically
updateMetaTag({ name: 'description' }, 'Updated description');
updateMetaTag({ property: 'og:title' }, 'Updated OG Title');
// Update a link tag programmatically
updateLinkTag('stylesheet', 'https://example.com/style.css', {
type: 'text/css',
});
// Remove all SEO tags added by this hook
clearSEOTags();
// Get the current SEO configuration
const currentConfig = getCurrentSEO();
console.log(currentConfig.title); // 'My Page'TypeScript Support
All types are exported for use in your TypeScript projects:
import type {
SEOProps,
SEOHookReturn,
OpenGraphImage,
OpenGraphVideo,
OpenGraphAudio,
HreflangLink,
RobotsOptions,
RobotsObject,
AdditionalMetaTag,
AdditionalLinkTag,
StructuredData,
MetaTagKey,
LinkTagAttrs,
} from '@hiprax/use-seo';
// Use types in your code
const seoConfig: SEOProps = {
title: 'My Page',
description: 'Page description',
};
const images: OpenGraphImage[] = [
{ url: 'https://example.com/image.jpg', width: 1200, height: 630 },
];SSR Considerations
The hook is SSR-safe — it never throws on the server, but it only mutates
the DOM on the client. The general pattern is the same in every framework:
let the framework render its initial meta tags on the server, and use
@hiprax/use-seo from a client component for any dynamic updates after
hydration.
Next.js (App Router)
Use Next.js's built-in metadata API for the server pass and a client
component for dynamic updates. The 'use client' directive must be the
FIRST statement of the file — an unparenthesized string literal at the
top, no leading whitespace.
// app/page.tsx
export const metadata = {
title: 'Static Title',
description: 'Static description',
};// app/components/DynamicSection.tsx
'use client';
import { useSEO } from '@hiprax/use-seo';
export function DynamicSection({ dynamicTitle }: { dynamicTitle: string }) {
useSEO({ title: dynamicTitle });
return <div>Content</div>;
}Next.js (Pages Router)
Render the static tags inside <Head> and call useSEO from any child
client component for the dynamic ones.
import Head from 'next/head';
import { useSEO } from '@hiprax/use-seo';
function Page() {
return (
<>
<Head>
<title>My Page</title>
</Head>
<ClientSideComponent />
</>
);
}
function ClientSideComponent() {
useSEO({ title: 'Dynamic Title' });
return <div>Content</div>;
}Constants
The package exports useful constants for customization:
import {
DEFAULT_OG_TYPE, // 'website'
DEFAULT_TWITTER_CARD, // 'summary_large_image'
DEFAULT_AUTO_CANONICAL, // true
DEFAULT_PREVENT_DUPLICATES, // true
DEFAULT_VALIDATE_URLS, // true
MIN_TITLE_LENGTH, // 30
MAX_TITLE_LENGTH, // 60
MIN_DESCRIPTION_LENGTH, // 120
MAX_DESCRIPTION_LENGTH, // 160
MAX_KEYWORDS_COUNT, // 10
} from '@hiprax/use-seo';Best Practices
Title
- Keep titles between 30-60 characters
- Put important keywords at the beginning
- Make each page title unique
- Include your brand name (use
titleSuffix)
Description
- Keep descriptions between 120-160 characters
- Include a call-to-action when relevant
- Make each description unique and compelling
- Include relevant keywords naturally
Open Graph Images
- Use 1200x630 pixels for optimal display
- Provide
alttext for accessibility - Use high-quality, relevant images
- Test with Facebook's Sharing Debugger
Structured Data
- Validate with Google's Rich Results Test
- Use appropriate schema types for your content
- Keep structured data accurate and up-to-date
Canonical URLs
- Always set canonical URLs to prevent duplicate content
- Use absolute URLs
- Point to the preferred version of the page
Development
This project uses tsup for bundling and outputs both ESM and CJS formats with TypeScript declarations and sourcemaps.
Available Scripts
# Build the library (uses tsup)
npm run build
# Watch mode for development
npm run dev
# Run tests
npm run test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm run test:coverage
# Lint source files
npm run lint
# Lint and auto-fix
npm run lint:fix
# Format source files with Prettier
npm run format
# Check formatting without writing
npm run format:check
# TypeScript type checking (no emit)
npm run typecheck
# Remove dist and coverage directories
npm run cleanProject Structure
src/
index.ts # Public API exports
useSEO.ts # Main hook implementation
types.ts # TypeScript type definitions
constants.ts # Default values and limits
utils/
index.ts # Barrel re-export for utilities
dom.ts # DOM manipulation helpers
validation.ts # URL and language validation
robots.ts # Robots directive builder
title.ts # Title formatting logic
warnings.ts # Development warning utilitiesCoverage
The package is exercised by a comprehensive Jest suite under tests/
covering the public hook, every utility module, the package's exports
manifest, SSR (node Jest environment) safety, and dozens of regression
cases. As of v0.2.5 the suite ships 509 tests and reports
98.65% statements, 95.34% branches, 100% functions, and 100% lines;
the exact numbers may vary slightly per release. To reproduce the report
locally:
npm run test:coverageBrowser Support
The hook targets modern browsers — anything that ships
Intl.getCanonicalLocales, URL, Set, Map, and the standard ES2018
language features the bundle is compiled to. Concretely, that means
recent Chrome, Edge, Firefox, Safari (desktop and iOS), Samsung Internet,
and Node 20.19+ for SSR (matching the package's engines.node). The build
is published as both ESM and CommonJS so
modern bundlers (Vite, Webpack 5, Rollup, esbuild, Next.js, Remix) can
pick whichever suits their target.
Note on
getCurrentSEO(): the snapshot returned bygetCurrentSEO()is a deep clone produced viaJSON.parse(JSON.stringify(...)), so any keys whose value isundefinedare dropped from the result and the schema must be JSON-clean (noDate,Map,Set,BigInt, functions, or circular references).SEOPropsis intentionally JSON-clean — it exposes only primitives, strings, plain arrays, and plain objects — so the result shape is uniform across every supported runtime.
Internet Explorer is not a supported target. IE11 lacks
Intl.getCanonicalLocales, has incompleteURL/Set/Mapsupport, and never received many of the language features the bundle relies on. If you must support IE11, you would need polyfills for at leastIntl.getCanonicalLocales,URL,Set,Map, and a downlevel compile pass — and even then several behaviors (e.g., the BCP 47 language-tag normalization) would degrade silently to the regex fallback. We do not test against IE11 and consider it out of scope.
Contributing
Contributions are welcome! Please submit pull requests to the GitHub repository. See the Development section above for setup instructions.
To report bugs or request features, please use GitHub Issues.
License
MIT © Hiprax
Made with ❤️ for the React community
