diff --git a/apps/website/app/blog/[slug]/page.tsx b/apps/website/app/blog/[slug]/page.tsx index 3cb9056b..a6366e18 100644 --- a/apps/website/app/blog/[slug]/page.tsx +++ b/apps/website/app/blog/[slug]/page.tsx @@ -1,4 +1,4 @@ -import { getPost, getPosts } from "@/lib/ghost"; +import { SITE_URL, getPost, getPostCanonicalUrl, getPosts } from "@/lib/ghost"; import type { Metadata, ResolvingMetadata } from "next"; import Image from "next/image"; import Link from "next/link"; @@ -37,20 +37,23 @@ export async function generateMetadata( const ogUrl = new URL( "/api/og", - process.env.NODE_ENV === "production" - ? "https://dokploy.com" - : "http://localhost:3001", + process.env.NODE_ENV === "production" ? SITE_URL : "http://localhost:3001", ); ogUrl.searchParams.set("slug", slug); + const canonicalUrl = getPostCanonicalUrl(post); + return { title: post.title, description: post.custom_excerpt || post.excerpt, + alternates: { + canonical: canonicalUrl, + }, openGraph: { title: post.title, description: post.custom_excerpt || post.excerpt, type: "article", - url: `${process.env.NEXT_PUBLIC_APP_URL}/blog/${post.slug}`, + url: canonicalUrl, images: [ { url: ogUrl.toString(), diff --git a/apps/website/app/blog/page.tsx b/apps/website/app/blog/page.tsx index d7afdf35..98b25e77 100644 --- a/apps/website/app/blog/page.tsx +++ b/apps/website/app/blog/page.tsx @@ -1,4 +1,4 @@ -import { getPosts, getTags } from "@/lib/ghost"; +import { SITE_URL, getPosts, getTags } from "@/lib/ghost"; import type { Post } from "@/lib/ghost"; import { RssIcon } from "lucide-react"; import type { Metadata } from "next"; @@ -15,6 +15,12 @@ interface Tag { export const metadata: Metadata = { title: "Blog", description: "Latest news, updates, and articles from Dokploy", + alternates: { + canonical: `${SITE_URL}/blog`, + }, + openGraph: { + url: `${SITE_URL}/blog`, + }, }; export default async function BlogPage({ diff --git a/apps/website/app/blog/tag/[tag]/page.tsx b/apps/website/app/blog/tag/[tag]/page.tsx index 2d344c53..ac71c499 100644 --- a/apps/website/app/blog/tag/[tag]/page.tsx +++ b/apps/website/app/blog/tag/[tag]/page.tsx @@ -1,4 +1,4 @@ -import { getPostsByTag, getTags } from "@/lib/ghost"; +import { SITE_URL, getPostsByTag, getTags } from "@/lib/ghost"; import type { Post } from "@/lib/ghost"; import type { Metadata } from "next"; import Image from "next/image"; @@ -26,6 +26,9 @@ export async function generateMetadata({ params }: Props): Promise { return { title: `${tagName} Posts`, description: `Browse all posts tagged with ${tagName}`, + alternates: { + canonical: `${SITE_URL}/blog/tag/${tag}`, + }, }; } diff --git a/apps/website/lib/ghost.ts b/apps/website/lib/ghost.ts index 339f5841..71818b0b 100644 --- a/apps/website/lib/ghost.ts +++ b/apps/website/lib/ghost.ts @@ -78,6 +78,27 @@ export interface Post { bio: string | null; }>; url: string; + canonical_url: string | null; +} + +export const SITE_URL = "https://dokploy.com"; + +// Canonical for a post: Ghost's canonical_url if set, otherwise the post's +// own /blog/[slug] URL. Always absolute, without query string or hash. +export function getPostCanonicalUrl( + post: Pick, +) { + const fallback = `${SITE_URL}/blog/${post.slug}`; + if (!post.canonical_url) return fallback; + try { + const url = new URL(post.canonical_url, SITE_URL); + // Never canonicalize to the Ghost CMS duplicate + if (url.hostname === "cms.dokploy.com") return fallback; + if (url.hostname === "www.dokploy.com") url.hostname = "dokploy.com"; + return `${url.origin}${url.pathname}`; + } catch { + return fallback; + } } export async function getPosts(options = {}): Promise {