feat: add i18n, SEO, and frontend infrastructure

Localization:
- Add middleware for locale detection/routing
- Add [locale] dynamic route structure
- Add i18n utility library (DE/EN support)

SEO & Discovery:
- Add robots.ts for search engine directives
- Add sitemap.ts for XML sitemap generation
- Add structuredData.ts for JSON-LD schemas

Utilities:
- Add search.ts for full-text search functionality
- Add tenantAccess.ts for multi-tenant access control
- Add envValidation.ts for environment validation

Frontend:
- Update layout.tsx with locale support
- Update page.tsx for localized content
- Add API routes for frontend functionality
- Add instrumentation.ts for monitoring

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-01 08:19:35 +00:00
parent 95c9d2a4bc
commit 51c340e9e7
16 changed files with 2006 additions and 66 deletions

View file

@ -0,0 +1,30 @@
import React from 'react'
import { notFound } from 'next/navigation'
import { isValidLocale, locales, getLocaleDirection, type Locale } from '@/lib/i18n'
export function generateStaticParams() {
return locales.map((locale) => ({ locale }))
}
interface LocaleLayoutProps {
children: React.ReactNode
params: Promise<{ locale: string }>
}
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = await params
if (!isValidLocale(locale)) {
notFound()
}
const direction = getLocaleDirection(locale as Locale)
return (
<html lang={locale} dir={direction}>
<body>
<main>{children}</main>
</body>
</html>
)
}

View file

@ -0,0 +1,100 @@
import { headers as getHeaders } from 'next/headers'
import Image from 'next/image'
import { getPayload } from 'payload'
import React from 'react'
import { fileURLToPath } from 'url'
import { notFound } from 'next/navigation'
import config from '@/payload.config'
import { isValidLocale, localeNames, type Locale } from '@/lib/i18n'
import '../styles.css'
interface HomePageProps {
params: Promise<{ locale: string }>
}
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params
if (!isValidLocale(locale)) {
notFound()
}
const headers = await getHeaders()
const payloadConfig = await config
const payload = await getPayload({ config: payloadConfig })
const { user } = await payload.auth({ headers })
const fileURL = `vscode://file/${fileURLToPath(import.meta.url)}`
// Localized content
const content = {
de: {
welcome: 'Willkommen zu Ihrem neuen Projekt.',
welcomeBack: 'Willkommen zurück,',
adminPanel: 'Zum Admin-Panel',
documentation: 'Dokumentation',
updatePage: 'Diese Seite bearbeiten in',
},
en: {
welcome: 'Welcome to your new project.',
welcomeBack: 'Welcome back,',
adminPanel: 'Go to admin panel',
documentation: 'Documentation',
updatePage: 'Update this page by editing',
},
}
const t = content[locale as Locale]
return (
<div className="home">
<div className="content">
<picture>
<source srcSet="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg" />
<Image
alt="Payload Logo"
height={65}
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg"
width={65}
/>
</picture>
{!user && <h1>{t.welcome}</h1>}
{user && (
<h1>
{t.welcomeBack} {user.email}
</h1>
)}
<div className="links">
<a
className="admin"
href={payloadConfig.routes.admin}
rel="noopener noreferrer"
target="_blank"
>
{t.adminPanel}
</a>
<a
className="docs"
href="https://payloadcms.com/docs"
rel="noopener noreferrer"
target="_blank"
>
{t.documentation}
</a>
</div>
<div className="locale-info" style={{ marginTop: '1rem' }}>
<p>
Current locale: <strong>{localeNames[locale as Locale].native}</strong>
</p>
</div>
</div>
<div className="footer">
<p>{t.updatePage}</p>
<a className="codeLink" href={fileURL}>
<code>app/(frontend)/[locale]/page.tsx</code>
</a>
</div>
</div>
)
}

View file

