(SP: 3) [Frontend] Drizzle query layer + swap blog routes from Sanity to PostgreSQL#391
(SP: 3) [Frontend] Drizzle query layer + swap blog routes from Sanity to PostgreSQL#391ViktorSvertoka merged 8 commits intodevelopfrom
Conversation
…ript - Add FK indexes on blog_posts.author_id and blog_post_categories.category_id - Add one-time migration script: fetches Sanity data via REST API, re-uploads images to Cloudinary, converts Portable Text → Tiptap JSON, inserts into 7 blog tables (4 categories, 3 authors, 21 posts) - Drizzle migration 0028 for index changes Closes #384, #385
…routes Swap every blog page, API route, and header component from Sanity GROQ queries to Drizzle ORM against PostgreSQL. Adds Tiptap JSON renderer, shared text extraction, and typed query layer for posts/authors/categories. Fixes runtime crash where /api/blog-author returned Portable Text bio objects that React tried to render as children.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📝 WalkthroughWalkthroughThis PR replaces Sanity/GROQ blog data fetching with Drizzle/Postgres queries, adds locale-aware query functions for posts/categories/authors, introduces a TipTap JSON server-side BlogPostRenderer, updates blog types and components to the new shapes, and removes client-side GROQ fetches across blog routes. Changes
Sequence Diagram(s)sequenceDiagram
participant Browser
participant NextJS_Server
participant QueryLayer
participant Postgres
Browser->>NextJS_Server: GET /{locale}/blog/{slug}
NextJS_Server->>QueryLayer: getBlogPostBySlug(slug, locale)
QueryLayer->>Postgres: execute Drizzle query (posts + joins)
Postgres-->>QueryLayer: row(s) with translations, author, categories
QueryLayer-->>NextJS_Server: BlogPost object (with categories)
NextJS_Server->>NextJS_Server: render PostDetails -> BlogPostRenderer(content)
NextJS_Server-->>Browser: HTML response (rendered post)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~70 minutes Possibly related issues
Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
frontend/app/[locale]/blog/category/[category]/page.tsx (1)
72-73:⚠️ Potential issue | 🟠 MajorPosts can disappear when the first item has no image.
Line 72 gates featured rendering on
featuredPost?.mainImage, but Line 127 always drops the first post viaposts.slice(1). If the first post has no image, it won’t render anywhere.Suggested fix
- const featuredPost = posts[0]; + const featuredPost = posts[0]; + const showFeaturedHero = Boolean(featuredPost?.mainImage); - {featuredPost?.mainImage && ( + {showFeaturedHero && ( <section className="mt-10"> ... </section> )} <div className="mt-12"> - <BlogCategoryGrid posts={posts.slice(1)} /> + <BlogCategoryGrid posts={showFeaturedHero ? posts.slice(1) : posts} /> </div>Also applies to: 127-127
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/app/`[locale]/blog/category/[category]/page.tsx around lines 72 - 73, The featured-post rendering is gated on featuredPost?.mainImage while the list rendering unconditionally uses posts.slice(1), which causes the first post to be dropped entirely if it lacks an image; update the logic so the featured slot and the posts list stay consistent — either choose the featured post as the first post that has a mainImage (e.g., compute featuredPost = posts.find(p => p.mainImage) and then use posts.filter(p => p !== featuredPost) for the list), or stop gating the featured section on featuredPost?.mainImage and instead render a fallback for posts without images; adjust references to featuredPost and posts.slice(1) accordingly so no post is silently omitted.
🧹 Nitpick comments (6)
frontend/db/queries/blog/blog-authors.ts (1)
6-14: Consider using a stronger type forsocialMedia.The
socialMediafield is typed asunknown[], but based on the schema and consumer expectations (SocialLink[]), you could use a more specific type to improve type safety across the codebase.♻️ Proposed type improvement
+export interface SocialMediaLink { + platform?: string; + url?: string; +} + export interface BlogAuthorProfile { name: string; image: string | null; company: string | null; jobTitle: string | null; city: string | null; bio: string | null; - socialMedia: unknown[]; + socialMedia: SocialMediaLink[]; }Then update line 57:
- socialMedia: (row.socialMedia as unknown[]) ?? [], + socialMedia: (row.socialMedia as SocialMediaLink[]) ?? [],🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/db/queries/blog/blog-authors.ts` around lines 6 - 14, The BlogAuthorProfile type uses socialMedia: unknown[] which is too loose; change it to the correct, stronger type (e.g., SocialLink[] or an appropriate interface) so consumers get proper type safety. Update the BlogAuthorProfile declaration (the socialMedia field) to use the SocialLink[] type (or the exact SocialLink-like type used elsewhere) and ensure any imports or exports for SocialLink are added/adjusted so the file compiles and callers receive the stronger typing.frontend/components/blog/BlogPostRenderer.tsx (2)
145-156: Consider handling missing image src gracefully.If
node.attrs?.srcis undefined or empty,next/imagemay error or produce unexpected behavior. Consider returningnullor a placeholder when src is missing.♻️ Proposed improvement
case 'image': + if (!node.attrs?.src) return null; return ( <Image key={key} - src={node.attrs?.src ?? ''} + src={node.attrs.src} alt={node.attrs?.alt ?? 'Post image'} width={1200} height={800} sizes="100vw" className="my-6 h-auto w-full rounded-xl border border-gray-200" /> );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/components/blog/BlogPostRenderer.tsx` around lines 145 - 156, The image renderer in BlogPostRenderer's case 'image' uses node.attrs?.src directly which can be undefined/empty and cause next/image to error; update the case 'image' branch (where Image is returned) to check node.attrs?.src (and optionally node.attrs?.alt) and if the src is falsy return null or a lightweight placeholder element instead of rendering <Image>, otherwise render <Image> with the validated src and alt values; ensure you reference the same node.attrs?.src check and the Image component when making the change.
38-48: Consider sanitizing external link URLs.Links rendered from user content could potentially contain
javascript:URLs or other harmful schemes. Consider validating the href before rendering.🛡️ Proposed validation
case 'link': + const href = mark.attrs?.href; + const isSafeUrl = href && (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('/') || href.startsWith('#') || href.startsWith('mailto:')); + if (!isSafeUrl) { + // Render as plain text if URL is potentially unsafe + break; + } node = ( <a - href={mark.attrs?.href} + href={href} target="_blank" rel="noopener noreferrer" className="text-[var(--accent-primary)] underline underline-offset-4" > {node} </a> ); break;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/components/blog/BlogPostRenderer.tsx` around lines 38 - 48, The link case in BlogPostRenderer is rendering user-provided hrefs (mark.attrs?.href) without sanitization, which allows unsafe schemes like javascript:; update the case 'link' branch to validate/sanitize the URL before rendering: parse mark.attrs?.href (e.g., new URL or a safe parser), allow only safe protocols (http:, https:, mailto: or a configurable whitelist), and if the href is invalid or uses a disallowed scheme set a safe fallback (e.g., omit the href or use '#' and do not set target="_blank") or render the content as plain text; keep the existing rel="noopener noreferrer" but ensure target="_blank" is only added for validated http(s) links.frontend/db/queries/blog/blog-posts.ts (2)
85-97: Remove unusedbuildPostJoinshelper.This function is defined but never called. The join logic is duplicated inline in
getBlogPosts,getBlogPostBySlug, andgetBlogPostsByCategory.♻️ Either remove or use the helper
Option 1: Remove the unused function
-function buildPostJoins(locale: string) { - return (qb: any) => - qb - .leftJoin( - blogPostTranslations, - sql`${blogPostTranslations.postId} = ${blogPosts.id} AND ${blogPostTranslations.locale} = ${locale}` - ) - .leftJoin(blogAuthors, eq(blogAuthors.id, blogPosts.authorId)) - .leftJoin( - blogAuthorTranslations, - sql`${blogAuthorTranslations.authorId} = ${blogAuthors.id} AND ${blogAuthorTranslations.locale} = ${locale}` - ); -}Option 2: Refactor queries to use the helper (requires adjusting the query builder pattern)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/db/queries/blog/blog-posts.ts` around lines 85 - 97, The helper function buildPostJoins is defined but unused and duplicates join logic present in getBlogPosts, getBlogPostBySlug, and getBlogPostsByCategory; either delete buildPostJoins entirely, or update those three functions to call buildPostJoins(locale) as the join callback (adjusting the query-builder usage to accept a returned function) so the join logic is centralized and the duplicated leftJoin/sql blocks are removed from getBlogPosts, getBlogPostBySlug, and getBlogPostsByCategory.
209-214: Redundant author bio override.The
assemblePostfunction already setsbio: row.authorBio ?? nullat line 152. Re-assigning the same value here appears unnecessary.♻️ Simplify by returning base directly
const catMap = await attachCategories([row.id], locale); - const base = assemblePost(row as RawPostRow, catMap.get(row.id) ?? []); - - return { - ...base, - author: base.author - ? { ...base.author, bio: (row as RawPostRow).authorBio } - : undefined, - }; + return assemblePost(row as RawPostRow, catMap.get(row.id) ?? []); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/db/queries/blog/blog-posts.ts` around lines 209 - 214, The code redundantly overrides author.bio when assembling a post; in the assemblePost function avoid reassigning author.bio from (row as RawPostRow).authorBio since base already sets bio (authorBio ?? null). Replace the returned object that spreads base and reassigns author with simply returning base (i.e., remove the author: base.author ? {...} override) so the existing bio value is preserved and duplication removed.frontend/app/api/blog-search/route.ts (1)
10-17: Consider returning a lighter search payload.Line 13 and Line 17 currently return full
bodyJSON for all posts. This can get heavy as content grows. A better long-term shape isid/title/slug + plainText (or excerpt)precomputed server-side to reduce transfer and client filtering cost.Refactor sketch
+import { extractPlainText } from '@/lib/blog/text'; const items = posts.map(p => ({ id: p.id, title: p.title, - body: p.body, + bodyText: extractPlainText(p.body), slug: p.slug, }));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/app/api/blog-search/route.ts` around lines 10 - 17, The search route currently includes the full post body in the response; update the posts mapping in route.ts (the posts.map(...) that builds items and the NextResponse.json(items) return) to return a lighter payload: keep id, title, slug and replace body with a precomputed excerpt or plainText field (e.g., excerpt or plainText) trimmed to a safe length or generated from post.summary/plainText if available server-side; ensure the map uses the server-side precomputed field (or derives a short excerpt) before returning items so clients receive id/title/slug/excerpt instead of the full body.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@frontend/app/`[locale]/blog/[slug]/PostDetails.tsx:
- Line 224: PostDetails currently passes the unoptimized prop unconditionally on
the hero, recommended post, and author images which disables Next.js image
optimization; remove the unconditional unoptimized attribute in the PostDetails
component and instead mirror the conditional pattern used in BlogCard (use the
same check you have there — e.g., a boolean like disableImageOptimization or a
function that detects local/static URLs) so you only set unoptimized when that
condition is true; update the hero image, the recommended-post image rendering
in PostDetails (or RecommendedPost helper used there), and the author avatar
rendering to use that conditional flag rather than always passing unoptimized.
In `@frontend/app/`[locale]/layout.tsx:
- Around line 28-31: The layout currently calls getBlogCategories(locale) on
every render which can hammer the DB; wrap or replace that call with a cached
variant keyed by locale (e.g., implement cachedGetBlogCategories(locale) or add
caching inside getBlogCategories) that returns previously fetched categories for
a locale and only refreshes on TTL/invalidations (or use Next.js caching helpers
if available). Locate the call site in layout.tsx (the Promise.all that includes
getBlogCategories) and swap it to use the cached function, or modify the
existing getBlogCategories function to consult an in-memory Map/cache keyed by
locale (and implement a sensible TTL or explicit invalidation) so repeated
global layout requests do not trigger DB queries.
In `@frontend/components/blog/BlogCategoryLinks.tsx`:
- Around line 13-15: Update the test fixtures used by the BlogCategoryLinks
component so they match the new category schema: replace objects that pass {
_id, title } with { id, slug, title } (add a slug and rename _id to id) in
frontend/components/tests/blog/blog-category-links.test.tsx (the fixture around
lines where the test creates category props) and any other tests that construct
category props for BlogCategoryLinks; ensure the updated objects provide valid
id and slug strings so category.id and category.slug are defined in the
component.
In `@frontend/components/blog/BlogFilters.tsx`:
- Around line 387-390: The category title access is assumed non-null and can
throw when c.title is null; update the filtering and featured-label paths (look
for the postCategories mapping that uses normalizeTag(c.title) and the usages
around resolvedCategory.norm and getCategoryLabel) to defensively handle nullish
titles by skipping or defaulting them before calling normalizeTag and before
passing into getCategoryLabel—e.g., filter out or map to an empty string when
c.title is null, and ensure resolvedCategory input is validated (null/undefined)
before includes/getCategoryLabel is called so the filter and featured-label
rendering cannot fail on a missing title.
In `@frontend/db/schema/blog.ts`:
- Around line 4-5: Remove the duplicate import of the identifier "index" in the
import list in frontend/db/schema/blog.ts; locate the import statement that
currently contains "index, index," (referencing the imported symbol "index") and
delete the redundant entry so "index" is only imported once to resolve the
parse/compile error.
---
Outside diff comments:
In `@frontend/app/`[locale]/blog/category/[category]/page.tsx:
- Around line 72-73: The featured-post rendering is gated on
featuredPost?.mainImage while the list rendering unconditionally uses
posts.slice(1), which causes the first post to be dropped entirely if it lacks
an image; update the logic so the featured slot and the posts list stay
consistent — either choose the featured post as the first post that has a
mainImage (e.g., compute featuredPost = posts.find(p => p.mainImage) and then
use posts.filter(p => p !== featuredPost) for the list), or stop gating the
featured section on featuredPost?.mainImage and instead render a fallback for
posts without images; adjust references to featuredPost and posts.slice(1)
accordingly so no post is silently omitted.
---
Nitpick comments:
In `@frontend/app/api/blog-search/route.ts`:
- Around line 10-17: The search route currently includes the full post body in
the response; update the posts mapping in route.ts (the posts.map(...) that
builds items and the NextResponse.json(items) return) to return a lighter
payload: keep id, title, slug and replace body with a precomputed excerpt or
plainText field (e.g., excerpt or plainText) trimmed to a safe length or
generated from post.summary/plainText if available server-side; ensure the map
uses the server-side precomputed field (or derives a short excerpt) before
returning items so clients receive id/title/slug/excerpt instead of the full
body.
In `@frontend/components/blog/BlogPostRenderer.tsx`:
- Around line 145-156: The image renderer in BlogPostRenderer's case 'image'
uses node.attrs?.src directly which can be undefined/empty and cause next/image
to error; update the case 'image' branch (where Image is returned) to check
node.attrs?.src (and optionally node.attrs?.alt) and if the src is falsy return
null or a lightweight placeholder element instead of rendering <Image>,
otherwise render <Image> with the validated src and alt values; ensure you
reference the same node.attrs?.src check and the Image component when making the
change.
- Around line 38-48: The link case in BlogPostRenderer is rendering
user-provided hrefs (mark.attrs?.href) without sanitization, which allows unsafe
schemes like javascript:; update the case 'link' branch to validate/sanitize the
URL before rendering: parse mark.attrs?.href (e.g., new URL or a safe parser),
allow only safe protocols (http:, https:, mailto: or a configurable whitelist),
and if the href is invalid or uses a disallowed scheme set a safe fallback
(e.g., omit the href or use '#' and do not set target="_blank") or render the
content as plain text; keep the existing rel="noopener noreferrer" but ensure
target="_blank" is only added for validated http(s) links.
In `@frontend/db/queries/blog/blog-authors.ts`:
- Around line 6-14: The BlogAuthorProfile type uses socialMedia: unknown[] which
is too loose; change it to the correct, stronger type (e.g., SocialLink[] or an
appropriate interface) so consumers get proper type safety. Update the
BlogAuthorProfile declaration (the socialMedia field) to use the SocialLink[]
type (or the exact SocialLink-like type used elsewhere) and ensure any imports
or exports for SocialLink are added/adjusted so the file compiles and callers
receive the stronger typing.
In `@frontend/db/queries/blog/blog-posts.ts`:
- Around line 85-97: The helper function buildPostJoins is defined but unused
and duplicates join logic present in getBlogPosts, getBlogPostBySlug, and
getBlogPostsByCategory; either delete buildPostJoins entirely, or update those
three functions to call buildPostJoins(locale) as the join callback (adjusting
the query-builder usage to accept a returned function) so the join logic is
centralized and the duplicated leftJoin/sql blocks are removed from
getBlogPosts, getBlogPostBySlug, and getBlogPostsByCategory.
- Around line 209-214: The code redundantly overrides author.bio when assembling
a post; in the assemblePost function avoid reassigning author.bio from (row as
RawPostRow).authorBio since base already sets bio (authorBio ?? null). Replace
the returned object that spreads base and reassigns author with simply returning
base (i.e., remove the author: base.author ? {...} override) so the existing bio
value is preserved and duplication removed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 0910f02c-d27e-48cb-a403-5cb424e69c5c
📒 Files selected for processing (33)
frontend/actions/quiz.tsfrontend/app/[locale]/blog/[slug]/PostDetails.tsxfrontend/app/[locale]/blog/[slug]/page.tsxfrontend/app/[locale]/blog/category/[category]/page.tsxfrontend/app/[locale]/blog/page.tsxfrontend/app/[locale]/layout.tsxfrontend/app/api/blog-author/route.tsfrontend/app/api/blog-search/route.tsfrontend/components/blog/BlogCard.tsxfrontend/components/blog/BlogCategoryLinks.tsxfrontend/components/blog/BlogFilters.tsxfrontend/components/blog/BlogGrid.tsxfrontend/components/blog/BlogHeaderSearch.tsxfrontend/components/blog/BlogNavLinks.tsxfrontend/components/blog/BlogPostRenderer.tsxfrontend/components/header/AppChrome.tsxfrontend/components/header/AppMobileMenu.tsxfrontend/components/header/DesktopNav.tsxfrontend/components/header/MainSwitcher.tsxfrontend/components/header/MobileActions.tsxfrontend/components/header/UnifiedHeader.tsxfrontend/components/quiz/QuizzesSection.tsxfrontend/db/queries/blog/blog-authors.tsfrontend/db/queries/blog/blog-categories.tsfrontend/db/queries/blog/blog-posts.tsfrontend/db/schema/blog.tsfrontend/db/seed-blog-migration.tsfrontend/drizzle/meta/0027_snapshot.jsonfrontend/drizzle/meta/0028_snapshot.jsonfrontend/drizzle/meta/_journal.jsonfrontend/lib/blog/image.tsfrontend/lib/blog/text.tsfrontend/lib/services/orders/checkout.ts
💤 Files with no reviewable changes (1)
- frontend/components/blog/BlogNavLinks.tsx
| id: string; | ||
| slug: string; | ||
| title: string; |
There was a problem hiding this comment.
Update dependent test fixtures to the new category schema.
BlogCategoryLinks now requires { id, slug, title }, but frontend/components/tests/blog/blog-category-links.test.tsx (Lines 46-53) still passes { _id, title }. That creates invalid props for this component (category.slug/category.id undefined) and can break test behavior.
Proposed fixture update (test file)
render(
<BlogCategoryLinks
categories={[
- { _id: '1', title: 'Tech' },
- { _id: '2', title: 'Growth' },
+ { id: '1', slug: 'tech', title: 'Tech' },
+ { id: '2', slug: 'growth', title: 'Growth' },
]}
/>
);Also applies to: 83-83
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/components/blog/BlogCategoryLinks.tsx` around lines 13 - 15, Update
the test fixtures used by the BlogCategoryLinks component so they match the new
category schema: replace objects that pass { _id, title } with { id, slug, title
} (add a slug and rename _id to id) in
frontend/components/tests/blog/blog-category-links.test.tsx (the fixture around
lines where the test creates category props) and any other tests that construct
category props for BlogCategoryLinks; ensure the updated objects provide valid
id and slug strings so category.id and category.slug are defined in the
component.
There was a problem hiding this comment.
🧹 Nitpick comments (3)
frontend/db/schema/blog.ts (1)
102-104: Add an index for publish-window query paths.Nice refactor to callback-style indexes. Since public blog reads now filter by publish flags/timestamps, add a composite index to keep those queries scalable as rows grow.
💡 Suggested change
table => ({ authorIdx: index('blog_posts_author_id_idx').on(table.authorId), + publishWindowIdx: index('blog_posts_publish_window_idx').on( + table.isPublished, + table.scheduledPublishAt, + table.publishedAt + ), })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/db/schema/blog.ts` around lines 102 - 104, Add a composite index for publish-window queries alongside the existing authorIdx: update the table => ({ ... }) index block (where authorIdx is defined using index('blog_posts_author_id_idx').on(table.authorId)) to include a new index (e.g., blog_posts_publish_idx) that covers the publish flag/timestamp columns used by read queries (for example published/status, publishAt/start, and unpublishAt/end) so reads filtering by publish state and timestamps are supported efficiently as the table grows.frontend/app/[locale]/blog/[slug]/PostDetails.tsx (1)
55-58: Avoid loading the full post list before the 404 short-circuit.
getBlogPosts(locale)executes even when the slug does not exist. That adds avoidable DB load for invalid-slug traffic.Proposed change
- const [post, allPosts] = await Promise.all([ - getBlogPostBySlug(slugParam, locale), - getBlogPosts(locale), - ]); - - if (!post) return notFound(); + const post = await getBlogPostBySlug(slugParam, locale); + if (!post) return notFound(); + const allPosts = await getBlogPosts(locale);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/app/`[locale]/blog/[slug]/PostDetails.tsx around lines 55 - 58, Currently Promise.all triggers getBlogPosts(locale) even for missing slugs; change the logic in PostDetails.tsx to first await getBlogPostBySlug(slugParam, locale), check the returned post and short-circuit (throw/render 404) if missing, and only then call getBlogPosts(locale) to load allPosts; remove the Promise.all usage and sequence the calls so getBlogPosts is not executed for invalid slugs (reference functions getBlogPostBySlug, getBlogPosts and variables slugParam, locale, post).frontend/components/blog/BlogFilters.tsx (1)
379-408: Precompute searchable body text to keep filtering responsive.
extractPlainText(post.body)runs for every post on every filter pass. For larger datasets, this can cause noticeable UI lag while searching.Proposed change
+ const searchableBodyById = useMemo(() => { + const map = new Map<string, string>(); + for (const post of posts) { + map.set(post.id, normalizeSearchText(extractPlainText(post.body))); + } + return map; + }, [posts]); + const filteredPosts = useMemo(() => { return posts.filter(post => { @@ if (searchQueryNormalized) { const titleText = normalizeSearchText(post.title); - const bodyText = normalizeSearchText(extractPlainText(post.body)); + const bodyText = searchableBodyById.get(post.id) || ''; const words = searchQueryNormalized.split(/\s+/).filter(Boolean); @@ - }, [posts, resolvedAuthor, resolvedCategory, searchQueryNormalized]); + }, [ + posts, + resolvedAuthor, + resolvedCategory, + searchQueryNormalized, + searchableBodyById, + ]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/components/blog/BlogFilters.tsx` around lines 379 - 408, The filter recalculates extractPlainText(post.body) for every post on each render; precompute normalized searchable title/body once when posts change and use those in the filteredPosts useMemo. Add a new useMemo (dependent on posts) that maps each post to a lightweight derived object containing searchTitle = normalizeSearchText(post.title) and searchBody = normalizeSearchText(extractPlainText(post.body)), then update the existing filteredPosts useMemo to iterate over that derived array and reference searchTitle/searchBody instead of calling extractPlainText/normalizeSearchText inside the per-post filter.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@frontend/app/`[locale]/blog/[slug]/PostDetails.tsx:
- Around line 55-58: Currently Promise.all triggers getBlogPosts(locale) even
for missing slugs; change the logic in PostDetails.tsx to first await
getBlogPostBySlug(slugParam, locale), check the returned post and short-circuit
(throw/render 404) if missing, and only then call getBlogPosts(locale) to load
allPosts; remove the Promise.all usage and sequence the calls so getBlogPosts is
not executed for invalid slugs (reference functions getBlogPostBySlug,
getBlogPosts and variables slugParam, locale, post).
In `@frontend/components/blog/BlogFilters.tsx`:
- Around line 379-408: The filter recalculates extractPlainText(post.body) for
every post on each render; precompute normalized searchable title/body once when
posts change and use those in the filteredPosts useMemo. Add a new useMemo
(dependent on posts) that maps each post to a lightweight derived object
containing searchTitle = normalizeSearchText(post.title) and searchBody =
normalizeSearchText(extractPlainText(post.body)), then update the existing
filteredPosts useMemo to iterate over that derived array and reference
searchTitle/searchBody instead of calling extractPlainText/normalizeSearchText
inside the per-post filter.
In `@frontend/db/schema/blog.ts`:
- Around line 102-104: Add a composite index for publish-window queries
alongside the existing authorIdx: update the table => ({ ... }) index block
(where authorIdx is defined using
index('blog_posts_author_id_idx').on(table.authorId)) to include a new index
(e.g., blog_posts_publish_idx) that covers the publish flag/timestamp columns
used by read queries (for example published/status, publishAt/start, and
unpublishAt/end) so reads filtering by publish state and timestamps are
supported efficiently as the table grows.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 282b90ee-ea9c-4524-ac82-52a14de04a39
📒 Files selected for processing (5)
frontend/app/[locale]/blog/[slug]/PostDetails.tsxfrontend/app/[locale]/layout.tsxfrontend/components/blog/BlogFilters.tsxfrontend/db/queries/blog/blog-categories.tsfrontend/db/schema/blog.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- frontend/db/queries/blog/blog-categories.ts
Closes #386
Goal
Replace all Sanity GROQ queries in public blog routes with Drizzle ORM queries. After this PR, the public blog reads entirely from PostgreSQL — zero requests to
api.sanity.ioorcdn.sanity.io.Summary
New: Query layer —
db/queries/blog/(3 files)Pattern follows
db/queries/quizzes/admin-quiz.ts— multi-step fetch, assemble in JS, exported interfaces.blog-posts.ts—getBlogPosts(locale),getBlogPostBySlug(slug, locale),getBlogPostsByCategory(categorySlug, locale),getBlogPostSlugs(). Filters byisPublished = true AND (scheduledPublishAt IS NULL OR scheduledPublishAt <= NOW()). Joins post translations, author translations, categories. Converts DBnull→undefinedat query boundary (adapter pattern).blog-categories.ts—getBlogCategories(locale)— all categories ordered by displayOrder with locale-aware title.blog-authors.ts—getBlogAuthorByName(name, locale)— author profile lookup (name, bio, jobTitle, company, city, imageUrl, socialMedia).New:
BlogPostRenderer.tsx— server componentReplaces the 200+ line Portable Text renderer from PostDetails.tsx. Renders Tiptap JSON to semantic HTML with Tailwind. Recursive
renderNode/renderContentpattern. Supports: paragraph, heading (h1-h6), blockquote, bulletList, orderedList, codeBlock, image (next/image), hardBreak, text marks (bold, italic, code, link, strike, underline).New:
lib/blog/text.ts— sharedextractPlainText()Lightweight recursive walker for Tiptap JSON → plain text. Used in BlogFilters (excerpt generation, search filtering), BlogCard (excerpt), BlogHeaderSearch (search snippet). Replaces the old
plainTextFromPortableText()that walked Sanity Portable Text blocks.Modified: Blog pages (Sanity → Drizzle swap)
blog/page.tsx— removed GROQ query +clientimport. UsesgetBlogPosts(locale)+getBlogCategories(locale). Addedrevalidate = 3600.blog/[slug]/page.tsx—generateMetadatausesgetBlogPostBySlug. Removeddynamic = 'force-dynamic'. Addedrevalidate = 3600.blog/[slug]/PostDetails.tsx— replaced GROQ fetch withgetBlogPostBySlug. Replaced entirerenderPortableText*block (~200 lines) with<BlogPostRenderer content={body} />. Kept JSON-LD structured data, breadcrumbs, recommended posts with seeded shuffle.blog/category/[category]/page.tsx— usesgetBlogPostsByCategory(categorySlug, locale)+getBlogCategories(locale). Removed localslugify()— DB has proper slugs. Removed GROQ +clientimport.Modified: API routes
api/blog-search/route.ts— replaced GROQ withgetBlogPosts(locale), returns{id, title, body, slug}.api/blog-author/route.ts— replaced GROQ withgetBlogAuthorByName(name, locale). Bio is now plain text string (was Portable Text array — root cause of "Objects are not valid as React child" runtime error).Modified: Blog components (type migration)
BlogFilters.tsx— newPosttype (_id→id,slug.current→slug,body→ Tiptap JSON). Removed all Portable Text types (PortableTextSpan,PortableTextBlock,PortableTextImage,PortableText).Author.biois nowstring | null(was Portable Text). UsesextractPlainText()for excerpts and search.BlogCard.tsx— fixedLinkimport (next/link→@/i18n/routing). UsesextractPlainTextfor excerpt. Updated field refs (post.slug.current→post.slug).BlogGrid.tsx— key proppost._id→post.id.BlogHeaderSearch.tsx— updatedPostSearchItemtype (_id→id,slug→string). UsesextractPlainTextfor search snippets.Modified: Header components (category type update)
Category type changed from Sanity shape
{ _id: string; title: string }to DB shape{ id: string; slug: string; title: string }across all components:layout.tsx— replaced Sanity cached query (getCachedBlogCategories) withgetBlogCategories(locale). Removedgroq,client,unstable_cacheimports.AppChrome.tsx,MainSwitcher.tsx,UnifiedHeader.tsx,DesktopNav.tsx,MobileActions.tsx— updated category prop types.AppMobileMenu.tsx— updated category type, removed localslugify(), usescategory.slugdirectly.BlogCategoryLinks.tsx— updated category type, removed localslugify(), usescategory.slugdirectly.category._id→category.id.Modified:
lib/blog/image.tsshouldBypassImageOptimization()now always returnsfalse— Cloudinary URLs work natively with Next.js Image optimizer (no bypass needed unlike Sanity CDN).Deleted:
BlogNavLinks.tsxDead code — not imported anywhere. Was the old Sanity-based category navigation.
Test plan
npm run dev→ navigate/en/blog,/uk/blog,/pl/blog— posts load/en/blog/[any-slug]— post renders with correct body, images, author/en/blog/category/[cat]— category filter worksapi.sanity.ioorcdn.sanity.ionpm run build— no errorsSummary by CodeRabbit
Release Notes
New Features
Improvements