Adding a Featured Posts Section to the Home Page with Velite & Next.js
Table of Contents
Overview
I added a "Featured" section to the home page that lets me pin selected posts in a grid. Adding featured: true to a post's frontmatter is all it takes to include it.
Here's the full implementation broken down into steps:
- Extend the Velite posts schema with a
featuredfield - Add a
getFeaturedPosts()helper - Build a
FeaturedPostCardcomponent - Handle tag overflow
- Wire everything up on the home page
Extending the Velite Schema
Velite defines frontmatter shape via schemas. Adding a field to velite.config.ts is straightforward:
// velite.config.ts
const posts = defineCollection({
schema: s.object({
// ...existing fields
published: s.boolean().default(true),
featured: s.boolean().default(false), // new
// ...
}),
});Using default(false) means existing posts need no changes — only posts that explicitly set featured: true are affected.
Adding getFeaturedPosts()
In src/lib/posts.ts, a one-liner on top of the existing getPublishedPosts() does the job:
export function getFeaturedPosts(locale?: Locale) {
return getPublishedPosts(locale).filter((post) => post.featured);
}Because getPublishedPosts() already filters out unpublished posts and sorts by date descending, this function inherits both behaviors for free. A post with published: false and featured: true will correctly be excluded.
Building FeaturedPostCard
Featured posts needed a visually distinct card — rounded border, subtle background — rather than the plain border-bottom style used in the regular post list. A dedicated component keeps the two concerns independent:
// src/components/blog/featured-post-card.tsx
const MAX_TAGS = 3;
export function FeaturedPostCard({ title, description, date, permalink, categories, tags, locale }) {
return (
<article className="group rounded-lg border border-border bg-muted/30 p-4 hover:bg-muted/50 transition-colors">
<Link href={permalink} className="block">
<h3 className="font-semibold tracking-tight group-hover:underline">
{title}
</h3>
{description && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
{description}
</p>
)}
</Link>
<div className="mt-2 flex flex-wrap items-center gap-2">
<time dateTime={date} className="text-xs text-muted-foreground">
{formatDate(date, locale)}
</time>
{/* category and tag badges */}
</div>
</article>
);
}Keeping this separate from PostCard also makes it easy to tune the tag display limit independently for each layout.
Handling Tag Overflow
Posts with many tags would overflow the card layout. The fix: show at most MAX_TAGS tags and display a +N indicator for the rest.
const MAX_TAGS = 3;
{tags.slice(0, MAX_TAGS).map((tag) => (
<TagBadge key={tag} slug={tag} locale={locale} />
))}
{tags.length > MAX_TAGS && (
<span className="text-xs text-muted-foreground">+{tags.length - MAX_TAGS}</span>
)}Extracting the limit into a MAX_TAGS constant means a single edit updates both the slice and the overflow count. The same pattern was applied to the existing PostCard to keep behavior consistent across both components.
Wiring Up the Home Page
In src/app/[locale]/page.tsx, the section only renders when there's at least one featured post:
const featuredPosts = getFeaturedPosts(locale);
{featuredPosts.length > 0 && (
<section className="mt-8 md:mt-12">
<h2 className="text-xl font-semibold mb-4">{t.home.featuredPosts}</h2>
<div className="grid gap-3 sm:grid-cols-2">
{featuredPosts.map((post) => (
<FeaturedPostCard key={post.permalink} {...post} locale={locale} />
))}
</div>
</section>
)}sm:grid-cols-2 switches to a two-column layout on small screens and above. The conditional render means removing all featured: true markers simply hides the section — no UI breakage.
UI strings live in src/lib/i18n.ts:
// ja
home: { featuredPosts: "注目記事" }
// en
home: { featuredPosts: "Featured" }Usage
Mark any post as featured by adding one line to its frontmatter:
---
title: "Post Title"
date: 2026-03-07
published: true
featured: true # add this
categories:
- programming
---Posts without featured default to false, so nothing breaks for existing content.
Conclusion
The Velite schema extension was the cleanest part — default(false) made the migration zero-impact on existing posts. Separating FeaturedPostCard from PostCard gave each component its own MAX_TAGS constant and styling, making future tweaks straightforward. The home page section is purely additive and self-hiding when empty, so it fits naturally into the existing layout.