@ -0,0 +1,136 @@
// src/app/(frontend)/api/posts/route.ts
// Posts listing with category and type filters
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { getPostsByCategory, checkRateLimit } from '@/lib/search'
// Validation constants
const MAX_LIMIT = 50
const DEFAULT_LIMIT = 10
export async function GET(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'unknown'
const rateLimit = checkRateLimit(ip)
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: {
'Retry-After': String(rateLimit.retryAfter || 60),
'X-RateLimit-Remaining': '0',
},
}
)
}
// Parse query parameters
const { searchParams } = new URL(request.url)
const category = searchParams.get('category')?.trim()
const type = searchParams.get('type')?.trim() as 'blog' | 'news' | 'press' | 'announcement' | undefined
const tenantParam = searchParams.get('tenant')
const pageParam = searchParams.get('page')
const limitParam = searchParams.get('limit')
const localeParam = searchParams.get('locale')?.trim()
// Validate locale
const validLocales = ['de', 'en']
const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de'
// Validate type
const validTypes = ['blog', 'news', 'press', 'announcement']
if (type && !validTypes.includes(type)) {
return NextResponse.json(
{ error: `Invalid type. Must be one of: ${validTypes.join(', ')}` },
{ status: 400 }
)
}
// Parse numeric parameters
const page = Math.max(1, parseInt(pageParam || '1', 10) || 1)
const limit = Math.min(
Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT),
MAX_LIMIT
)
const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined
// Validate tenant ID if provided
if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) {
return NextResponse.json(
{ error: 'Invalid tenant ID' },
{ status: 400 }
)
}
// Get payload instance
const payload = await getPayload({ config })
// Get posts
const result = await getPostsByCategory(payload, {
tenantId,
categorySlug: category,
type,
locale,
page,
limit,
})
// Transform response
const response = {
docs: result.docs.map((post) => ({
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt || null,
publishedAt: post.publishedAt || null,
type: (post as typeof post & { type?: string }).type || 'blog',
featuredImage: post.featuredImage && typeof post.featuredImage === 'object'
? {
url: post.featuredImage.url,
alt: post.featuredImage.alt,
width: post.featuredImage.width,
height: post.featuredImage.height,
}
: null,
category: post.category && typeof post.category === 'object'
? { name: post.category.name, slug: post.category.slug }
: null,
})),
pagination: {
page: result.page,
limit,
totalPages: result.totalPages,
totalDocs: result.totalDocs,
hasNextPage: result.hasNextPage,
hasPrevPage: result.hasPrevPage,
},
filters: {
category,
type,
locale,
tenant: tenantId,
},
}
return NextResponse.json(response, {
headers: {
'X-RateLimit-Remaining': String(rateLimit.remaining),
'Cache-Control': 'public, max-age=60, s-maxage=60',
},
})
} catch (error) {
console.error('[Posts API] Error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,118 @@
// src/app/(frontend)/api/search/route.ts
// Main search API endpoint
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { searchPosts, checkRateLimit } from '@/lib/search'
// Validation constants
const MIN_QUERY_LENGTH = 2
const MAX_QUERY_LENGTH = 100
const MAX_LIMIT = 50
const DEFAULT_LIMIT = 10
export async function GET(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'unknown'
const rateLimit = checkRateLimit(ip)
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: {
'Retry-After': String(rateLimit.retryAfter || 60),
'X-RateLimit-Remaining': '0',
},
}
)
}
// Parse query parameters
const { searchParams } = new URL(request.url)
const query = searchParams.get('q')?.trim() || ''
const category = searchParams.get('category')?.trim()
const type = searchParams.get('type')?.trim() as 'blog' | 'news' | 'press' | 'announcement' | undefined
const tenantParam = searchParams.get('tenant')
const limitParam = searchParams.get('limit')
const offsetParam = searchParams.get('offset')
const localeParam = searchParams.get('locale')?.trim()
// Validate locale
const validLocales = ['de', 'en']
const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de'
// Validate query
if (query && query.length < MIN_QUERY_LENGTH) {
return NextResponse.json(
{ error: `Query must be at least ${MIN_QUERY_LENGTH} characters` },
{ status: 400 }
)
}
if (query && query.length > MAX_QUERY_LENGTH) {
return NextResponse.json(
{ error: `Query must be at most ${MAX_QUERY_LENGTH} characters` },
{ status: 400 }
)
}
// Validate type
const validTypes = ['blog', 'news', 'press', 'announcement']
if (type && !validTypes.includes(type)) {
return NextResponse.json(
{ error: `Invalid type. Must be one of: ${validTypes.join(', ')}` },
{ status: 400 }
)
}
// Parse numeric parameters
const limit = Math.min(
Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT),
MAX_LIMIT
)
const offset = Math.max(0, parseInt(offsetParam || '0', 10) || 0)
const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined
// Validate tenant ID if provided
if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) {
return NextResponse.json(
{ error: 'Invalid tenant ID' },
{ status: 400 }
)
}
// Get payload instance
const payload = await getPayload({ config })
// Execute search
const result = await searchPosts(payload, {
query,
tenantId,
categorySlug: category,
type,
locale,
limit,
offset,
})
return NextResponse.json(result, {
headers: {
'X-RateLimit-Remaining': String(rateLimit.remaining),
'Cache-Control': 'public, max-age=60, s-maxage=60',
},
})
} catch (error) {
console.error('[Search API] Error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,112 @@
// src/app/(frontend)/api/search/suggestions/route.ts
// Auto-complete suggestions endpoint
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { getSearchSuggestions, checkRateLimit } from '@/lib/search'
// Validation constants
const MIN_QUERY_LENGTH = 2
const MAX_QUERY_LENGTH = 50
const MAX_LIMIT = 10
const DEFAULT_LIMIT = 5
export async function GET(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'unknown'
const rateLimit = checkRateLimit(ip)
if (!rateLimit.allowed) {
return NextResponse.json(
{ suggestions: [] },
{
status: 429,
headers: {
'Retry-After': String(rateLimit.retryAfter || 60),
'X-RateLimit-Remaining': '0',
},
}
)
}
// Parse query parameters
const { searchParams } = new URL(request.url)
const query = searchParams.get('q')?.trim() || ''
const category = searchParams.get('category')?.trim()
const tenantParam = searchParams.get('tenant')
const limitParam = searchParams.get('limit')
const localeParam = searchParams.get('locale')?.trim()
// Validate locale
const validLocales = ['de', 'en']
const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de'
// Validate query
if (!query || query.length < MIN_QUERY_LENGTH) {
return NextResponse.json(
{ suggestions: [] },
{
headers: {
'X-RateLimit-Remaining': String(rateLimit.remaining),
'Cache-Control': 'public, max-age=60, s-maxage=60',
},
}
)
}
if (query.length > MAX_QUERY_LENGTH) {
return NextResponse.json(
{ error: `Query must be at most ${MAX_QUERY_LENGTH} characters` },
{ status: 400 }
)
}
// Parse numeric parameters
const limit = Math.min(
Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT),
MAX_LIMIT
)
const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined
// Validate tenant ID if provided
if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) {
return NextResponse.json(
{ error: 'Invalid tenant ID' },
{ status: 400 }
)
}
// Get payload instance
const payload = await getPayload({ config })
// Get suggestions
const suggestions = await getSearchSuggestions(payload, {
query,
tenantId,
categorySlug: category,
locale,
limit,
})
return NextResponse.json(
{ suggestions },
{
headers: {
'X-RateLimit-Remaining': String(rateLimit.remaining),
'Cache-Control': 'public, max-age=60, s-maxage=60',
},
}
)
} catch (error) {
console.error('[Suggestions API] Error:', error)
return NextResponse.json(
{ suggestions: [] },
{ status: 500 }
)
}
}

View file

