feat: Add comprehensive SEO implementation
- Add robots.txt with admin/API blocking - Add dynamic sitemap.xml with static pages and genre pages - Implement full meta tags (Open Graph, Twitter Cards, Canonical, Alternates) - Add SEO helper functions for domain detection and URL generation - Add generateMetadata to all pages (homepage, about, genre, special) - Support automatic domain detection for hoerdle.de and hördle.de - Add SEO configuration to lib/config.ts
This commit is contained in:
@@ -6,6 +6,8 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { getLocalizedValue } from '@/lib/i18n';
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import { generateBaseMetadata } from '@/lib/metadata';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -15,6 +17,32 @@ interface PageProps {
|
|||||||
params: Promise<{ locale: string; genre: string }>;
|
params: Promise<{ locale: string; genre: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const { locale, genre } = await params;
|
||||||
|
const decodedGenre = decodeURIComponent(genre);
|
||||||
|
|
||||||
|
// Fetch genre to get localized name
|
||||||
|
const allGenres = await prisma.genre.findMany();
|
||||||
|
const currentGenre = allGenres.find(g => getLocalizedValue(g.name, locale) === decodedGenre);
|
||||||
|
|
||||||
|
if (!currentGenre || !currentGenre.active) {
|
||||||
|
return await generateBaseMetadata(locale, genre);
|
||||||
|
}
|
||||||
|
|
||||||
|
const genreName = getLocalizedValue(currentGenre.name, locale);
|
||||||
|
const genreSubtitle = getLocalizedValue(currentGenre.subtitle, locale);
|
||||||
|
|
||||||
|
const title = locale === 'de'
|
||||||
|
? `${genreName} - Hördle`
|
||||||
|
: `${genreName} - Hördle`;
|
||||||
|
|
||||||
|
const description = genreSubtitle || (locale === 'de'
|
||||||
|
? `Spiele Hördle im Genre ${genreName} und errate Songs aus kurzen Audio-Clips!`
|
||||||
|
: `Play Hördle in the ${genreName} genre and guess songs from short audio clips!`);
|
||||||
|
|
||||||
|
return await generateBaseMetadata(locale, genre, title, description);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function GenrePage({ params }: PageProps) {
|
export default async function GenrePage({ params }: PageProps) {
|
||||||
const { locale, genre } = await params;
|
const { locale, genre } = await params;
|
||||||
const decodedGenre = decodeURIComponent(genre);
|
const decodedGenre = decodeURIComponent(genre);
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Link } from "@/lib/navigation";
|
import { Link } from "@/lib/navigation";
|
||||||
|
import { generateBaseMetadata } from "@/lib/metadata";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
interface AboutPageProps {
|
interface AboutPageProps {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: AboutPageProps): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "About" });
|
||||||
|
|
||||||
|
const title = t("title");
|
||||||
|
const description = t("intro");
|
||||||
|
|
||||||
|
return await generateBaseMetadata(locale, "about", title, description);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function AboutPage({ params }: AboutPageProps) {
|
export default async function AboutPage({ params }: AboutPageProps) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: "About" });
|
const t = await getTranslations({ locale, namespace: "About" });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { notFound } from 'next/navigation';
|
|||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@/lib/config";
|
||||||
|
import { generateBaseMetadata } from "@/lib/metadata";
|
||||||
import InstallPrompt from "@/components/InstallPrompt";
|
import InstallPrompt from "@/components/InstallPrompt";
|
||||||
import AppFooter from "@/components/AppFooter";
|
import AppFooter from "@/components/AppFooter";
|
||||||
|
|
||||||
@@ -21,10 +22,10 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
title: config.appName,
|
const { locale } = await params;
|
||||||
description: config.appDescription,
|
return await generateBaseMetadata(locale);
|
||||||
};
|
}
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: config.colors.themeColor,
|
themeColor: config.colors.themeColor,
|
||||||
|
|||||||
@@ -7,15 +7,33 @@ import { Link } from '@/lib/navigation';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { getLocalizedValue } from '@/lib/i18n';
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
import { generateBaseMetadata } from '@/lib/metadata';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations('Home');
|
||||||
|
|
||||||
|
// Get localized title and description
|
||||||
|
const title = locale === 'de'
|
||||||
|
? 'Hördle - Tägliches Musik-Erraten'
|
||||||
|
: 'Hördle - Daily Music Guessing Game';
|
||||||
|
|
||||||
|
const description = locale === 'de'
|
||||||
|
? 'Spiele Hördle und errate Songs aus kurzen Audio-Clips! Täglich neue Rätsel aus verschiedenen Genres. Inspiriert von Wordle, aber für Musikfans.'
|
||||||
|
: 'Play Hördle and guess songs from short audio clips! Daily new puzzles from various genres. Inspired by Wordle, but for music lovers.';
|
||||||
|
|
||||||
|
return await generateBaseMetadata(locale, '', title, description);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Home({
|
export default async function Home({
|
||||||
params
|
params
|
||||||
}: {
|
}: {
|
||||||
params: { locale: string };
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations('Home');
|
const t = await getTranslations('Home');
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Link } from '@/lib/navigation';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { getLocalizedValue } from '@/lib/i18n';
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import { generateBaseMetadata } from '@/lib/metadata';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -14,6 +16,30 @@ interface PageProps {
|
|||||||
params: Promise<{ locale: string; name: string }>;
|
params: Promise<{ locale: string; name: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const { locale, name } = await params;
|
||||||
|
const decodedName = decodeURIComponent(name);
|
||||||
|
|
||||||
|
// Fetch special to get localized name
|
||||||
|
const allSpecials = await prisma.special.findMany();
|
||||||
|
const currentSpecial = allSpecials.find(s => getLocalizedValue(s.name, locale) === decodedName);
|
||||||
|
|
||||||
|
if (!currentSpecial) {
|
||||||
|
return await generateBaseMetadata(locale, `special/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const specialName = getLocalizedValue(currentSpecial.name, locale);
|
||||||
|
const specialSubtitle = getLocalizedValue(currentSpecial.subtitle, locale);
|
||||||
|
|
||||||
|
const title = `★ ${specialName} - Hördle`;
|
||||||
|
|
||||||
|
const description = specialSubtitle || (locale === 'de'
|
||||||
|
? `Spiele das Hördle-Special "${specialName}" und errate Songs aus kurzen Audio-Clips!`
|
||||||
|
: `Play the Hördle special "${specialName}" and guess songs from short audio clips!`);
|
||||||
|
|
||||||
|
return await generateBaseMetadata(locale, `special/${name}`, title, description);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function SpecialPage({ params }: PageProps) {
|
export default async function SpecialPage({ params }: PageProps) {
|
||||||
const { locale, name } = await params;
|
const { locale, name } = await params;
|
||||||
const decodedName = decodeURIComponent(name);
|
const decodedName = decodeURIComponent(name);
|
||||||
|
|||||||
20
app/robots.ts
Normal file
20
app/robots.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||||
|
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||||
|
const siteUrl = `${protocol}://${baseUrl}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/',
|
||||||
|
disallow: ['/admin/', '/api/'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sitemap: `${siteUrl}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
128
app/sitemap.ts
Normal file
128
app/sitemap.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||||
|
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||||
|
const siteUrl = `${protocol}://${baseUrl}`;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Static pages
|
||||||
|
const staticPages: MetadataRoute.Sitemap = [
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/en`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'daily',
|
||||||
|
priority: 1.0,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
'de': `${siteUrl}/de`,
|
||||||
|
'en': `${siteUrl}/en`,
|
||||||
|
'x-default': `${siteUrl}/en`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/de`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.8,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
'de': `${siteUrl}/de`,
|
||||||
|
'en': `${siteUrl}/en`,
|
||||||
|
'x-default': `${siteUrl}/en`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/en/about`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.7,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
'de': `${siteUrl}/de/about`,
|
||||||
|
'en': `${siteUrl}/en/about`,
|
||||||
|
'x-default': `${siteUrl}/en/about`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/de/about`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.7,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
'de': `${siteUrl}/de/about`,
|
||||||
|
'en': `${siteUrl}/en/about`,
|
||||||
|
'x-default': `${siteUrl}/en/about`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dynamic genre pages
|
||||||
|
try {
|
||||||
|
const genres = await prisma.genre.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const genrePages: MetadataRoute.Sitemap = [];
|
||||||
|
|
||||||
|
for (const genre of genres) {
|
||||||
|
const genreNameEn = getLocalizedValue(genre.name, 'en');
|
||||||
|
const genreNameDe = getLocalizedValue(genre.name, 'de');
|
||||||
|
|
||||||
|
// Only add if genre name is valid
|
||||||
|
if (genreNameEn && genreNameDe) {
|
||||||
|
const encodedEn = encodeURIComponent(genreNameEn);
|
||||||
|
const encodedDe = encodeURIComponent(genreNameDe);
|
||||||
|
|
||||||
|
genrePages.push(
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/en/${encodedEn}`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'daily',
|
||||||
|
priority: 0.9,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
'de': `${siteUrl}/de/${encodedDe}`,
|
||||||
|
'en': `${siteUrl}/en/${encodedEn}`,
|
||||||
|
'x-default': `${siteUrl}/en/${encodedEn}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/de/${encodedDe}`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'daily',
|
||||||
|
priority: 0.9,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
'de': `${siteUrl}/de/${encodedDe}`,
|
||||||
|
'en': `${siteUrl}/en/${encodedEn}`,
|
||||||
|
'x-default': `${siteUrl}/en/${encodedEn}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...staticPages, ...genrePages];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating sitemap:', error);
|
||||||
|
// Return static pages only if database query fails
|
||||||
|
return staticPages;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -12,5 +12,9 @@ export const config = {
|
|||||||
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
|
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
|
||||||
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
||||||
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
||||||
|
},
|
||||||
|
seo: {
|
||||||
|
ogImage: process.env.NEXT_PUBLIC_OG_IMAGE || '/favicon.ico',
|
||||||
|
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || undefined,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
64
lib/metadata.ts
Normal file
64
lib/metadata.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { config } from './config';
|
||||||
|
import { getBaseUrl } from './seo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate base metadata with Open Graph, Twitter Cards, and canonical URLs
|
||||||
|
*/
|
||||||
|
export async function generateBaseMetadata(
|
||||||
|
locale: string,
|
||||||
|
path: string = '',
|
||||||
|
title?: string,
|
||||||
|
description?: string,
|
||||||
|
image?: string
|
||||||
|
): Promise<Metadata> {
|
||||||
|
const baseUrl = await getBaseUrl();
|
||||||
|
const pathSegment = path ? `/${path}` : '';
|
||||||
|
const fullUrl = `${baseUrl}/${locale}${pathSegment}`;
|
||||||
|
|
||||||
|
// Determine alternate URLs for both locales (same path for both)
|
||||||
|
const alternateLocale = locale === 'de' ? 'en' : 'de';
|
||||||
|
const alternateUrl = `${baseUrl}/${alternateLocale}${pathSegment}`;
|
||||||
|
|
||||||
|
// Default values
|
||||||
|
const metaTitle = title || config.appName;
|
||||||
|
const metaDescription = description || config.appDescription;
|
||||||
|
const ogImage = image || `${baseUrl}${config.seo.ogImage}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: metaTitle,
|
||||||
|
description: metaDescription,
|
||||||
|
alternates: {
|
||||||
|
canonical: fullUrl,
|
||||||
|
languages: {
|
||||||
|
[locale]: fullUrl,
|
||||||
|
[alternateLocale]: alternateUrl,
|
||||||
|
'x-default': `${baseUrl}/en${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: metaTitle,
|
||||||
|
description: metaDescription,
|
||||||
|
url: fullUrl,
|
||||||
|
siteName: config.appName,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: ogImage,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: metaTitle,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
locale: locale,
|
||||||
|
type: 'website',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: metaTitle,
|
||||||
|
description: metaDescription,
|
||||||
|
images: [ogImage],
|
||||||
|
...(config.seo.twitterHandle && { creator: config.seo.twitterHandle }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
43
lib/seo.ts
Normal file
43
lib/seo.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { config } from './config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current base URL from request headers
|
||||||
|
* Automatically detects hoerdle.de or hördle.de (xn--hrdle-jua.de)
|
||||||
|
*/
|
||||||
|
export async function getBaseUrl(): Promise<string> {
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = headersList.get('host') || headersList.get('x-forwarded-host') || '';
|
||||||
|
|
||||||
|
let domain = config.domain; // Default fallback
|
||||||
|
|
||||||
|
if (host) {
|
||||||
|
// Extract domain from host (remove port if present)
|
||||||
|
const detectedDomain = host.split(':')[0].toLowerCase();
|
||||||
|
|
||||||
|
// Map domains
|
||||||
|
if (detectedDomain === 'hoerdle.de') {
|
||||||
|
domain = 'hoerdle.de';
|
||||||
|
} else if (detectedDomain === 'hördle.de' || detectedDomain === 'xn--hrdle-jua.de') {
|
||||||
|
domain = 'hördle.de';
|
||||||
|
} else {
|
||||||
|
// Use detected domain if it's different from default
|
||||||
|
domain = detectedDomain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always use HTTPS in production
|
||||||
|
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||||
|
return `${protocol}://${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get base URL synchronously (for use in non-async contexts)
|
||||||
|
* Uses environment variable or config as fallback
|
||||||
|
*/
|
||||||
|
export function getBaseUrlSync(): string {
|
||||||
|
const domain = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||||
|
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||||
|
return `${protocol}://${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user