Blog

The tenant's published articles as a paginated, crawlable grid (?page=N links).

Root + partsClient component@pandaidx/widgets 1.14.1

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 widget

The 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

  1. 1
    npm install <tarball or @pandaidx/widgets>
    Terminal
    npm install https://github.com/pandaidx/panda-widgets/releases/download/v1.14.1/pandaidx-widgets-1.14.1.tgz
  2. 2
    import "@pandaidx/widgets/styles.css" in app/layout.tsx
  3. 3
    Wrap the site in <PandaProvider publicKey apiUrl preset theme> (app/layout.tsx)
  4. 4
    In each page (Server Component) call the widget's server loader, then render the widget with its data
  5. 5
    Allow the images: next.config images.remotePatterns [{ protocol: "https", hostname: "**" }] (listing and blog photos come from several CDNs)

Usage

BlogList
// app/blog/page.tsx
import { 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 searchParams
const 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

BlogList
// app/stories/page.tsx
import { fetchBlogPosts } from "@pandaidx/widgets/server"
import { StoriesView } from "@/components/stories-view"
export default async function StoriesPage({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
const { page } = await searchParams
const 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

BlogPost
// app/story/[slug]/page.tsx
import { 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 params
const 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

PropTypeDefaultDescription
data*object—Page of articles.
basePathstring"/blog"Host blog path; articles open at <basePath>/<slug>. panda: the index path (default /stories, breadcrumb root and page links); articles use articleHref.
categorystring—Current category slug (kept in pagination links). panda: category page mode (breadcrumb, category title, no trending pills; pages at categoryHref).
emptyMessagestring"No articles published yet."Copy when there are no articles.
columnsobject3Grid 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.
showExcerptbooleantrueShows the excerpt on cards.
showAuthorbooleanfalseShows the author next to the date.
featuredFirstbooleanfalseFirst article of page 1 as a full-width featured card (panda: default true, the 2/3 featured story).
titlestring—panda: headline title. Default "Latest Stories" (the category name on a category page).
descriptionstring—panda: headline subtitle. Default "The most valuable stories you don't want to miss" (none on a category page).
showHeadlinebooleantruepanda: headline (and breadcrumb on a category page).
showCategoriesboolean—panda: "Trending Posts" category pills (desktop only, like websites). Default true on the index, false on a category page.
showCategoriesTitlebooleantruepanda: "Trending Posts" title above the pills.
articleHrefstring"/story/{slug}"panda: article route ({slug}).
categoryHrefstring"/stories/category/{slug}"panda: category route ({slug}).
authorHrefstring"/profile"panda: author (site owner) link of the featured story.
classNamesobject—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.
themeobject—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

PropTypeDefaultDescription
article*object—Article + next article (null → notFound()).
basePathstring"/blog"Host blog path (category and next-article links).
layout"centered" | "wide""centered"Reading column (830px) or container-wide article.
showSharebooleantrueShows the social share bar (panda: the Share button of the author row).
showNextbooleantrueShows the next-article box (not in panda).
showAuthorbooleantruepanda: author row (avatar, name, relative date, read time).
showCategoriesbooleantruepanda: category pills under the body.
showRelatedbooleantruepanda: "Related Stories" list (article.related, up to 5 sharing a category).
relatedTitlestring"Related Stories"panda: heading of the related list.
shareUrlstring—panda: link the Share dialog shares. Default: the current page URL.
maxWidthnumber768panda: reading column width (px).
titleTag"div" | "h1""div"panda: title element (websites renders a div).
articleHrefstring"/story/{slug}"panda: route of the related stories ({slug}).
categoryHrefstring"/stories/category/{slug}"panda: category route ({slug}).
authorHrefstring"/profile"panda: author (site owner) link.
classNamesobject—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.
themeobject—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 PandaProvider

useBlogList({ 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 BlogPost

useBlogArticle({ 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 loader

One page of published articles. Returns WidgetBlogPage → data

fetchBlogPost()widget loader

One article by slug; null when it does not exist. Returns WidgetBlogArticle | null (with related) → article