@@ -66,6 +67,7 @@ function MyPage() {
ogImage="https://example.com/image.jpg"
ogUrl="https://example.com/page"
ogType="website"
+ canonicalUrl="https://example.com/page"
/>
diff --git a/examples/basic/src/pages/Contact.tsx b/examples/basic/src/pages/Contact.tsx
index e8481c3..5fb1963 100644
--- a/examples/basic/src/pages/Contact.tsx
+++ b/examples/basic/src/pages/Contact.tsx
@@ -12,6 +12,7 @@ export default function Contact() {
ogImage={`${window.location.origin}/logo.png`}
ogUrl={window.location.href}
ogType="website"
+ canonicalUrl={window.location.href}
/>
diff --git a/examples/basic/src/pages/Home.tsx b/examples/basic/src/pages/Home.tsx
index fe1e9d9..d5721ff 100644
--- a/examples/basic/src/pages/Home.tsx
+++ b/examples/basic/src/pages/Home.tsx
@@ -12,6 +12,7 @@ export default function Home() {
ogImage={`${window.location.origin}/logo.png`}
ogUrl={window.location.href}
ogType="website"
+ canonicalUrl={window.location.href}
/>
diff --git a/package.json b/package.json
index 09e10fc..334e22d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "react-head-safe",
- "version": "1.3.0",
+ "version": "1.4.0",
"description": "A lightweight React head manager for CSR apps. Safely manage document title, meta tags, Open Graph, and SEO metadata without duplicates. TypeScript support included.",
"author": "umsungjun",
"license": "MIT",
diff --git a/src/ReactHeadSafe.tsx b/src/ReactHeadSafe.tsx
index 52238b6..550d896 100644
--- a/src/ReactHeadSafe.tsx
+++ b/src/ReactHeadSafe.tsx
@@ -16,6 +16,7 @@ import { type ReactHeadSafeProps } from './types';
* ogImage="https://example.com/image.jpg"
* ogUrl="https://example.com/page"
* ogType="website"
+ * canonicalUrl="https://example.com/page"
* />
*/
export const ReactHeadSafe: FC = ({
@@ -27,6 +28,7 @@ export const ReactHeadSafe: FC = ({
ogImage,
ogUrl,
ogType,
+ canonicalUrl,
}) => {
useLayoutEffect(() => {
// Update title
@@ -68,6 +70,11 @@ export const ReactHeadSafe: FC = ({
if (ogType !== undefined) {
updateMetaTag('property', 'og:type', ogType);
}
+
+ // Update canonical URL
+ if (canonicalUrl !== undefined) {
+ updateLinkTag('canonical', canonicalUrl);
+ }
}, [
title,
description,
@@ -77,11 +84,28 @@ export const ReactHeadSafe: FC = ({
ogImage,
ogUrl,
ogType,
+ canonicalUrl,
]);
return null;
};
+/**
+ * Updates or creates a link tag in the document head.
+ * Removes existing tag with the same rel to prevent duplicates.
+ */
+function updateLinkTag(rel: string, href: string): void {
+ const existingTag = document.querySelector(`link[rel="${rel}"]`);
+ if (existingTag) {
+ existingTag.remove();
+ }
+
+ const linkTag = document.createElement('link');
+ linkTag.setAttribute('rel', rel);
+ linkTag.setAttribute('href', href);
+ document.head.appendChild(linkTag);
+}
+
/**
* Updates or creates a meta tag in the document head.
* Removes existing tag with the same identifier to prevent duplicates.
diff --git a/src/test/ReactHeadSafe.test.tsx b/src/test/ReactHeadSafe.test.tsx
index 4354d8b..9ed65c8 100644
--- a/src/test/ReactHeadSafe.test.tsx
+++ b/src/test/ReactHeadSafe.test.tsx
@@ -333,6 +333,43 @@ describe('ReactHeadSafe', () => {
});
});
+ describe('canonical URL', () => {
+ it('should create link rel="canonical" tag', () => {
+ render();
+
+ const linkTag = document.querySelector('link[rel="canonical"]');
+ expect(linkTag).toBeInTheDocument();
+ expect(linkTag?.getAttribute('href')).toBe('https://example.com/page');
+ });
+
+ it('should update canonical URL when prop changes', () => {
+ const { rerender } = render(
+
+ );
+
+ let linkTag = document.querySelector('link[rel="canonical"]');
+ expect(linkTag?.getAttribute('href')).toBe('https://example.com/page-1');
+
+ rerender();
+
+ linkTag = document.querySelector('link[rel="canonical"]');
+ expect(linkTag?.getAttribute('href')).toBe('https://example.com/page-2');
+ });
+
+ it('should prevent duplicate canonical link tags', () => {
+ const { rerender } = render(
+
+ );
+ rerender();
+
+ const linkTags = document.querySelectorAll('link[rel="canonical"]');
+ expect(linkTags).toHaveLength(1);
+ expect(linkTags[0].getAttribute('href')).toBe(
+ 'https://example.com/second'
+ );
+ });
+ });
+
describe('multiple props', () => {
it('should handle all props together', () => {
render(
@@ -345,6 +382,7 @@ describe('ReactHeadSafe', () => {
ogImage="https://example.com/image.jpg"
ogUrl="https://example.com/page"
ogType="website"
+ canonicalUrl="https://example.com/page"
/>
);
@@ -369,6 +407,11 @@ describe('ReactHeadSafe', () => {
.querySelector('meta[property="og:type"]')
?.getAttribute('content')
).toBe('website');
+ expect(
+ document
+ .querySelector('link[rel="canonical"]')
+ ?.getAttribute('href')
+ ).toBe('https://example.com/page');
});
it('should update only changed props', () => {
diff --git a/src/types.ts b/src/types.ts
index f8ddfc6..0a61fc5 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -15,4 +15,6 @@ export interface ReactHeadSafeProps {
ogUrl?: string;
/** The type of your object, e.g., "website", "article" (og:type) */
ogType?: string;
+ /** The canonical URL of the page for SEO (link rel="canonical") */
+ canonicalUrl?: string;
}