Skip to content

Commit 9cd1660

Browse files
committed
feat: add SEO/OG config for public educator profiles
- Educators/[profileid]: server-render real profile metadata (name/bio via cache()-deduped getUserById), canonical /educators/:id, og:type profile, ProfilePage JSON-LD, notFound() on missing - EducatorPageClient: accept server-passed educator and skip its own duplicate fetch - Dynamic opengraph-image + twitter-image using renderOgCard (Verified Educator badge) - renderOgCard: support custom typeLabel for the educator card - sitemap: include educator profile URLs via fetchEducators with 5s timeout fallback
1 parent f3ac7d2 commit 9cd1660

6 files changed

Lines changed: 171 additions & 17 deletions

File tree

app/[locale]/(pages)/educators/[profileid]/EducatorPageClient.jsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ import {
3333
} from "@/lib/config/font.config";
3434
import Button from "@/components/atoms/form/Button";
3535

36-
export default function PublicEducatorPage({ params }) {
36+
export default function PublicEducatorPage({ params, educator: educatorProp }) {
3737
const { profileid } = use(params);
3838
const { user: currentUser } = useAuth();
3939
const router = useRouter();
4040

41-
const [educator, setEducator] = useState(null);
41+
const [educator, setEducator] = useState(educatorProp || null);
4242
const [courses, setCourses] = useState([]);
4343
const [books, setBooks] = useState([]);
4444
const [spaces, setSpaces] = useState([]);
@@ -54,8 +54,11 @@ export default function PublicEducatorPage({ params }) {
5454
setLoading(true);
5555
setError(false);
5656
try {
57-
const res = await getUserById(profileid);
58-
const u = res?.user || null;
57+
let u = educatorProp;
58+
if (!u) {
59+
const res = await getUserById(profileid);
60+
u = res?.user || null;
61+
}
5962
if (!u) {
6063
setError(true);
6164
setLoading(false);
@@ -100,7 +103,7 @@ export default function PublicEducatorPage({ params }) {
100103
}
101104
}
102105
load();
103-
}, [profileid, currentUser?._id]);
106+
}, [profileid, educatorProp, currentUser?._id]);
104107

