open-prompt / src /lib /seo.ts
anky2002's picture
fix: SEO lib uses dynamic OG route as default image (fixes missing og-image.png)
9ce334a verified
export interface SEOConfig {
title: string
description: string
keywords?: string[]
image?: string
url?: string
type?: 'website' | 'article' | 'profile'
publishedTime?: string
modifiedTime?: string
author?: string
section?: string
}
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://open-prompt.netlify.app'
// Use dynamic OG route as default (no static og-image.png needed)
const DEFAULT_IMAGE = `${BASE_URL}/api/og?title=OpenPrompt&description=The+GitHub+for+AI+Prompts`
/**
* Generate a dynamic OG image URL via /api/og
*/
export function generateOGImageUrl(params: {
title: string
description?: string
type?: 'default' | 'prompt' | 'tool' | 'creator' | 'collection'
category?: string
runs?: number
stars?: number
remixes?: number
creator?: string
badge?: string
}): string {
const url = new URL(`${BASE_URL}/api/og`)
url.searchParams.set('title', params.title.slice(0, 80))
if (params.description) url.searchParams.set('description', params.description.slice(0, 140))
if (params.type) url.searchParams.set('type', params.type)
if (params.category) url.searchParams.set('category', params.category)
if (params.runs !== undefined) url.searchParams.set('runs', formatCount(params.runs))
if (params.stars !== undefined) url.searchParams.set('stars', formatCount(params.stars))
if (params.remixes !== undefined) url.searchParams.set('remixes', formatCount(params.remixes))
if (params.creator) url.searchParams.set('creator', params.creator)
if (params.badge) url.searchParams.set('badge', params.badge)
return url.toString()
}
function formatCount(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'K'
return n.toString()
}
export function generateSEO(config: SEOConfig) {
const {
title,
description,
keywords = [],
image = DEFAULT_IMAGE,
url = BASE_URL,
type = 'website',
publishedTime,
modifiedTime,
author,
section,
} = config
const fullTitle = title.includes('OpenPrompt') ? title : `${title} | OpenPrompt`
return {
title: fullTitle,
description,
keywords: [
'AI prompts',
'prompt engineering',
'ChatGPT prompts',
'Claude prompts',
'AI tools',
'prompt marketplace',
...keywords,
],
authors: [{ name: author || 'OpenPrompt', url: 'https://github.com/Anky9972' }],
creator: 'OpenPrompt',
publisher: 'OpenPrompt',
metadataBase: new URL(BASE_URL),
alternates: {
canonical: url,
},
openGraph: {
title: fullTitle,
description,
url,
siteName: 'OpenPrompt',
images: [
{
url: image,
width: 1200,
height: 630,
alt: title,
},
],
locale: 'en_US',
type,
...(publishedTime && { publishedTime }),
...(modifiedTime && { modifiedTime }),
...(section && { section }),
},
twitter: {
card: 'summary_large_image',
title: fullTitle,
description,
images: [image],
creator: '@anky_vivek',
site: '@openprompt',
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
verification: {
google: 'google60f35921954050fd',
},
}
}
// JSON-LD Structured Data generators
export function generateWebsiteSchema() {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'OpenPrompt',
description: 'The GitHub for AI Prompts - Transform AI prompts into shareable micro-apps',
url: BASE_URL,
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${BASE_URL}/explore?q={search_term_string}`,
},
'query-input': 'required name=search_term_string',
},
sameAs: [
'https://github.com/Anky9972/open-prompt',
'https://twitter.com/anky_vivek',
],
}
}
export function generateOrganizationSchema() {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'OpenPrompt',
url: BASE_URL,
logo: `${BASE_URL}/logos/logo.svg`,
description: 'The GitHub for AI Prompts - Transform AI prompts into shareable micro-apps',
sameAs: [
'https://github.com/Anky9972/open-prompt',
'https://twitter.com/anky_vivek',
],
}
}
export function generateSoftwareApplicationSchema() {
return {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'OpenPrompt',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
},
description: 'AI Prompts Marketplace with 177+ tools, multi-model support, and browser extension',
featureList: [
'AI Prompts Marketplace',
'177+ AI Tools',
'Multi-Model Support (GPT-4, Claude, Gemini)',
'Browser Extension',
'Workflow Automation',
'Model Comparison (Thunderdome)',
],
}
}
export function generatePromptSchema(prompt: {
title: string
description?: string
slug: string
creator?: { name?: string }
createdAt: Date
updatedAt: Date
starsCount: number
totalRuns?: number
remixesCount?: number
category?: string
}) {
const hasEnoughRatings = prompt.starsCount >= 5
const normalizedRating = hasEnoughRatings
? Math.min(5, Math.max(1, 1 + (prompt.starsCount / (prompt.starsCount + 10)) * 4))
: undefined
return {
'@context': 'https://schema.org',
'@type': 'CreativeWork',
name: prompt.title,
description: prompt.description || `AI prompt: ${prompt.title}`,
url: `${BASE_URL}/p/${prompt.slug}`,
author: {
'@type': 'Person',
name: prompt.creator?.name || 'Anonymous',
},
dateCreated: prompt.createdAt.toISOString(),
dateModified: prompt.updatedAt.toISOString(),
...(hasEnoughRatings && normalizedRating && {
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: normalizedRating.toFixed(1),
ratingCount: prompt.starsCount,
reviewCount: prompt.starsCount,
bestRating: 5,
worstRating: 1,
},
}),
genre: prompt.category || 'AI Prompt',
isAccessibleForFree: true,
...(prompt.totalRuns && {
interactionStatistic: {
'@type': 'InteractionCounter',
interactionType: 'https://schema.org/UseAction',
userInteractionCount: prompt.totalRuns,
},
}),
}
}
export function generateToolSchema(tool: {
name: string
description: string
slug: string
category: string
}) {
return {
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: tool.name,
description: tool.description,
url: `${BASE_URL}/tools/${tool.slug}`,
applicationCategory: 'Productivity',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
},
isAccessibleForFree: true,
}
}
export function generateBreadcrumbSchema(items: { name: string; url: string }[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url,
})),
}
}
export function generateFAQSchema(faqs: { question: string; answer: string }[]) {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
}
}