@ -2,18 +2,15 @@ import React from 'react'
import './styles.css' import './styles.css'
export const metadata = { export const metadata = {
description: 'A blank template using Payload in a Next.js app.', description: 'Multi-Tenant CMS für mehrere Websites',
title: 'Payload Blank Template', title: 'Payload CMS',
} }
export default async function RootLayout(props: { children: React.ReactNode }) { export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props const { children } = props
return ( // This layout wraps both localized and non-localized routes
<html lang="en"> // The [locale]/layout.tsx handles the html/body for localized routes
<body> // For API routes etc, this provides basic wrapper
<main>{children}</main> return <>{children}</>
</body>
</html>
)
} }

View file

@ -1,59 +1,8 @@
import { headers as getHeaders } from 'next/headers.js' import { redirect } from 'next/navigation'
import Image from 'next/image' import { defaultLocale } from '@/lib/i18n'
import { getPayload } from 'payload'
import React from 'react'
import { fileURLToPath } from 'url'
import config from '@/payload.config' // This page catches requests to / and redirects to the default locale
import './styles.css' // The middleware should normally handle this, but this is a fallback
export default function RootPage() {
export default async function HomePage() { redirect(`/${defaultLocale}`)
const headers = await getHeaders()
const payloadConfig = await config
const payload = await getPayload({ config: payloadConfig })
const { user } = await payload.auth({ headers })
const fileURL = `vscode://file/${fileURLToPath(import.meta.url)}`
return (
<div className="home">
<div className="content">
<picture>
<source srcSet="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg" />
<Image
alt="Payload Logo"
height={65}
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg"
width={65}
/>
</picture>
{!user && <h1>Welcome to your new project.</h1>}
{user && <h1>Welcome back, {user.email}</h1>}
<div className="links">
<a
className="admin"
href={payloadConfig.routes.admin}
rel="noopener noreferrer"
target="_blank"
>
Go to admin panel
</a>
<a
className="docs"
href="https://payloadcms.com/docs"
rel="noopener noreferrer"
target="_blank"
>
Documentation
</a>
</div>
</div>
<div className="footer">
<p>Update this page by editing</p>
<a className="codeLink" href={fileURL}>
<code>app/(frontend)/page.tsx</code>
</a>
</div>
</div>
)
} }

40
src/app/robots.ts Normal file
View file

@ -0,0 +1,40 @@
import { MetadataRoute } from 'next'
/**
* robots.txt Generierung
*
* Steuert das Crawling-Verhalten von Suchmaschinen.
* Für Multi-Tenant können individuelle Regeln pro Domain erstellt werden.
*/
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || 'https://pl.c2sgmbh.de'
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: [
'/admin',
'/admin/*',
'/api/*',
'/_next/*',
'/media/*', // Optional: Media-Ordner ausschließen
],
},
// Spezifische Regeln für bekannte Bots
{
userAgent: 'Googlebot',
allow: '/',
disallow: ['/admin', '/api'],
},
{
userAgent: 'Bingbot',
allow: '/',
disallow: ['/admin', '/api'],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
host: baseUrl,
}
}

87
src/app/sitemap.ts Normal file
View file

