List Blog Posts
Public paginated list of published blog posts. Optional category filter (by slug). Lightweight items (no body, just excerpt). Categories embedded as { name, slug } pairs. CDN 5min/10min SWR.
GET /api/v1/public/blog โ ๐ Public ยท Rate limit: 60 req / minute
Returns a paginated list of published blog posts. PUBLISHED-only filter server-side. Each item carries the slug + title + excerpt + featured image + publishedAt + embedded categories โ no body (use GET /public/blog/:slug for the full content). Optional ?category=<slug> filter.
Lightweight by design. The list intentionally excludes the full HTML body โ keeps the payload small for index pages with many posts. Render the excerpt + featured image in the card; lazy-load the full content via GET /public/blog/:slug on click.
Limit clamped at 50. Server enforces min(max(limit, 1), 50). The default is 20.
Request
Query parameters
| Param | Type | Default | Validation | Notes |
|---|---|---|---|---|
page | number | 1 | ParseIntPipe, server-clamped to >= 1 | 1-based page index |
limit | number | 20 | ParseIntPipe, server-clamped to [1, 50] | Items per page |
category | string | โ | โ | Filter by BlogCategory.slug (e.g. product-updates, engineering) |
No headers required.
Response headers
| Header | Value |
|---|---|
Cache-Control | public, s-maxage=300, stale-while-revalidate=600 |
Response
200 OK โ ApiResponseOf<PublicBlogListDataDto>
{
"success": true,
"data": {
"items": [
{
"slug": "introducing-biore",
"title": "Introducing BIO.RE",
"excerpt": "A short excerpt of the post...",
"featuredImage": "https://cdn.bio.re/blog/cover.jpg",
"publishedAt": "2026-04-29T20:00:00.000Z",
"categories": [
{ "name": "Product Updates", "slug": "product-updates" }
]
}
],
"total": 42,
"page": 1,
"limit": 20,
"totalPages": 3
}
}Item fields
| Field | Type | Notes |
|---|---|---|
slug | string | Pass to GET /public/blog/:slug for the body |
title | string | Display title |
excerpt | string | null | Short summary for cards |
featuredImage | string | null | Cover image URL |
publishedAt | string (ISO 8601) | null | When the post was published |
categories | array | { name, slug } pairs โ categories embedded for filter UI / breadcrumbs |
Top-level fields
| Field | Type | Notes |
|---|---|---|
items | array | Up to limit posts, ordered by publishedAt DESC |
total | number | Total matching the filter |
page / limit / totalPages | number | Echoed pagination metadata |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
429 | (throttle) | Rate limit exceeded (60 req/min) |
Side effects
- Clamp
page = max(page, 1),limit = min(max(limit, 1), 50). - Build
whereโstatus = PUBLISHED, plus optionalcategories: { some: { category: { slug: categorySlug } } }filter whencategoryprovided. - In parallel:
blogPost.findMany({ where, orderBy: publishedAt desc, include: { categories: { include: { category: true } } }, skip, take })+count({ where }). - Map each row โ strip internal fields, project category embed to
{ name, slug }. - Return paginated envelope. No mutations.
Code samples
# All blog posts
curl 'https://api.bio.re/api/v1/public/blog?page=1&limit=20'
# Filtered by category
curl 'https://api.bio.re/api/v1/public/blog?category=product-updates'type BlogCategoryEmbed = {
name: string;
slug: string;
};
type BlogListItem = {
slug: string;
title: string;
excerpt: string | null;
featuredImage: string | null;
publishedAt: string | null;
categories: BlogCategoryEmbed[];
};
type BlogListData = {
items: BlogListItem[];
total: number;
page: number;
limit: number;
totalPages: number;
};
async function listBlogPosts(
filter: { page?: number; limit?: number; category?: string } = {},
): Promise<BlogListData> {
const url = new URL('https://api.bio.re/api/v1/public/blog');
if (filter.page) url.searchParams.set('page', String(filter.page));
if (filter.limit) url.searchParams.set('limit', String(filter.limit));
if (filter.category) url.searchParams.set('category', filter.category);
const res = await fetch(url);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Blog list fetch failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useQuery, keepPreviousData } from '@tanstack/react-query';
export const contentKeys = {
blogList: (filter: { page?: number; limit?: number; category?: string }) =>
['content', 'blog', 'list', filter] as const,
};
export function useBlogList(filter: { page?: number; limit?: number; category?: string } = {}) {
return useQuery({
queryKey: contentKeys.blogList(filter),
queryFn: async () => {
const url = new URL('/api/v1/public/blog', window.location.origin);
if (filter.page) url.searchParams.set('page', String(filter.page));
if (filter.limit) url.searchParams.set('limit', String(filter.limit));
if (filter.category) url.searchParams.set('category', filter.category);
const res = await fetch(url);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Blog list fetch failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data as BlogListData;
},
placeholderData: keepPreviousData, // smooth pagination
staleTime: 5 * 60_000,
});
}Try it
Query Parameters
Page number (default: 1)
Items per page (default: 20, max: 50)
Filter by category slug
Response Body
application/json
curl -X GET "https://loading/api/v1/public/blog"{
"success": true,
"data": {
"items": [
{
"slug": "introducing-biore",
"title": "Introducing BIO.RE",
"excerpt": "A short excerpt of the post...",
"featuredImage": "https://cdn.bio.re/blog/cover.jpg",
"publishedAt": "2019-08-24T14:15:22Z",
"categories": [
{
"slug": "product-updates",
"name": "Product Updates"
}
]
}
],
"total": 42,
"page": 1,
"limit": 20,
"totalPages": 3
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/content/public-content.controller.ts | 101โ136 (listBlogPosts) |
| DTO (response) | apps/api-core/src/modules/content/dto/content-public-response.dto.ts | 96โ111 (PublicBlogListDataDto), 73โ91 (PublicBlogListItemDto), 61โ67 (PublicBlogCategoryEmbedDto) |
| Service | apps/api-core/src/modules/content/content.service.ts | 551โ572 (listPublishedBlogPosts) |
| Prisma models | packages/prisma/prisma/schema.prisma | BlogPost (filter status = PUBLISHED), BlogCategory (embedded via join), enum ContentStatus |
Get Active Announcements
Public list of currently-active announcements. Filtered by active flag + time window. Cap 50. Cache 60s. HTML body server-side sanitized.
Blog RSS Feed
RSS 2.0 XML feed of the last 20 published blog posts. Content-Type application/rss+xml. Bot-detection skipped (feed readers should not be flagged). CDN 15min/30min SWR.