Blog
The tenant's published articles as a paginated, crawlable grid (?page=N links).
Anatomy
Every piece of Blog as it nests in code. A root holds the data and state; its parts and hooks only work inside it.
<PandaProvider>Mounted once in app/layout.tsx<BlogList />complete widgetThe tenant's published articles as a paginated, crawlable grid (?page=N links).
<BlogPost>One article: title, author, categories, date, cover, sanitized body, share bar and the next article.<BlogArticleBody />The story body: the HTML the widgets API already sanitized (no scripts, handlers or javascript: URLs) with the…
</BlogPost></PandaProvider>Installation
- 1npm install <tarball or @pandaidx/widgets>Terminalnpm install https://github.com/pandaidx/panda-widgets/releases/download/v1.14.1/pandaidx-widgets-1.14.1.tgz
- 2import "@pandaidx/widgets/styles.css" in app/layout.tsx
- 3Wrap the site in <PandaProvider publicKey apiUrl preset theme> (app/layout.tsx)
- 4In each page (Server Component) call the widget's server loader, then render the widget with its data
- 5Allow the images: next.config images.remotePatterns [{ protocol: "https", hostname: "**" }] (listing and blog photos come from several CDNs)
Usage
// app/blog/page.tsximport { BlogList } from "@pandaidx/widgets/client"import { fetchBlogPosts } from "@pandaidx/widgets/server"export default async function BlogPage({ searchParams }: { searchParams: Promise<{ page?: string; category?: string }> }) {const { page, category } = await searchParamsconst data = await fetchBlogPosts({ publicKey: process.env.NEXT_PUBLIC_PANDA_PUBLIC_KEY!, apiUrl: process.env.NEXT_PUBLIC_PANDA_API_URL, page, category })return <BlogList data={data} category={category} columns={3} cardVariant="classic" featuredFirst />}// Panda websites stories: app/stories/page.tsx (+ app/stories/category/[slug]/page.tsx with category={slug})const data = await fetchBlogPosts({ publicKey, apiUrl, all: true, category })<BlogList variant="panda" data={data} category={category} />
Examples
A stories index in the site's own cards, pills and pager
// app/stories/page.tsximport { fetchBlogPosts } from "@pandaidx/widgets/server"import { StoriesView } from "@/components/stories-view"export default async function StoriesPage({ searchParams }: { searchParams: Promise<{ page?: string }> }) {const { page } = await searchParamsconst data = await fetchBlogPosts({ publicKey: process.env.NEXT_PUBLIC_PANDA_PUBLIC_KEY!, apiUrl: process.env.NEXT_PUBLIC_PANDA_API_URL, page: Number(page) || 1, pageSize: 9 })return <StoriesView data={data} />}// components/stories-view.tsx"use client"import Image from "next/image"import Link from "next/link"import { useBlogList, type WidgetBlogPage } from "@pandaidx/widgets/client"export const StoriesView = ({ data }: { data: WidgetBlogPage }) => {const blog = useBlogList({ data })if (blog.empty) return <p>No stories yet.</p>return (<section className="mx-auto max-w-6xl px-4 py-16"><nav className="mb-8 flex flex-wrap gap-2"><Link href={blog.indexHref}>All</Link>{blog.categories.map((category) => <Link key={category.slug} href={category.href}>{category.label}</Link>)}</nav><div className="grid gap-8 md:grid-cols-3">{blog.stories.map((story) => (<article key={story.slug}><Link href={story.href}>{story.image ? <Image src={story.image} alt="" width={640} height={400} sizes="(min-width: 768px) 33vw, 100vw" className="aspect-[16/10] w-full object-cover" /> : null}<h2 className="mt-4 text-xl">{story.title}</h2></Link><p className="text-sm opacity-70">{story.dateLabel}{story.readMinutes ? ` · ${story.readMinutes} min read` : ""}</p></article>))}</div><div className="mt-10 flex justify-between">{blog.prevHref ? <Link href={blog.prevHref}>Previous</Link> : <span />}{blog.nextHref ? <Link href={blog.nextHref}>Next</Link> : null}</div></section>)}
A story page with its own header and next box
// app/story/[slug]/page.tsximport { notFound } from "next/navigation"import { fetchBlogPost } from "@pandaidx/widgets/server"import { StoryView } from "@/components/story-view"export default async function StoryPage({ params }: { params: Promise<{ slug: string }> }) {const { slug } = await paramsconst article = await fetchBlogPost({ publicKey: process.env.NEXT_PUBLIC_PANDA_PUBLIC_KEY!, apiUrl: process.env.NEXT_PUBLIC_PANDA_API_URL, slug })if (!article) notFound()return <StoryView article={article} />}// components/story-view.tsx"use client"import Image from "next/image"import Link from "next/link"import { BlogArticleBody, ShareButton, useBlogArticle, type WidgetBlogArticle } from "@pandaidx/widgets/client"export const StoryView = ({ article }: { article: WidgetBlogArticle }) => {const { story, next, indexHref } = useBlogArticle({ article })return (<main className="mx-auto max-w-3xl px-4 py-16"><Link href={indexHref} className="text-sm underline">All stories</Link><h1 className="mt-4 text-4xl">{story.title}</h1><p className="mt-2 text-sm opacity-70">{story.author?.name} · {story.dateLabel}{story.readMinutes ? ` · ${story.readMinutes} min read` : ""}</p>{story.image ? <Image src={story.image} alt="" width={1200} height={675} sizes="(min-width: 768px) 768px, 100vw" priority className="mt-8 w-full object-cover" /> : null}<BlogArticleBody article={article} className="mt-8" />{next ? <Link href={next.href} className="mt-12 block border-t pt-6">Next: {next.title}</Link> : null}</main>)}
API reference
BlogList
The tenant's published articles as a paginated, crawlable grid (?page=N links). variant="panda": the Panda websites /stories page (headline, featured story with a 2/3 cover, "Trending Posts" category pills on desktop, 1/2/3-column grid of 240px covers with relative date and read time; with category, breadcrumb + category title) — load it with fetchBlogPosts({ all: true }) (websites lists every story on one page).
Import from @pandaidx/widgets/client · renders inside PandaProvider
| Prop | Type | Default | Description |
|---|---|---|---|
| data* | object | — | Page of articles. |
| basePath | string | "/blog" | Host blog path; articles open at <basePath>/<slug>. panda: the index path (default /stories, breadcrumb root and page links); articles use articleHref. |
| category | string | — | Current category slug (kept in pagination links). panda: category page mode (breadcrumb, category title, no trending pills; pages at categoryHref). |
| emptyMessage | string | "No articles published yet." | Copy when there are no articles. |
| columns | object | 3 | Grid columns: a number 1-4 (desktop; phones 1, tablets up to 2) or { base, sm, md, lg, xl } per breakpoint. |
| cardVariant | "classic" | "overlay" | "minimal" | "horizontal" | "classic" | Card design: cover on top, title over the cover, frameless, or cover beside the text. |
| showExcerpt | boolean | true | Shows the excerpt on cards. |
| showAuthor | boolean | false | Shows the author next to the date. |
| featuredFirst | boolean | false | First article of page 1 as a full-width featured card (panda: default true, the 2/3 featured story). |
| title | string | — | panda: headline title. Default "Latest Stories" (the category name on a category page). |
| description | string | — | panda: headline subtitle. Default "The most valuable stories you don't want to miss" (none on a category page). |
| showHeadline | boolean | true | panda: headline (and breadcrumb on a category page). |
| showCategories | boolean | — | panda: "Trending Posts" category pills (desktop only, like websites). Default true on the index, false on a category page. |
| showCategoriesTitle | boolean | true | panda: "Trending Posts" title above the pills. |
| articleHref | string | "/story/{slug}" | panda: article route ({slug}). |
| categoryHref | string | "/stories/category/{slug}" | panda: category route ({slug}). |
| authorHref | string | "/profile" | panda: author (site owner) link of the featured story. |
| classNames | object | — | Host classes appended to any blog slot (see slots; panda reads the pagination slots). |
| preset | "default" | "template1" | — | Preset for this widget only (defaults to the provider preset). |
| variant | "panda" | "default" | "template1" | — | panda: the Panda websites stories index. Other values: deprecated alias of preset. |
| theme | object | — | Token overrides for this widget only, merged over the provider theme (same shape as PandaProvider.theme). |
BlogPost
One article: title, author, categories, date, cover, sanitized body, share bar and the next article. variant="panda": the Panda websites /story/<slug> page (768px column, bold title, author avatar + name + relative date + read time with a Share button, prose body with the editor's inline cover — load with keepCover: true —, category pills and the "Related Stories" list).
Import from @pandaidx/widgets/client · renders inside PandaProvider
| Prop | Type | Default | Description |
|---|---|---|---|
| article* | object | — | Article + next article (null → notFound()). |
| basePath | string | "/blog" | Host blog path (category and next-article links). |
| layout | "centered" | "wide" | "centered" | Reading column (830px) or container-wide article. |
| showShare | boolean | true | Shows the social share bar (panda: the Share button of the author row). |
| showNext | boolean | true | Shows the next-article box (not in panda). |
| showAuthor | boolean | true | panda: author row (avatar, name, relative date, read time). |
| showCategories | boolean | true | panda: category pills under the body. |
| showRelated | boolean | true | panda: "Related Stories" list (article.related, up to 5 sharing a category). |
| relatedTitle | string | "Related Stories" | panda: heading of the related list. |
| shareUrl | string | — | panda: link the Share dialog shares. Default: the current page URL. |
| maxWidth | number | 768 | panda: reading column width (px). |
| titleTag | "div" | "h1" | "div" | panda: title element (websites renders a div). |
| articleHref | string | "/story/{slug}" | panda: route of the related stories ({slug}). |
| categoryHref | string | "/stories/category/{slug}" | panda: category route ({slug}). |
| authorHref | string | "/profile" | panda: author (site owner) link. |
| classNames | object | — | Host classes appended to any blog slot (see slots). |
| preset | "default" | "template1" | — | Preset for this widget only (defaults to the provider preset). |
| variant | "panda" | "default" | "template1" | — | panda: the Panda websites article page. Other values: deprecated alias of preset. |
| theme | object | — | Token overrides for this widget only, merged over the provider theme (same shape as PandaProvider.theme). |
Parts
<BlogArticleBody />The story body: the HTML the widgets API already sanitized (no scripts, handlers or javascript: URLs) with the dashboard editor's content styles (headings, lists, quotes, images, tables). Use it in your own article layout instead of injecting the HTML yourself; className sets the width, the prose size and colors.
Hooks
useBlogList()inside PandaProvideruseBlogList({ data, category?, articleHref?: "/story/{slug}", categoryHref?: "/stories/category/{slug}", authorHref?: "/profile", indexHref?: "/stories" }) → { stories: { slug, title, subtitle, excerpt, image, href, categories: { slug, label, href }[], publishedAt, dateLabel, readMinutes, author, authorHref }[], featured (first story of page 1), rest, categories (pills, order of appearance), category, categoryTitle, indexHref, total, page, pageSize, totalPages, pageHref(page), prevHref, nextHref, empty } — data comes from fetchBlogPosts (all: true for every story on one page, category for the category page). Every href is the route of the Panda website (/story/<slug>, /stories/category/<slug>): link with it, never rebuild it. dateLabel is the same text on server and browser (UTC). Draw the header, cards, pills and pager in the site's style; BlogList stays the ready-made page. Client component only.
useBlogArticle()inside BlogPostuseBlogArticle({ article, articleHref?, categoryHref?, authorHref?, indexHref? }) → { story: { slug, title, subtitle, excerpt, image, href, categories: { slug, label, href }[], publishedAt, dateLabel, readMinutes, author, authorHref }, next (the next older story, wraps to the newest; null when alone), related (up to 5 sharing a category), indexHref } — article comes from fetchBlogPost (null → notFound(); keepCover: true keeps the editor's inline cover in the body). Draw the title, cover, author, date and the next / related boxes in the site's style; the body is <BlogArticleBody article={article} /> and sharing is <ShareButton url name image context="story"> around your own button. BlogPost stays the ready-made page. Client component only.
Server data
Loaders run in a Server Component and come from @pandaidx/widgets/server. Typed data is for a page you design yourself, rendered inside the root.
fetchBlogPosts()widget loaderOne page of published articles. Returns WidgetBlogPage → data
fetchBlogPost()widget loaderOne article by slug; null when it does not exist. Returns WidgetBlogArticle | null (with related) → article