Build a Blog with Next.js #3: Tags and Search
Last time we finished the post list and detail pages. This time we fill in the two header links we placed earlier (/tags, /search) — adding a tag system and a search feature.
Tag list page #
Start with the page that lists every tag, with each tag’s post count next to it.
Add helper functions #
Add tag-related functions to src/app/lib/posts.js.
export function getAllTags() {
const posts = getAllPosts();
const tagCount = {};
for (const post of posts) {
for (const tag of post.frontmatter.tags ?? []) {
tagCount[tag] = (tagCount[tag] ?? 0) + 1;
}
}
return Object.entries(tagCount)
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count);
}
export function getPostsByTag(tag) {
return getAllPosts().filter(post =>
(post.frontmatter.tags ?? []).includes(tag)
);
}getAllTags returns an array of [{ tag, count }, ...] (sorted by count descending). getPostsByTag(tag) filters posts to those carrying that tag.
/tags page
#
src/app/tags/page.js:
import Link from 'next/link';
import { getAllTags } from '../lib/posts';
export default function TagsPage() {
const tags = getAllTags();
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>Tags</h1>
{tags.length === 0 ? (
<p>No tags.</p>
) : (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{tags.map(({ tag, count }) => (
<Link
key={tag}
href={`/tags/${tag}`}
style={{
padding: '6px 12px',
background: '#f0f0f0',
borderRadius: '16px',
textDecoration: 'none',
color: '#333',
}}
>
#{tag} <span style={{ color: '#888', fontSize: '12px' }}>({count})</span>
</Link>
))}
</div>
)}
</main>
);
}Save and visit /tags — every tag (sorted by count) shows up. Click one to navigate to that tag’s post list.
Per-tag post list page #
src/app/tags/[tag]/page.js:
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { getAllTags, getPostsByTag } from '../../lib/posts';
import PostCard from '../../PostCard';
export async function generateStaticParams() {
return getAllTags().map(({ tag }) => ({ tag }));
}
export default async function TagPage({ params }) {
const { tag } = await params;
const decodedTag = decodeURIComponent(tag);
const posts = getPostsByTag(decodedTag);
if (posts.length === 0) {
notFound();
}
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<Link href="/tags" style={{ fontSize: '14px' }}>← Tag list</Link>
<h1>#{decodedTag}</h1>
<p>{posts.length} posts</p>
<ul style={{ listStyle: 'none', padding: 0 }}>
{posts.map(post => (
<PostCard key={post.slug} post={post} />
))}
</ul>
</main>
);
}Key points:
decodeURIComponent
#
When the :tag part of the URL contains non-ASCII (say, “공지” — Korean for “announcement”), the browser URL-encodes it (/tags/%EA%B3%B5%EC%A7%80). params.tag arrives still encoded, so you have to call decodeURIComponent to match against the data.
generateStaticParams for static generation
#
export async function generateStaticParams() {
return getAllTags().map(({ tag }) => ({ tag }));
}Pre-generates static pages for every existing tag at build time. Same pattern as the post detail page.
Reusing PostCard #
We just import PostCard again. Although we built it for the home list in #2, the same shape suits the tag page. The benefit of factoring out a reusable unit shows here.
Verify (tag part) #
/tags— every tag listed by count- Click a tag → goes to that tag’s post list
- URLs with non-ASCII tags (e.g.,
/tags/공지) work - A nonexistent tag (
/tags/no-such-tag) → 404 - Clicking a post card’s tag also navigates to the same page
Search — searchParams #
Search differs a bit from tags. With /search?q=react style query strings, you read searchParams on the same page, not via a dynamic folder.
Search helper #
src/app/lib/posts.js:
export function searchPosts(query) {
if (!query || !query.trim()) return [];
const q = query.toLowerCase();
return getAllPosts().filter(post => {
const title = post.frontmatter.title.toLowerCase();
const description = (post.frontmatter.description ?? '').toLowerCase();
const content = post.content.toLowerCase();
return title.includes(q) || description.includes(q) || content.includes(q);
});
}A simple substring match. Real-world implementations would include stemming, weights, and the like, but it’s fine for learning. For larger blogs, you usually adopt a search engine like Algolia, Meilisearch, or Pagefind.
Search page #
src/app/search/page.js:
import { searchPosts } from '../lib/posts';
import PostCard from '../PostCard';
import SearchInput from './SearchInput';
export default async function SearchPage({ searchParams }) {
const params = await searchParams;
const query = params.q ?? '';
const results = query ? searchPosts(query) : [];
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>Search</h1>
<SearchInput defaultQuery={query} />
{query && (
<p style={{ marginTop: '16px', color: '#555' }}>
{results.length} results for "{query}"
</p>
)}
{query && results.length === 0 ? (
<p style={{ color: '#888' }}>No results.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, marginTop: '16px' }}>
{results.map(post => (
<PostCard key={post.slug} post={post} />
))}
</ul>
)}
</main>
);
}Key points:
searchParams is also a Promise
#
const params = await searchParams;
const query = params.q ?? '';Like params, starting in Next.js 15 searchParams is a Promise. Use it as an object after await.
The search page is dynamic #
A page that reads searchParams is automatically dynamic. Every request runs a fresh search, which is natural (with static generation you can’t possibly know every search term up front).
Search input — Client Component #
The input box needs user interaction, so it’s a Client Component.
src/app/search/SearchInput.jsx:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function SearchInput({ defaultQuery = '' }) {
const [query, setQuery] = useState(defaultQuery);
const router = useRouter();
function handleSubmit(e) {
e.preventDefault();
if (query.trim()) {
router.push(`/search?q=${encodeURIComponent(query.trim())}`);
} else {
router.push('/search');
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type and press Enter"
style={{ width: '100%', padding: '8px', fontSize: '16px' }}
/>
</form>
);
}We use useRouter to navigate to /search?q=... on form submit. Navigation re-renders the same page with new searchParams, refreshing the results.
Verify (search part) #
/search— empty search page- Type and press Enter → URL becomes
/search?q=reactetc., results show - Visit a URL directly (e.g.,
/search?q=announcement) — search runs as expected - No results → helpful message
- Click a tag in a result → goes to the tag page
The URL embeds the search state, so sharing the URL shows the same results and a refresh keeps the query. Things that require extra work with SPA search boxes come for free here.
Debounced search (optional) #
The current search waits for a form submit (Enter). For real-time as-you-type search, use a debounce pattern based on useDebounce (#13).
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
export default function SearchInput({ defaultQuery = '' }) {
const [query, setQuery] = useState(defaultQuery);
const debounced = useDebounce(query, 400);
const router = useRouter();
useEffect(() => {
const target = debounced.trim()
? `/search?q=${encodeURIComponent(debounced.trim())}`
: '/search';
router.replace(target);
}, [debounced, router]);
return (
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type to search automatically"
style={{ width: '100%', padding: '8px', fontSize: '16px' }}
/>
);
}When typing pauses for 400 ms, the URL updates and the Server Component re-runs to deliver fresh results. We use router.replace to avoid pushing a new history entry on every debounce (which would force the back button to step through each keystroke).
This series sticks with form submit for simplicity, but the debounce pattern is good to know.
Polishing the empty state #
Let’s make the search page’s empty state friendlier. We branch on no query, query but no results, and results.
// ...
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>Search</h1>
<SearchInput defaultQuery={query} />
{!query && (
<p style={{ marginTop: '16px', color: '#888' }}>
Search across titles, descriptions, and bodies.
</p>
)}
{query && (
<p style={{ marginTop: '16px', color: '#555' }}>
{results.length} results for "{query}"
</p>
)}
{query && results.length === 0 && (
<p style={{ color: '#888' }}>
No results. Try a different keyword.
</p>
)}
<ul style={{ listStyle: 'none', padding: 0, marginTop: '16px' }}>
{results.map(post => (
<PostCard key={post.slug} post={post} />
))}
</ul>
</main>
);Three states are explicitly branched — same conditional rendering as #7.
A note — static vs dynamic routes #
This post showed two routing patterns.
| Route | Pattern | Static/dynamic | Why |
|---|---|---|---|
/tags/[tag] | Dynamic folder + generateStaticParams | Static | The set of values is known at build time |
/search?q=... | Same page + searchParams | Dynamic | Infinitely many queries, can’t pre-generate |
Once you internalize this distinction, picking tools for a new page becomes natural. Known set of values → dynamic route + static generation, arbitrary input → query parameters + dynamic rendering.
Wrap-up #
This post covered two routing patterns.
- Tags:
/tags/[tag]dynamic folder +generateStaticParamsfor static generation - Search:
/search?q=...query parameters + dynamic rendering decodeURIComponentfor non-ASCII URLs- Search input is a Client Component; the result page is a Server Component
- (Optional)
useDebouncefor as-you-type search
So far our blog has been read-only. We’ve covered viewing, searching, and categorizing — users have had no way to submit anything yet. In the next post, “Build a Blog with Next.js #4 Comments (Server Actions),” we’ll add commenting on each post and put Server Actions from the Modern React series to serious use.