BTST
PluginsQuickstartDocs
Live Blog

From research to product evaluation

Evaluating a publishing workflow for an app you already own?

See what the BTST Blog plugin adds to an existing React or Next.js app
BTST

Open-source TypeScript features for the React application, data, and deployment you already own.

Released plugins

  • Blog
  • AI Chat
  • CMS
  • Form Builder
  • UI Builder
  • Kanban
  • Comments
  • Media
  • Route Docs
  • OpenAPI
  • Better Auth UI

Resources

  • Quickstart
  • Documentation
  • All plugins
  • Live Blog
  • GitHub (opens in a new tab)
© 2026 BTST. Open source under the MIT License.
AI Chat
September 14, 2026ReactBlog

BTST Blog Search, Tags, and Pagination: Build a Public Index

Combine published-only filtering, tag slugs, search terms, and pagination while understanding the released search implementation and its scaling limits.

BTST Blog Search, Tags, and Pagination: Build a Public Index

A custom blog index needs a consistent definition of its result set. Apply publication status, tag selection, and search before deciding which page to show. Otherwise, a page can contain fewer results than expected or expose records that the public index should exclude.

BTST's Blog operation supports these filters together. This guide describes the released @btst/stack@3.0.2 behavior and the tradeoffs to consider before building a custom search page in Next.js, TanStack Start, or React Router.

Use the request-scoped list operation#

For a user-facing server request, use myStack.forRequest(request).operations.blog.listPosts(...). It runs input validation and the operation's authorization and lifecycle behavior. Your configured server policy must permit the intended public read.

For a public index, set published: true in application code. Do not forward an arbitrary visitor-supplied published value. Leaving the field unspecified is not a substitute for a public-content policy.

The operation accepts:

FieldMeaning in this release
publishedFilter on publication state
tagSlugMatch a stored tag slug
queryCase-insensitive substring search over title, body, or excerpt
offsetNonnegative integer result offset
limitInteger page size from 1 through 100
slugMatch one exact post slug

The search string is limited to 200 characters. It is not a relevance-ranked search engine: the implementation uses substring matching, not stemming, typo correction, or semantic search.

Parse the URL into a bounded query#

The following helper accepts an offset, search query, and tag slug while fixing public visibility and page size:

TS
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
import { PostListQuerySchema } from "@btst/stack/plugins/blog/api";

export function readBlogFilters(url: URL) {
  return PostListQuerySchema.parse({
    published: true,
    limit: 12,
    offset: url.searchParams.get("offset") ?? "0",
    query: url.searchParams.get("q")?.trim() || undefined,
    tagSlug: url.searchParams.get("tag") || undefined,
  });
}

Inside your existing server handler, pass that result to the request-scoped Blog operation. Handle a validation failure as invalid input according to your framework's response conventions; do not turn it into a successful response containing an unrestricted list.

Use the operation's items for the current page and total for the total number of matching records. The next offset is the current offset plus the page size. A next-page link is needed only when that offset is below total. Preserve q and tag when constructing pagination URLs with URLSearchParams.

When the visitor changes a search term or tag, reset the offset to zero. An offset that existed in a broader result set can legitimately return an empty page after a filter narrows it.

Understand which work happens in the database#

Without a search term, the released getter pushes publication state, exact slug, and tag membership filters into the adapter, then applies limit and offset there. It counts the matching records separately. An unknown tag returns an empty result.

With a search term, the getter retrieves the records matching the other filters, performs the text search in application memory, and slices the matches afterward. This preserves the meaning of total and pagination, but a page size of twelve does not limit that search to twelve database records.

For a small editorial collection, this may be sufficient. As content grows, measure response time and the number of records scanned for broad searches. A dedicated index or database search implementation may become appropriate. Keep publication and access rules aligned in any replacement, including how content is removed after unpublishing.

Results are sorted by createdAt descending in this release. Changing a post's publication timestamp does not turn this into a publication-date sort. Offset pagination can also shift when new records are inserted between page requests; do not promise a stable cursor or immutable snapshot.

Make search usable and discoverable#

Use a labeled search field, an explicit submit action, and links for pagination. A URL that includes the chosen term, tag, and offset lets visitors refresh or share their current results. Display the selected filter and provide a clear way to remove it.

For server-rendered frameworks, load the first result page on the server and hydrate the same query state. The TanStack blog SEO guide and React Router hydration guide cover that integration. Decide which curated tag or listing pages deserve search indexing; arbitrary search combinations do not automatically need sitemap entries.

Test empty results, an unknown tag, mixed-case searches, a query found only in the excerpt, and an offset beyond the end. Include a matching draft and verify that anonymous results exclude it. Check direct requests as well as navigation from the blog page.

See the Blog documentation, released list schema and operation, and getter implementation for the exact contract.

In This Post

Use the request-scoped list operationParse the URL into a bounded queryUnderstand which work happens in the databaseMake search usable and discoverable