Putting It All Together
Apply everything from the SEO module: dynamic generateMetadata(), an opengraph-image.tsx that renders the post title and author, a sitemap.ts that lists all published posts, and Article schema JSON-LD on every post page.
Public Blog with Dynamic OG Images & Full SEO
Building a public-facing blog requires more than just loading content onto a web page; you must ensure search engines and social networks can crawl and display your articles with high fidelity. This means outputting unique meta tags for every post, embedding schema markup to trigger Google rich results, generating visual preview cards on-the-fly, and compiling dynamic XML sitemaps. In this lesson, we will integrate these SEO features into the public side of our Capstone Project. We will implement dynamic metadata generators, build a dynamic Open Graph image endpoint, embed Article JSON-LD, and build a database-driven sitemap generator.
1. Dynamic Page Metadata (generateMetadata)
To ensure each blog post has unique titles and description tags for search results, implement the async generateMetadata() function in your dynamic route page:
// app/(public)/posts/[slug]/page.tsx
import { Metadata } from 'next';
import { db } from '@/db';
import { posts } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { notFound } from 'next/navigation';
interface PostPageProps {
params: Promise<{ slug: string }>;
}
// Generate metadata dynamically based on active slug params
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
const { slug } = await params;
const post = await db.query.posts.findFirst({
where: eq(posts.slug, slug),
with: { author: true },
});
if (!post) return {};
return {
title: post.title + ' | ProgrUmar Blog',
description: post.content.substring(0, 160).replace(/<[^>]*>/g, ''), // Strip HTML tags
openGraph: {
title: post.title,
type: 'article',
publishedTime: post.createdAt.toISOString(),
authors: [post.author?.name || 'Qasim Ali'],
images: [
{
url: 'https://progrumar.com/posts/' + slug + '/opengraph-image',
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
},
};
}
2. Embedding JSON-LD Article Schema
Structured schemas signal page meaning to search engine crawlers. We render this by embedding a JSON-LD script block directly inside our page HTML wrapper:
// Inside app/(public)/posts/[slug]/page.tsx Page Component:
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params;
const post = await db.query.posts.findFirst({
where: eq(posts.slug, slug),
with: { author: true },
});
if (!post) notFound();
// Define structured JSON-LD data
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
datePublished: post.createdAt.toISOString(),
dateModified: post.updatedAt.toISOString(),
author: {
'@type': 'Person',
name: post.author.name,
},
publisher: {
'@type': 'Organization',
name: 'ProgrUmar',
logo: {
'@type': 'ImageObject',
url: 'https://progrumar.com/logo.png',
},
},
};
return (
<article className="max-w-2xl mx-auto p-4">
{/* Inject JSON-LD Schema */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<h1 className="text-4xl font-bold">{post.title}</h1>
<p className="text-sm text-neutral-500 mt-2">By {post.author.name}</p>
<div className="mt-8 prose" dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
3. Dynamic Open Graph Image Generator (opengraph-image.tsx)
Social networks render visual cards whenever links are shared. Instead of manually exporting custom graphics for every post, write an opengraph-image.tsx file within your route folder. Next.js runs this file in server environments to draw dynamic SVG graphics on-the-fly:
// app/(public)/posts/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { db } from '@/db';
import { posts } from '@/db/schema';
import { eq } from 'drizzle-orm';
export const alt = 'Post Preview Banner';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await db.query.posts.findFirst({
where: eq(posts.slug, slug),
});
return new ImageResponse(
(
<div
style={{
background: 'linear-gradient(to right, #0f172a, #1e293b)',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
padding: '80px',
}}
>
<span style={{ color: '#38bdf8', fontSize: '24px', fontWeight: 'bold', textTransform: 'uppercase' }}>
ProgrUmar Academy
</span>
<h1 style={{ color: 'white', fontSize: '64px', fontWeight: 'bold', marginTop: '20px', lineHeight: 1.2 }}>
{post?.title || 'Blogging Platform'}
</h1>
<div style={{ display: 'flex', alignItems: 'center', marginTop: '40px' }}>
<span style={{ color: '#94a3b8', fontSize: '20px' }}>Read article on progrumar.com</span>
</div>
</div>
),
{ ...size }
);
}
4. Database-Driven Sitemap Generator (sitemap.ts)
Search engines need to discover new articles quickly. We compile an XML list of all public routes dynamically by creating a file named sitemap.ts inside the root of our app/ folder:
// app/sitemap.ts
import { MetadataRoute } from 'next';
import { db } from '@/db';
import { posts } from '@/db/schema';
import { eq } from 'drizzle-orm';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://progrumar.com';
// Retrieve all published articles from the database
const publishedPosts = await db.select().from(posts).where(eq(posts.published, true));
const postUrls = publishedPosts.map((post) => ({
url: baseUrl + '/posts/' + post.slug,
lastModified: post.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
// Combine dynamic posts with static page directories
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1.0,
},
{
url: baseUrl + '/courses',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
...postUrls,
];
}
5. Common Gotchas
-
HTML Elements inside Meta Descriptions: Extracting raw post text directly from Tiptap database inputs (which contains tags like
<p>,<strong>) and feeding it straight to page metadata configurations. Search engines will display raw HTML code strings in user search lists. Always use regex string matches (.replace(/<[^>]*>/g, '')) to strip tags from text. -
Slow Image Generator Runs: Adding slow database joins or external font requests inside
opengraph-image.tsx. This triggers slow image load times on social share link displays. Keep image canvases simple and cache results where possible.
Key Takeaways
- Implement
generateMetadata()on dynamic routes to assign per-post meta tags. - Inject JSON-LD schemas inside page HTML markup using
dangerouslySetInnerHTMLstructures. - Configure
opengraph-image.tsxdynamically using Next'sImageResponse. - Query databases inside
sitemap.tsto auto-compile dynamic XML sitemaps. - Strip HTML tag selectors from text before assigning description properties.
Building public views and integrating SEO structures completes the user experience. Now, we must verify configuration flags, establish production logs, and deploy the platform. In the next lesson, we cover Deploying & Monitoring the Capstone Project, finalizing database connections, setting up error log tracking, and completing the course.