From 9e791648e9b74811e85b65e9585d585e9d348f14 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Fri, 27 Feb 2026 15:58:32 +0000 Subject: [PATCH] fix: support slug and where[] query params in custom posts route The custom /api/posts route intercepted all post queries but only supported listing parameters (category, type, page). Frontend detail pages sending where[slug][equals]=X got all posts back, always showing the latest post regardless of which article was clicked. Now parses slug from both ?slug=X and ?where[slug][equals]=X format. Replaced getPostsByCategory with direct payload.find using properly typed Where conditions. Detail queries (with slug) include content and readingTime in the response. Co-Authored-By: Claude Opus 4.6 --- src/app/(frontend)/api/posts/route.ts | 116 +++++++++++++++++--------- 1 file changed, 76 insertions(+), 40 deletions(-) diff --git a/src/app/(frontend)/api/posts/route.ts b/src/app/(frontend)/api/posts/route.ts index 9604885..fa60de4 100644 --- a/src/app/(frontend)/api/posts/route.ts +++ b/src/app/(frontend)/api/posts/route.ts @@ -3,8 +3,8 @@ import { NextRequest, NextResponse } from 'next/server' import { getPayload } from 'payload' +import type { Where } from 'payload' import config from '@payload-config' -import { getPostsByCategory } from '@/lib/search' import type { Category } from '@/payload-types' import { searchLimiter, @@ -42,11 +42,12 @@ export async function GET(request: NextRequest) { ) } - // Parse query parameters + // Parse query parameters (supports both ?key=val and ?where[key][equals]=val formats) 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') || searchParams.get('where[tenant][equals]') + const slugParam = searchParams.get('slug')?.trim() || searchParams.get('where[slug][equals]')?.trim() const pageParam = searchParams.get('page') const limitParam = searchParams.get('limit') const localeParam = searchParams.get('locale')?.trim() @@ -90,47 +91,82 @@ export async function GET(request: NextRequest) { // Get payload instance const payload = await getPayload({ config }) - // Get posts - const result = await getPostsByCategory(payload, { - tenantId, - categorySlug: category, - type, - locale, + // Build query: slug lookup uses direct payload.find for full where support + const whereConditions: Where[] = [ + { tenant: { equals: tenantId } }, + { status: { equals: 'published' } }, + ] + if (slugParam) { + whereConditions.push({ slug: { equals: slugParam } }) + } + if (type) { + whereConditions.push({ type: { equals: type } }) + } + if (category) { + // Category filter by slug: look up category first + const catResult = await payload.find({ + collection: 'categories', + where: { + slug: { equals: category }, + ...(tenantId ? { tenant: { equals: tenantId } } : {}), + }, + locale: locale as 'de' | 'en', + limit: 1, + }) + if (catResult.docs.length > 0) { + whereConditions.push({ categories: { contains: catResult.docs[0].id } }) + } + } + + const result = await payload.find({ + collection: 'posts', + where: { and: whereConditions }, + locale: locale as 'de' | 'en', + fallbackLocale: 'de', page, limit, + sort: '-publishedAt', + depth: slugParam ? 2 : 1, }) - // Transform response + // Transform response — slug lookup returns full post, listing returns summary 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: Array.isArray(post.categories) && post.categories.length > 0 - ? (() => { - const firstCat = post.categories.find( - (cat): cat is Category => cat !== null && typeof cat === 'object' && 'name' in cat - ) - return firstCat ? { name: firstCat.name, slug: firstCat.slug } : null - })() - : null, - categories: Array.isArray(post.categories) - ? post.categories - .filter((cat): cat is Category => cat !== null && typeof cat === 'object' && 'name' in cat) - .map((cat) => ({ name: cat.name, slug: cat.slug })) - : [], - })), + docs: result.docs.map((post) => { + const base = { + 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: Array.isArray(post.categories) && post.categories.length > 0 + ? (() => { + const firstCat = post.categories.find( + (cat): cat is Category => cat !== null && typeof cat === 'object' && 'name' in cat + ) + return firstCat ? { name: firstCat.name, slug: firstCat.slug } : null + })() + : null, + categories: Array.isArray(post.categories) + ? post.categories + .filter((cat): cat is Category => cat !== null && typeof cat === 'object' && 'name' in cat) + .map((cat) => ({ name: cat.name, slug: cat.slug })) + : [], + } + // Include content and extra fields for detail queries (slug lookup) + if (slugParam) { + return { ...base, content: post.content, readingTime: (post as typeof post & { readingTime?: number }).readingTime } + } + return base + }), pagination: { page: result.page, limit, @@ -140,8 +176,8 @@ export async function GET(request: NextRequest) { hasPrevPage: result.hasPrevPage, }, filters: { - category, - type, + ...(category ? { category } : {}), + ...(type ? { type } : {}), locale, tenant: tenantId, },