105108
const handleFollowToggle = async () => {
106109
if (!currentUser?._id) {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { getUserById } from "@/lib/actions/users/getUserById";
2+
import {
3+
renderOgCard,
4+
OG_IMAGE_SIZE,
5+
} from "@/components/seo/renderOgCard";
6+
import { truncateText } from "@/lib/utils/seo";
7+
8+
export const runtime = "nodejs";
9+
export const alt = "Educator on Deen Bridge";
10+
export const size = OG_IMAGE_SIZE;
11+
export const contentType = "image/png";
12+
13+
export default async function Image({ params }) {
14+
const { profileid } = await params;
15+
16+
let educator = null;
17+
try {
18+
const res = await getUserById(profileid);
19+
educator = res?.user || null;
20+
} catch {
21+
educator = null;
22+
}
23+
24+
const title = educator?.name || "Meet an educator on Deen Bridge";
25+
const subtitle =
26+
truncateText(educator?.bio, 140) ||
27+
"Courses, books and live spaces from a verified educator — on Deen Bridge.";
28+
const badge = educator?.isVerified
29+
? "Verified Educator"
30+
: "Educator on Deen Bridge";
31+
32+
return renderOgCard({ title, subtitle, badge, typeLabel: "EDUCATOR" });
33+
}
Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,81 @@
1+
import { cache } from "react";
2+
import { notFound } from "next/navigation";
3+
import { getUserById } from "@/lib/actions/users/getUserById";
14
import EducatorPageClient from "./EducatorPageClient";
5+
import { JsonLd } from "@/components/seo/JsonLd";
6+
import { siteUrl, siteName } from "@/lib/config/site.config";
7+
import { truncateText } from "@/lib/utils/seo";
8+
9+
// getUserById uses axios (not fetch), so React cache() deduplicates the call
10+
// between generateMetadata and the page, keeping it to one backend hit.
11+
const getEducator = cache(getUserById);
12+
13+
async function resolveEducator(profileid) {
14+
const res = await getEducator(profileid);
15+
return res?.user || null;
16+
}
217

318
export async function generateMetadata({ params }) {
419
const { profileid } = await params;
20+
const educator = await resolveEducator(profileid);
21+
if (!educator) return {};
22+
23+
const name = educator.name || "Educator";
24+
const title = `${name} | Deen Bridge`;
25+
const description =
26+
truncateText(educator.bio, 160) ||
27+
`Learn from ${name} — courses, books and live spaces on Deen Bridge.`;
28+
const nameParts = name.split(/\s+/).filter(Boolean);
29+
530
return {
6-
title: "Educator Profile - Deen Bridge",
7-
description: "View this educator's courses, books, and spaces on Deen Bridge.",
31+
title: { absolute: title },
32+
description,
33+
alternates: { canonical: `/educators/${profileid}` },
834
openGraph: {
9-
title: "Educator Profile - Deen Bridge",
10-
description: "View this educator's courses, books, and spaces on Deen Bridge.",
11-
url: `https://deenbridge.com/educators/${profileid}`,
35+
title,
36+
description,
37+
url: `${siteUrl}/educators/${profileid}`,
38+
siteName,
39+
locale: "en_US",
1240
type: "profile",
41+
profile: {
42+
firstName: nameParts[0] || undefined,
43+
lastName: nameParts.slice(1).join(" ") || undefined,
44+
username: profileid,
45+
},
1346
},
1447
twitter: {
1548
card: "summary_large_image",
16-
title: "Educator Profile - Deen Bridge",
17-
description: "View this educator's courses, books, and spaces on Deen Bridge.",
49+
title,
50+
description,
1851
},
1952
};
2053
}
2154

22-
export default function EducatorPage({ params }) {
23-
return <EducatorPageClient params={params} />;
55+
function buildProfileJsonLd(educator, profileid) {
56+
return {
57+
"@context": "https://schema.org",
58+
"@type": "ProfilePage",
59+
mainEntity: {
60+
"@type": "Person",
61+
name: educator.name,
62+
url: `${siteUrl}/educators/${profileid}`,
63+
description: truncateText(educator.bio, 200) || undefined,
64+
image: educator.avatar || undefined,
65+
},
66+
};
2467
}
68+
69+
export default async function EducatorPage({ params }) {
70+
const { profileid } = await params;
71+
const educator = await resolveEducator(profileid);
72+
73+
if (!educator) return notFound();
74+
75+
return (
76+
<>
77+
<JsonLd data={buildProfileJsonLd(educator, profileid)} />
78+
<EducatorPageClient params={params} educator={educator} />
79+
</>
80+
);
81+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { getUserById } from "@/lib/actions/users/getUserById";
2+
import {
3+
renderOgCard,
4+
OG_IMAGE_SIZE,
5+
} from "@/components/seo/renderOgCard";
6+
import { truncateText } from "@/lib/utils/seo";
7+
8+
export const runtime = "nodejs";
9+
export const alt = "Educator on Deen Bridge";
10+
export const size = OG_IMAGE_SIZE;
11+
export const contentType = "image/png";
12+
13+
export default async function Image({ params }) {
14+
const { profileid } = await params;
15+
16+
let educator = null;
17+
try {
18+
const res = await getUserById(profileid);
19+
educator = res?.user || null;
20+
} catch {
21+
educator = null;
22+
}
23+
24+
const title = educator?.name || "Meet an educator on Deen Bridge";
25+
const subtitle =
26+
truncateText(educator?.bio, 140) ||
27+
"Courses, books and live spaces from a verified educator — on Deen Bridge.";
28+
const badge = educator?.isVerified
29+
? "Verified Educator"
30+
: "Educator on Deen Bridge";
31+
32+
return renderOgCard({ title, subtitle, badge, typeLabel: "EDUCATOR" });
33+
}

app/sitemap.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { siteUrl, publicRoutes } from "@/lib/config/site.config";
22
import { fetchCourses } from "@/lib/actions/courses/fetch-courses";
33
import { fetchBooks } from "@/lib/actions/library/fetch-books";
4+
import { fetchEducators } from "@/lib/actions/educators/fetch-educators";
45

56
const API_TIMEOUT_MS = 5000;
67

@@ -36,6 +37,12 @@ function normalizeBooks(response) {
3637
return [];
3738
}
3839

40+
function normalizeEducators(response) {
41+
if (Array.isArray(response)) return response;
42+
if (response?.educators) return response.educators;
43+
return [];
44+
}
45+
3946
export default async function sitemap() {
4047
const lastModified = new Date();
4148

@@ -48,6 +55,7 @@ export default async function sitemap() {
4855

4956
let courseEntries = [];
5057
let bookEntries = [];
58+
let educatorEntries = [];
5159

5260
try {
5361
const courses = await withTimeout(fetchCourses());
@@ -79,5 +87,20 @@ export default async function sitemap() {
7987
);
8088
}
8189

82-
return [...staticEntries, ...courseEntries, ...bookEntries];
90+
try {
91+
const educators = await withTimeout(fetchEducators());
92+
educatorEntries = normalizeEducators(educators).map((educator) => ({
93+
url: `${siteUrl}/educators/${educator._id || educator.id}`,
94+
lastModified: safeDate(educator.updatedAt, lastModified),
95+
changeFrequency: "weekly",
96+
priority: 0.7,
97+
}));
98+
} catch (error) {
99+
console.warn(
100+
"sitemap: educator profile fetch failed, returning static routes only.",
101+
error?.message ?? error
102+
);
103+
}
104+
105+
return [...staticEntries, ...courseEntries, ...bookEntries, ...educatorEntries];
83106
}

components/seo/renderOgCard.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ export const OG_IMAGE_SIZE = { width: 1200, height: 630 };
77
* Renders an ImageResponse social card. Shared by the opengraph-image and
88
* twitter-image routes for courses and books.
99
*/
10-
export function renderOgCard({ title, subtitle, badge }) {
10+
export function renderOgCard({ title, subtitle, badge, typeLabel }) {
1111
return new ImageResponse(
12-
<OgBrandCard title={title} subtitle={subtitle} badge={badge} />,
12+
<OgBrandCard
13+
title={title}
14+
subtitle={subtitle}
15+
badge={badge}
16+
typeLabel={typeLabel}
17+
/>,
1318
OG_IMAGE_SIZE
1419
);
1520
}

0 commit comments

Comments
 (0)