@ -0,0 +1,87 @@
import { MetadataRoute } from 'next'
// Force dynamic rendering - don't generate at build time
export const dynamic = 'force-dynamic'
/**
* Dynamische Sitemap-Generierung
*
* Diese Funktion wird zur Laufzeit aufgerufen und generiert die Sitemap
* basierend auf den aktuellen Inhalten der Datenbank.
*/
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || 'https://pl.c2sgmbh.de'
// Basis-Sitemap mit Startseite
const sitemapEntries: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1.0,
},
]
// Dynamische Inhalte nur zur Laufzeit laden, nicht beim Build
if (process.env.NODE_ENV === 'production' && typeof window === 'undefined') {
try {
// Lazy import um Build-Zeit-Probleme zu vermeiden
const { getPayload } = await import('payload')
const config = (await import('@payload-config')).default
const payload = await getPayload({ config })
// Alle veröffentlichten Seiten abrufen
const pages = await payload.find({
collection: 'pages',
where: { status: { equals: 'published' } },
limit: 1000,
depth: 0,
})
for (const page of pages.docs) {
if (page.slug === 'home' || page.slug === 'startseite' || page.slug === '/') {
continue
}
sitemapEntries.push({
url: `${baseUrl}/${page.slug}`,
lastModified: new Date(page.updatedAt),
changeFrequency: 'weekly',
priority: 0.8,
})
}
// Alle veröffentlichten Blog-Posts abrufen
const posts = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
limit: 1000,
depth: 0,
})
for (const post of posts.docs) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const postType = (post as any).type || 'blog'
const urlPrefix =
postType === 'news'
? 'news'
: postType === 'press'
? 'presse'
: postType === 'announcement'
? 'aktuelles'
: 'blog'
sitemapEntries.push({
url: `${baseUrl}/${urlPrefix}/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'monthly',
priority: 0.6,
})
}
} catch (error) {
console.warn('[Sitemap] Could not fetch dynamic content:', error)
}
}
return sitemapEntries
}

24
src/instrumentation.ts Normal file
View file

@ -0,0 +1,24 @@
/**
* Next.js Instrumentation Hook
*
* Wird beim Server-Start ausgeführt. Initialisiert Scheduled Jobs.
* https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*/
export async function register() {
// Nur auf dem Server ausführen, nicht im Edge Runtime
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { getPayload } = await import('payload')
const config = await import('./payload.config')
const { initScheduledJobs } = await import('./jobs/scheduler')
// Payload initialisieren
const payload = await getPayload({
config: config.default,
})
// Scheduled Jobs starten
initScheduledJobs(payload)
console.log('[Instrumentation] Payload und Scheduled Jobs initialisiert.')
}
}

73
src/lib/envValidation.ts Normal file
View file

@ -0,0 +1,73 @@
// src/lib/envValidation.ts
/**
* Zentrale Validierung aller erforderlichen Environment-Variablen.
* Wird beim Server-Start aufgerufen und beendet den Prozess bei fehlenden Werten.
*/
interface RequiredEnvVars {
PAYLOAD_SECRET: string
DATABASE_URI: string
CONSENT_LOGGING_API_KEY: string
IP_ANONYMIZATION_PEPPER: string
}
const FORBIDDEN_VALUES = [
'',
'default-pepper-change-me',
'change-me',
'your-secret-here',
'xxx',
]
function validateEnvVar(name: string, value: string | undefined): string {
if (!value || value.trim() === '') {
throw new Error(
`FATAL: Environment variable ${name} is required but not set. ` +
`Server cannot start without this value.`,
)
}
if (FORBIDDEN_VALUES.includes(value.trim().toLowerCase())) {
throw new Error(
`FATAL: Environment variable ${name} has an insecure default value. ` +
`Please set a secure random value.`,
)
}
return value.trim()
}
/**
* Validiert alle erforderlichen Environment-Variablen.
* Wirft einen Fehler und beendet den Server-Start, wenn Variablen fehlen.
*/
export function validateRequiredEnvVars(): RequiredEnvVars {
return {
PAYLOAD_SECRET: validateEnvVar('PAYLOAD_SECRET', process.env.PAYLOAD_SECRET),
DATABASE_URI: validateEnvVar('DATABASE_URI', process.env.DATABASE_URI),
CONSENT_LOGGING_API_KEY: validateEnvVar(
'CONSENT_LOGGING_API_KEY',
process.env.CONSENT_LOGGING_API_KEY,
),
IP_ANONYMIZATION_PEPPER: validateEnvVar(
'IP_ANONYMIZATION_PEPPER',
process.env.IP_ANONYMIZATION_PEPPER,
),
}
}
/**
* Lazy-initialized Environment-Variablen.
* Wird erst beim ersten Zugriff validiert (vermeidet Build-Probleme).
*/
let _cachedEnv: RequiredEnvVars | null = null
export const env: RequiredEnvVars = new Proxy({} as RequiredEnvVars, {
get(_, prop: keyof RequiredEnvVars) {
if (!_cachedEnv) {
_cachedEnv = validateRequiredEnvVars()
}
return _cachedEnv[prop]
},
})

84
src/lib/i18n.ts Normal file
View file

@ -0,0 +1,84 @@
// src/lib/i18n.ts
// Frontend i18n configuration and utilities
export const locales = ['de', 'en'] as const
export type Locale = (typeof locales)[number]
export const defaultLocale: Locale = 'de'
/**
* Validate if a string is a valid locale
*/
export function isValidLocale(locale: string): locale is Locale {
return locales.includes(locale as Locale)
}
/**
* Get locale from pathname
* Returns the locale if found, otherwise returns the default locale
*/
export function getLocaleFromPathname(pathname: string): Locale {
const segments = pathname.split('/').filter(Boolean)
const firstSegment = segments[0]
if (firstSegment && isValidLocale(firstSegment)) {
return firstSegment
}
return defaultLocale
}
/**
* Remove locale prefix from pathname
*/
export function removeLocaleFromPathname(pathname: string): string {
const locale = getLocaleFromPathname(pathname)
const segments = pathname.split('/').filter(Boolean)
if (segments[0] === locale) {
segments.shift()
}
return '/' + segments.join('/')
}
/**
* Add locale prefix to pathname
*/
export function addLocaleToPathname(pathname: string, locale: Locale): string {
const cleanPathname = removeLocaleFromPathname(pathname)
// For default locale, we could optionally skip the prefix
// But for consistency, we'll always include it
return `/${locale}${cleanPathname === '/' ? '' : cleanPathname}`
}
/**
* Get alternate language links for SEO (hreflang)
*/
export function getAlternateLinks(
pathname: string,
baseUrl: string,
): { locale: Locale; url: string }[] {
const cleanPathname = removeLocaleFromPathname(pathname)
return locales.map((locale) => ({
locale,
url: `${baseUrl}/${locale}${cleanPathname === '/' ? '' : cleanPathname}`,
}))
}
/**
* Locale display names for UI
*/
export const localeNames: Record<Locale, { native: string; english: string }> = {
de: { native: 'Deutsch', english: 'German' },
en: { native: 'English', english: 'English' },
}
/**
* Get locale direction (for RTL support in future)
*/
export function getLocaleDirection(locale: Locale): 'ltr' | 'rtl' {
// Both German and English are LTR
return 'ltr'
}

526
src/lib/search.ts Normal file
View file

@ -0,0 +1,526 @@
// src/lib/search.ts
// Shared search library with caching, rate limiting, and search functions
import type { Payload, Where } from 'payload'
import type { Post, Category } from '../payload-types'
// ============================================================================
// Types
// ============================================================================
export interface SearchParams {
query: string
tenantId?: number
categorySlug?: string
type?: 'blog' | 'news' | 'press' | 'announcement'
locale?: string
limit?: number
offset?: number
}
export interface SearchResult {
results: SearchResultItem[]
total: number
query: string
filters: {
category?: string
type?: string
locale?: string
}
pagination: {
limit: number
offset: number
hasMore: boolean
}
}
export interface SearchResultItem {
id: number
title: string
slug: string
excerpt: string | null
publishedAt: string | null
type: string
category: {
name: string
slug: string
} | null
}
export interface SuggestionParams {
query: string
tenantId?: number
categorySlug?: string
locale?: string
limit?: number
}
export interface Suggestion {
title: string
slug: string
type: string
}
export interface CategoryFilterParams {
tenantId?: number
categorySlug?: string
type?: 'blog' | 'news' | 'press' | 'announcement'
locale?: string
page?: number
limit?: number
}
export interface PaginatedPosts {
docs: Post[]
totalDocs: number
page: number
totalPages: number
hasNextPage: boolean
hasPrevPage: boolean
}
export interface RateLimitResult {
allowed: boolean
remaining: number
retryAfter?: number
}
// ============================================================================
// TTL Cache Implementation
// ============================================================================
interface CacheEntry<T> {
value: T
expiresAt: number
}
class TTLCache<K, V> {
private cache = new Map<K, CacheEntry<V>>()
private readonly ttlMs: number
private cleanupInterval: ReturnType<typeof setInterval> | null = null
constructor(ttlSeconds: number = 60) {
this.ttlMs = ttlSeconds * 1000
// Cleanup expired entries every minute
this.cleanupInterval = setInterval(() => this.cleanup(), 60000)
}
get(key: K): V | undefined {
const entry = this.cache.get(key)
if (!entry) return undefined
if (Date.now() > entry.expiresAt) {
this.cache.delete(key)
return undefined
}
return entry.value
}
set(key: K, value: V): void {
this.cache.set(key, {
value,
expiresAt: Date.now() + this.ttlMs,
})
}
delete(key: K): boolean {
return this.cache.delete(key)
}
clear(): void {
this.cache.clear()
}
private cleanup(): void {
const now = Date.now()
for (const [key, entry] of this.cache.entries()) {
if (now > entry.expiresAt) {
this.cache.delete(key)
}
}
}
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
this.cache.clear()
}
}
// Cache instances (60 second TTL)
export const searchCache = new TTLCache<string, SearchResult>(60)
export const suggestionCache = new TTLCache<string, Suggestion[]>(60)
// ============================================================================
// Rate Limiting
// ============================================================================
interface RateLimitEntry {
count: number
windowStart: number
}
const rateLimitStore = new Map<string, RateLimitEntry>()
const RATE_LIMIT_WINDOW_MS = 60000 // 1 minute
const RATE_LIMIT_MAX_REQUESTS = 30 // 30 requests per minute
// Cleanup rate limit entries every 5 minutes
setInterval(() => {
const now = Date.now()
for (const [key, entry] of rateLimitStore.entries()) {
if (now - entry.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
rateLimitStore.delete(key)
}
}
}, 300000)
export function checkRateLimit(ip: string): RateLimitResult {
const now = Date.now()
const entry = rateLimitStore.get(ip)
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
// New window
rateLimitStore.set(ip, { count: 1, windowStart: now })
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - 1 }
}
if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
const retryAfter = Math.ceil((entry.windowStart + RATE_LIMIT_WINDOW_MS - now) / 1000)
return { allowed: false, remaining: 0, retryAfter }
}
entry.count++
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - entry.count }
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Extract plain text from Lexical JSONB content
*/
export function extractTextFromLexical(content: unknown): string {
if (!content || typeof content !== 'object') return ''
const extractFromNode = (node: unknown): string => {
if (!node || typeof node !== 'object') return ''
const n = node as Record<string, unknown>
let text = ''
// Get text content
if (typeof n.text === 'string') {
text += n.text + ' '
}
// Recursively process children
if (Array.isArray(n.children)) {
for (const child of n.children) {
text += extractFromNode(child)
}
}
return text
}
const c = content as Record<string, unknown>
if (c.root && typeof c.root === 'object') {
return extractFromNode(c.root).trim()
}
return ''
}
/**
* Build Payload where clause for search
*/
export function buildSearchWhere(
query: string,
filters: {
tenantId?: number
categorySlug?: string
type?: string
},
): Where {
const conditions: Where[] = [
// Only published posts
{ status: { equals: 'published' } },
]
// Tenant filter
if (filters.tenantId) {
conditions.push({ tenant: { equals: filters.tenantId } })
}
// Category filter (requires lookup or slug field)
// Note: We'll handle this separately since category is a relationship
// Type filter
if (filters.type) {
conditions.push({ type: { equals: filters.type } })
}
// Search in title and excerpt using ILIKE (case-insensitive contains)
if (query) {
conditions.push({
or: [{ title: { contains: query } }, { excerpt: { contains: query } }],
})
}
return { and: conditions }
}
/**
* Generate cache key from search parameters
*/
function generateCacheKey(prefix: string, params: Record<string, unknown>): string {
const sortedEntries = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null)
.sort(([a], [b]) => a.localeCompare(b))
return `${prefix}:${JSON.stringify(sortedEntries)}`
}
// ============================================================================
// Search Functions
// ============================================================================
/**
* Search posts with ILIKE (Phase 1)
* Future: Add FTS support with USE_FTS=true
*/
export async function searchPosts(
payload: Payload,
params: SearchParams,
): Promise<SearchResult> {
const { query, tenantId, categorySlug, type, locale = 'de', limit = 10, offset = 0 } = params
// Check cache
const cacheKey = generateCacheKey('search', { query, tenantId, categorySlug, type, locale, limit, offset })
const cached = searchCache.get(cacheKey)
if (cached) {
return cached
}
// Build where clause
const whereConditions: Where[] = [{ status: { equals: 'published' } }]
if (tenantId) {
whereConditions.push({ tenant: { equals: tenantId } })
}
if (type) {
whereConditions.push({ type: { equals: type } })
}
// Search in title and excerpt
if (query && query.length >= 2) {
whereConditions.push({
or: [{ title: { contains: query } }, { excerpt: { contains: query } }],
})
}
// Category filter - need to lookup category first if categorySlug provided
let categoryId: number | undefined
if (categorySlug) {
const categoryResult = await payload.find({
collection: 'categories',
where: {
slug: { equals: categorySlug },
...(tenantId ? { tenant: { equals: tenantId } } : {}),
},
locale: locale as 'de' | 'en',
fallbackLocale: 'de',
limit: 1,
})
if (categoryResult.docs.length > 0) {
categoryId = categoryResult.docs[0].id
whereConditions.push({ category: { equals: categoryId } })
}
}
const where: Where = whereConditions.length > 1 ? { and: whereConditions } : whereConditions[0]
// Execute search
const result = await payload.find({
collection: 'posts',
where,
locale: locale as 'de' | 'en',
fallbackLocale: 'de',
limit,
page: Math.floor(offset / limit) + 1,
sort: '-publishedAt',
depth: 1, // Include category relation
})
// Transform results
const searchResult: SearchResult = {
results: result.docs.map((post) => ({
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt || null,
publishedAt: post.publishedAt || null,
type: (post as Post & { type?: string }).type || 'blog',
category:
post.category && typeof post.category === 'object'
? { name: post.category.name, slug: post.category.slug }
: null,
})),
total: result.totalDocs,
query,
filters: {
category: categorySlug,
type,
locale,
},
pagination: {
limit,
offset,
hasMore: result.hasNextPage,
},
}
// Cache result
searchCache.set(cacheKey, searchResult)
return searchResult
}
/**
* Get search suggestions (auto-complete)
*/
export async function getSearchSuggestions(
payload: Payload,
params: SuggestionParams,
): Promise<Suggestion[]> {
const { query, tenantId, categorySlug, locale = 'de', limit = 5 } = params
if (!query || query.length < 2) {
return []
}
// Check cache
const cacheKey = generateCacheKey('suggestions', { query, tenantId, categorySlug, locale, limit })
const cached = suggestionCache.get(cacheKey)
if (cached) {
return cached
}
// Build where clause
const whereConditions: Where[] = [
{ status: { equals: 'published' } },
// Prefix search on title
{ title: { contains: query } },
]
if (tenantId) {
whereConditions.push({ tenant: { equals: tenantId } })
}
// Category filter
if (categorySlug) {
const categoryResult = await payload.find({
collection: 'categories',
where: {
slug: { equals: categorySlug },
...(tenantId ? { tenant: { equals: tenantId } } : {}),
},
locale: locale as 'de' | 'en',
fallbackLocale: 'de',
limit: 1,
})
if (categoryResult.docs.length > 0) {
whereConditions.push({ category: { equals: categoryResult.docs[0].id } })
}
}
const where: Where = { and: whereConditions }
// Execute query
const result = await payload.find({
collection: 'posts',
where,
locale: locale as 'de' | 'en',
fallbackLocale: 'de',
limit,
sort: '-publishedAt',
depth: 0,
})
// Transform to suggestions
const suggestions: Suggestion[] = result.docs.map((post) => ({
title: post.title,
slug: post.slug,
type: (post as Post & { type?: string }).type || 'blog',
}))
// Cache result
suggestionCache.set(cacheKey, suggestions)
return suggestions
}
/**
* Get posts by category with pagination
*/
export async function getPostsByCategory(
payload: Payload,
params: CategoryFilterParams,
): Promise<PaginatedPosts> {
const { tenantId, categorySlug, type, locale = 'de', page = 1, limit = 10 } = params
// Build where clause
const whereConditions: Where[] = [{ status: { equals: 'published' } }]
if (tenantId) {
whereConditions.push({ tenant: { equals: tenantId } })
}
if (type) {
whereConditions.push({ type: { equals: type } })
}
// Category filter
if (categorySlug) {
const categoryResult = await payload.find({
collection: 'categories',
where: {
slug: { equals: categorySlug },
...(tenantId ? { tenant: { equals: tenantId } } : {}),
},
locale: locale as 'de' | 'en',
fallbackLocale: 'de',
limit: 1,
})
if (categoryResult.docs.length > 0) {
whereConditions.push({ category: { equals: categoryResult.docs[0].id } })
}
}
const where: Where = whereConditions.length > 1 ? { and: whereConditions } : whereConditions[0]
// Execute query
const result = await payload.find({
collection: 'posts',
where,
locale: locale as 'de' | 'en',
fallbackLocale: 'de',
page,
limit,
sort: '-publishedAt',
depth: 1,
})
return {
docs: result.docs,
totalDocs: result.totalDocs,
page: result.page || page,
totalPages: result.totalPages,
hasNextPage: result.hasNextPage,
hasPrevPage: result.hasPrevPage,
}
}

480
src/lib/structuredData.ts Normal file
View file

@ -0,0 +1,480 @@
/**
* Structured Data (JSON-LD) Helpers
*
* Diese Funktionen generieren Schema.org-konforme JSON-LD Daten
* für verschiedene Content-Typen.
*
* Verwendung in Next.js:
* ```tsx
* import { generateArticleSchema } from '@/lib/structuredData'
*
* export default function BlogPost({ post }) {
* return (
* <>
* <script
* type="application/ld+json"
* dangerouslySetInnerHTML={{
* __html: JSON.stringify(generateArticleSchema(post))
* }}
* />
* <article>...</article>
* </>
* )
* }
* ```
*/
export interface OrganizationData {
name: string
url: string
logo?: string
description?: string
email?: string
phone?: string
address?: {
street?: string
city?: string
postalCode?: string
country?: string
}
socialProfiles?: string[]
}
export interface ArticleData {
title: string
description?: string
slug: string
publishedAt?: string
updatedAt?: string
author?: string
featuredImage?: {
url?: string
alt?: string
width?: number
height?: number
}
categories?: string[]
}
export interface WebPageData {
title: string
description?: string
url: string
updatedAt?: string
image?: {
url?: string
alt?: string
}
}
export interface BreadcrumbItem {
name: string
url: string
}
export interface FAQItem {
question: string
answer: string
}
export interface TestimonialData {
author: string
quote: string
rating?: number
company?: string
date?: string
}
/**
* Generiert Organization Schema
*/
export function generateOrganizationSchema(data: OrganizationData) {
const schema: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: data.name,
url: data.url,
}
if (data.logo) {
schema.logo = {
'@type': 'ImageObject',
url: data.logo,
}
}
if (data.description) {
schema.description = data.description
}
if (data.email) {
schema.email = data.email
}
if (data.phone) {
schema.telephone = data.phone
}
if (data.address) {
schema.address = {
'@type': 'PostalAddress',
streetAddress: data.address.street,
addressLocality: data.address.city,
postalCode: data.address.postalCode,
addressCountry: data.address.country || 'DE',
}
}
if (data.socialProfiles && data.socialProfiles.length > 0) {
schema.sameAs = data.socialProfiles
}
return schema
}
/**
* Generiert Article Schema für Blog-Posts und News
*/
export function generateArticleSchema(
data: ArticleData,
baseUrl: string,
organization?: OrganizationData
) {
const articleUrl = `${baseUrl}/blog/${data.slug}`
const schema: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: data.title,
url: articleUrl,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': articleUrl,
},
}
if (data.description) {
schema.description = data.description
}
if (data.publishedAt) {
schema.datePublished = data.publishedAt
}
if (data.updatedAt) {
schema.dateModified = data.updatedAt
}
if (data.author) {
schema.author = {
'@type': 'Person',
name: data.author,
}
} else if (organization) {
schema.author = {
'@type': 'Organization',
name: organization.name,
}
}
if (organization) {
schema.publisher = {
'@type': 'Organization',
name: organization.name,
url: organization.url,
...(organization.logo && {
logo: {
'@type': 'ImageObject',
url: organization.logo,
},
}),
}
}
if (data.featuredImage?.url) {
schema.image = {
'@type': 'ImageObject',
url: data.featuredImage.url,
...(data.featuredImage.alt && { caption: data.featuredImage.alt }),
...(data.featuredImage.width && { width: data.featuredImage.width }),
...(data.featuredImage.height && { height: data.featuredImage.height }),
}
}
if (data.categories && data.categories.length > 0) {
schema.keywords = data.categories.join(', ')
}
return schema
}
/**
* Generiert NewsArticle Schema für News und Pressemitteilungen
*/
export function generateNewsArticleSchema(
data: ArticleData,
baseUrl: string,
organization?: OrganizationData
) {
const baseSchema = generateArticleSchema(data, baseUrl, organization)
return {
...baseSchema,
'@type': 'NewsArticle',
}
}
/**
* Generiert WebPage Schema
*/
export function generateWebPageSchema(data: WebPageData, organization?: OrganizationData) {
const schema: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: data.title,
url: data.url,
}
if (data.description) {
schema.description = data.description
}
if (data.updatedAt) {
schema.dateModified = data.updatedAt
}
if (data.image?.url) {
schema.image = {
'@type': 'ImageObject',
url: data.image.url,
...(data.image.alt && { caption: data.image.alt }),
}
}
if (organization) {
schema.publisher = {
'@type': 'Organization',
name: organization.name,
}
}
return schema
}
/**
* Generiert BreadcrumbList Schema
*/
export function generateBreadcrumbSchema(items: BreadcrumbItem[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url,
})),
}
}
/**
* Generiert FAQPage Schema
*/
export function generateFAQSchema(items: FAQItem[]) {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
}
}
/**
* Generiert Review/Testimonial Schema
*/
export function generateReviewSchema(
testimonial: TestimonialData,
organization: OrganizationData
) {
const schema: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'Review',
reviewBody: testimonial.quote,
author: {
'@type': 'Person',
name: testimonial.author,
},
itemReviewed: {
'@type': 'Organization',
name: organization.name,
},
}
if (testimonial.rating) {
schema.reviewRating = {
'@type': 'Rating',
ratingValue: testimonial.rating,
bestRating: 5,
worstRating: 1,
}
}
if (testimonial.date) {
schema.datePublished = testimonial.date
}
return schema
}
/**
* Generiert AggregateRating Schema für Testimonials
*/
export function generateAggregateRatingSchema(
testimonials: TestimonialData[],
organization: OrganizationData
) {
const ratingsWithScore = testimonials.filter((t) => t.rating)
if (ratingsWithScore.length === 0) return null
const totalRating = ratingsWithScore.reduce((sum, t) => sum + (t.rating || 0), 0)
const averageRating = totalRating / ratingsWithScore.length
return {
'@context': 'https://schema.org',
'@type': 'Organization',
name: organization.name,
url: organization.url,
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: averageRating.toFixed(1),
bestRating: 5,
worstRating: 1,
ratingCount: ratingsWithScore.length,
},
}
}
/**
* Generiert LocalBusiness Schema
*/
export function generateLocalBusinessSchema(
data: OrganizationData & {
type?: string
openingHours?: string[]
priceRange?: string
geo?: {
latitude: number
longitude: number
}
}
) {
const schema: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': data.type || 'LocalBusiness',
name: data.name,
url: data.url,
}
if (data.logo) {
schema.image = data.logo
}
if (data.description) {
schema.description = data.description
}
if (data.email) {
schema.email = data.email
}
if (data.phone) {
schema.telephone = data.phone
}
if (data.address) {
schema.address = {
'@type': 'PostalAddress',
streetAddress: data.address.street,
addressLocality: data.address.city,
postalCode: data.address.postalCode,
addressCountry: data.address.country || 'DE',
}
}
if (data.openingHours && data.openingHours.length > 0) {
schema.openingHours = data.openingHours
}
if (data.priceRange) {
schema.priceRange = data.priceRange
}
if (data.geo) {
schema.geo = {
'@type': 'GeoCoordinates',
latitude: data.geo.latitude,
longitude: data.geo.longitude,
}
}
if (data.socialProfiles && data.socialProfiles.length > 0) {
schema.sameAs = data.socialProfiles
}
return schema
}
/**
* Generiert WebSite Schema mit SearchAction
*/
export function generateWebSiteSchema(
name: string,
url: string,
searchUrl?: string
) {
const schema: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name,
url,
}
if (searchUrl) {
schema.potentialAction = {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${searchUrl}?q={search_term_string}`,
},
'query-input': 'required name=search_term_string',
}
}
return schema
}
/**
* Kombiniert mehrere Schemas in ein Array
*/
export function combineSchemas(...schemas: (Record<string, unknown> | null)[]) {
const validSchemas = schemas.filter((s) => s !== null)
if (validSchemas.length === 0) return null
if (validSchemas.length === 1) return validSchemas[0]
return validSchemas
}
/**
* Hilfsfunktion zum sicheren Rendern von JSON-LD
*/
export function renderJsonLd(schema: Record<string, unknown> | Record<string, unknown>[] | null) {
if (!schema) return null
return JSON.stringify(schema, null, process.env.NODE_ENV === 'development' ? 2 : 0)
}

