From 0c480fd12bfde19ba48788566bb418c42aef3f26 Mon Sep 17 00:00:00 2001 From: Mike Hartington Date: Mon, 11 May 2026 15:48:41 -0400 Subject: [PATCH] fix(blog): return results newest first --- apps/blog/src/app/api/search/route.ts | 62 +++++++++++++++++++-------- apps/blog/src/lib/search-types.ts | 1 + 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/apps/blog/src/app/api/search/route.ts b/apps/blog/src/app/api/search/route.ts index 4ebf1c91e3..49583bc4ba 100644 --- a/apps/blog/src/app/api/search/route.ts +++ b/apps/blog/src/app/api/search/route.ts @@ -2,6 +2,8 @@ import { createMixedbreadSearchAPI } from "fumadocs-core/search/mixedbread"; import Mixedbread from "@mixedbread/sdk"; import { type BlogSearchResult } from "../../../lib/search-types"; +// `generated_metadata` is stored as a generic object, but blog search always +// indexes the frontmatter fields below for each post. export type GeneratedMetadata = { title: string; slug: string; @@ -16,6 +18,7 @@ export type GeneratedMetadata = { excerpt: string; }; export const dynamic = "force-dynamic"; + const mixedbreadApiKey = process.env.MIXEDBREAD_API_KEY; if (!mixedbreadApiKey) { throw new Error("MIXEDBREAD_API_KEY environment variable is required"); @@ -26,26 +29,47 @@ export const { GET } = createMixedbreadSearchAPI({ client, storeIdentifier: "blog-search", topK: 20, + // Mixedbread can return multiple chunk hits for the same post. We normalize + // each hit into the blog search UI shape, then sort and dedupe the results. transform: (results, _query) => { - return results.flatMap((item) => { - const metadata = item.generated_metadata as unknown as GeneratedMetadata; - const slug = (metadata?.slug ?? "").replace(/^\/+/, ""); - const title = metadata?.metaTitle ?? metadata?.title ?? "Untitled"; + const seenUrls = new Set(); + + return results + .flatMap((item) => { + // Mixedbread types `generated_metadata` loosely, so we narrow it to the + // frontmatter shape we index for blog posts. + const metadata = item.generated_metadata as unknown as GeneratedMetadata; + const slug = (metadata?.slug ?? "").replace(/^\/+/, ""); + const title = metadata?.metaTitle ?? metadata?.title ?? "Untitled"; + const formattedUrl = slug ? `/${slug}` : "#"; + const base = `${item.file_id}-${item.chunk_index}`; + + const chunkResults: BlogSearchResult[] = [ + { + id: `${base}-page`, + type: "page", + content: title, + url: formattedUrl, + description: metadata?.metaDescription ?? "", + heroImagePath: metadata?.heroImagePath ?? "", + tags: metadata?.tags ?? [], + date: metadata.date, + }, + ]; + return chunkResults; + }) + // Dates are indexed as `YYYY-MM-DD`, so string comparison sorts them + // chronologically without converting them to `Date` objects. + .sort((a, b) => b.date.localeCompare(a.date)) + // Keep only the first hit for each post URL after sorting so duplicate + // chunk matches collapse into one search result. + .filter((item) => { + if (seenUrls.has(item.url)) { + return false; + } - const formattedUrl = slug ? `/${slug}` : "#"; - const base = `${item.file_id}-${item.chunk_index}`; - const chunkResults: BlogSearchResult[] = [ - { - id: `${base}-page`, - type: "page", - content: title, - url: formattedUrl, - description: metadata?.metaDescription ?? "", - heroImagePath: metadata?.heroImagePath ?? "", - tags: metadata?.tags ?? [], - }, - ]; - return chunkResults; - }); + seenUrls.add(item.url); + return true; + }); }, }); diff --git a/apps/blog/src/lib/search-types.ts b/apps/blog/src/lib/search-types.ts index 3f5c0b4f41..6e13af6474 100644 --- a/apps/blog/src/lib/search-types.ts +++ b/apps/blog/src/lib/search-types.ts @@ -6,4 +6,5 @@ export type BlogSearchResult = { description: string; heroImagePath: string; tags: string[]; + date: string; };