83
src/lib/tenantAccess.ts Normal file
View file

@ -0,0 +1,83 @@
// src/lib/tenantAccess.ts
import type { Access, PayloadRequest } from 'payload'
/**
* Ermittelt die Tenant-ID aus dem Request-Host.
* Gleicht die Domain mit der tenants-Collection ab.
*/
export async function getTenantIdFromHost(req: PayloadRequest): Promise<number | null> {
try {
// Host-Header extrahieren (unterstützt verschiedene Formate)
const headers = req.headers as Headers | Record<string, string | string[] | undefined>
const host =
typeof headers.get === 'function'
? headers.get('host')
: (headers as Record<string, string | string[] | undefined>)['host']
if (!host || typeof host !== 'string') {
return null
}
// Domain normalisieren: Port und www entfernen
const domain = host.split(':')[0].replace(/^www\./, '').toLowerCase().trim()
if (!domain) {
return null
}
// Tenant aus Datenbank suchen (domains ist ein Array mit domain-Subfeld)
const result = await req.payload.find({
collection: 'tenants',
where: {
'domains.domain': { equals: domain },
},
limit: 1,
depth: 0,
})
if (result.docs.length > 0 && result.docs[0]?.id) {
return Number(result.docs[0].id)
}
return null
} catch (error) {
console.error('[TenantAccess] Error resolving tenant from host:', error)
return null
}
}
/**
* Access-Control für öffentlich lesbare, aber tenant-isolierte Collections.
*
* - Authentifizierte Admin-User: Voller Lesezugriff
* - Anonyme Requests: Nur Daten des eigenen Tenants (basierend auf Domain)
*/
export const tenantScopedPublicRead: Access = async ({ req }) => {
// Authentifizierte Admins dürfen alles lesen
if (req.user) {
return true
}
// Anonyme Requests: Tenant aus Domain ermitteln
const tenantId = await getTenantIdFromHost(req)
if (!tenantId) {
// Keine gültige Domain → kein Zugriff
return false
}
// Nur Dokumente des eigenen Tenants zurückgeben
return {
tenant: {
equals: tenantId,
},
}
}
/**
* Access-Control: Nur authentifizierte User
*/
export const authenticatedOnly: Access = ({ req }) => {
return !!req.user
}

101
src/middleware.ts Normal file
View file

@ -0,0 +1,101 @@
// src/middleware.ts
// Next.js Middleware for locale detection and routing
import { NextRequest, NextResponse } from 'next/server'
import { locales, defaultLocale, isValidLocale, type Locale } from '@/lib/i18n'
// Paths that should not be affected by locale routing
const PUBLIC_FILE = /\.(.*)$/
const EXCLUDED_PATHS = ['/admin', '/api', '/_next', '/favicon.ico', '/robots.txt', '/sitemap.xml']
/**
* Detect user's preferred locale from Accept-Language header
*/
function getPreferredLocale(request: NextRequest): Locale {
const acceptLanguage = request.headers.get('accept-language')
if (!acceptLanguage) {
return defaultLocale
}
// Parse Accept-Language header
const languages = acceptLanguage
.split(',')
.map((lang) => {
const [code, priority] = lang.trim().split(';q=')
return {
code: code.split('-')[0].toLowerCase(), // Get primary language code
priority: priority ? parseFloat(priority) : 1,
}
})
.sort((a, b) => b.priority - a.priority)
// Find first matching locale
for (const { code } of languages) {
if (isValidLocale(code)) {
return code
}
}
return defaultLocale
}
/**
* Get locale from cookie
*/
function getLocaleFromCookie(request: NextRequest): Locale | null {
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value
if (cookieLocale && isValidLocale(cookieLocale)) {
return cookieLocale
}
return null
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Skip locale routing for excluded paths and public files
if (
EXCLUDED_PATHS.some((path) => pathname.startsWith(path)) ||
PUBLIC_FILE.test(pathname)
) {
return NextResponse.next()
}
// Check if pathname already has a valid locale prefix
const pathnameLocale = pathname.split('/')[1]
if (isValidLocale(pathnameLocale)) {
// Valid locale in URL, set cookie and continue
const response = NextResponse.next()
response.cookies.set('NEXT_LOCALE', pathnameLocale, {
maxAge: 60 * 60 * 24 * 365, // 1 year
path: '/',
})
return response
}
// No locale in URL, redirect to preferred locale
const cookieLocale = getLocaleFromCookie(request)
const preferredLocale = cookieLocale || getPreferredLocale(request)
// Build new URL with locale prefix
const newUrl = new URL(request.url)
newUrl.pathname = `/${preferredLocale}${pathname === '/' ? '' : pathname}`
// Redirect to localized URL
const response = NextResponse.redirect(newUrl)
response.cookies.set('NEXT_LOCALE', preferredLocale, {
maxAge: 60 * 60 * 24 * 365, // 1 year
path: '/',
})
return response
}
export const config = {
// Match all paths except static files and API routes
matcher: ['/((?!api|_next/static|_next/image|admin|favicon.ico).*)'],
}