Blog & Portfolio
Blog & portfolio
White Tree Digital has three editorial content types — post (blog), portfolioPost (page-builder project write-ups), and the legacy caseStudy — each with its own route family, its own detail component, and a shared, polymorphic card grid that powers both index pages.
The blog and portfolio surfaces are the one part of the site that breaks the dark-by-default theme: article bodies, index heads, grids, and "related" rails all stamp data-surface="light" so they read as a cream, legible reading island floating over the dark page. Everything else here — variants, fallback threading, the index singletons — follows the same Sanity-is-the-single-source-of-truth model the rest of the site uses, just applied to long-form content.
For the document models in the abstract see Content model; for the page-builder machinery the portfolio body rides on see Page builder overview and Section types. This page is about how those pieces assemble into blog posts, portfolio entries, and their indexes.
Three content types, two of them overlapping
| Type | Route | Body | Index | Fallback role |
|---|---|---|---|---|
post | /blog/[slug] | rich Portable Text (body) | /blog (auto-listed) | — |
portfolioPost | /portfolio/[slug] | page-builder sections[] | /portfolio (auto-listed) | — |
caseStudy | /work/[slug] (route + index not built) | fixed Portable Text (body) | none yet | feeds homepage workSection + service relatedCaseStudies |
post is the straightforward one: a blog article with an author byline, an excerpt, a cover image, tags, and a long-form body field. The other two — portfolioPost and caseStudy — both describe client work, and they coexist as two independent doc types. Editing one does not touch the other; they share no data.
That overlap is a deliberate, still-open decision, documented in TODO.md under "caseStudy vs portfolioPost":
caseStudyis the original, receipts-heavy model — a fixed schema with a Portable Textbody, atestimonialreference,outcomeStats, and acardColorway. It routes through/work/[slug]and is the auto-fallback list for the homepageworkSection(viarecentCaseStudiesProjection) and for service pages'relatedCaseStudies. No/workindex page exists — the schema and the/work/[slug]route do, but nothing lists case studies.portfolioPostis the newer, page-builder-driven model — its body is a fullsections[]array, it has a Layout variant, and it has a complete/portfolioindex that auto-lists published entries.
The migration decision is not made yet
TODO.md lists three open paths: keep both forever, deprecate caseStudy in favor of portfolioPost, or migrate caseStudy content into portfolioPost and remove the legacy type. Until that is resolved, treat caseStudy as load-bearing — the homepage work section and service-page fallbacks still query it. Don't delete it, and don't assume /work/* exists (it doesn't). If you migrate, the field-by-field plan is spelled out in TODO.md (e.g. the headlineStat string becomes a {value, label} object, body becomes a single richTextSection, testimonial becomes a testimonialsSection).
The index singletons
/blog and /portfolio are each driven by a singleton document — blogIndex and portfolioIndex — with identical shape. The singleton owns the page chrome (heading band + optional sections below); the post list itself is never stored on the singleton — it's fetched separately by the GROQ query.
Each singleton carries:
title— internal title, used in Studio listings and as the default<title>.eyebrow— small mono label above the heading.heading— the page H1. Authored with*asterisk*accent words (gold on dark, forest on cream); rendered viarenderAccentHeading(idx.heading)andset:html. This is distinct fromtitle.intro— one short paragraph below the heading.gridVariant—standard|featured|mosaic(covered below).sectionsBelow[]— an optional page-builder array rendered under the post grid, via the same<SectionRenderer />as the rest of the site (drop a CTA, FAQ, etc.).seo— the standard embedded SEO object.
The query for /blog fetches the singleton, the auto-listed posts, and the reference-fallback lists side by side:
"blogIndex": *[_type == "blogIndex" && _id == "blogIndex"][0]{
_type, title, eyebrow, heading, intro, gridVariant,
seo{metaTitle, metaDescription, ogImage},
"sectionsBelow": sectionsBelow[hideSection != true]{${sectionProjection}}
},
"posts": *[_type == "post" && !(_id in path("drafts.**"))]
| order(publishedAt desc, _createdAt desc){ … }
portfolioIndex is the same shape, listing portfolioPost docs instead of post. Both queries also fetch clients / testimonials / caseStudies so the sectionsBelow page-builder array gets the same reference fallbacks the homepage does. The index route assembles three pieces: a head <section>, a grid <section> (always data-section-anim — the grid reveal has no Sanity toggle), and sectionsBelow when present.
Never delete a Sanity singleton to reset it
blogIndex and portfolioIndex are singletons locked to an _id matching the type name. Running npx sanity documents delete blogIndex permanently tombstones the ID, and Studio's initialValue won't recover it — recovery then requires client.createOrReplace(...). To reset content, edit the fields in Studio, don't delete the document.
The /portfolio index ships with a forward-looking empty-state label — "Portfolio posts are coming — Phase 5 wires up the document type." — passed as emptyLabel. The document type now exists, so this label only appears when no portfolioPost is published; treat it as a TODO-flavored placeholder rather than current truth.
Detail variants: centered, sidebar, editorial
Both post and portfolioPost carry a variant string field — centered (default) | sidebar | editorial — that picks the detail-page layout. The component branches on it.
const cleanVariant = (stegaClean(post.variant) ?? 'centered') as PostVariant;
const isSidebar = cleanVariant === 'sidebar';
const isEditorial = cleanVariant === 'editorial';
const isCentered = !isSidebar && !isEditorial;
stegaClean(variant) is load-bearing
In draft preview the variant string carries invisible stega tag characters, so a bare variant === 'sidebar' silently evaluates false and the layout falls back to centered — only in preview, never in production, which makes the bug invisible. Every variant branch here cleans the value with stegaClean() from @sanity/client/stega first. Any new string field that drives JS logic must do the same.
The three treatments, as they render today:
- Centered (default) — a ~65ch prose column with the cover image full-width above it. Blog posts show the author byline in the header.
- Sidebar — a two-column
lg:grid-cols-[18rem_1fr]grid with alg:stickyleft rail. For blog (BlogPost.astro) the rail is the author byline plus a table of contents built from the body's H2s; for portfolio (PortfolioPost.astro) the rail is a meta<dl>(client / industry / services / headline stat). - Editorial — a full-bleed cover hero with a gradient scrim, an oversized title, and a narrow prose column. Blog editorial styles its first paragraph as a display-font lede (
.rich-body--editorial p:first-of-type).
For blog posts the TOC only exists in the sidebar variant — extractToc(post.body) runs only when isSidebar, and renderRichText(post.body, {h2Ids: isSidebar}) only stamps anchor IDs in that case, so the TOC links and the H2 IDs are computed together and stay in sync. See Components for the shared renderRichText / extractToc serializer and Typography for the prose styling.
Headline stat
portfolioPost (and caseStudy) carry a headlineStat — the big outcome number. On portfolioPost it's an object {value, label} (e.g. value: "+412%", label one short line). The detail component renders it large in the hero band (and the sidebar rail), gated on Boolean(stat?.value). The index card surfaces just headlineStat.value as a badge.
The shared grid: PostGrid
Both indexes render through one polymorphic component, PostGrid.astro. It takes a mode ('blog' | 'portfolio') that selects which list-item shape it's mapping, and a variant (the singleton's gridVariant) that selects the layout:
<PostGrid
mode="blog"
items={data.posts}
variant={idx.gridVariant}
basePath="/blog"
emptyLabel="No posts published yet — check back soon."
/>
The mode drives a cardProps() mapper that normalizes the two list shapes into one PostCard prop set — blog maps excerpt / tags[0] / author, portfolio maps summary / industry / headlineStat.value. Every card is wrapped in a plain <div data-reveal> so the scroll-in cascade can animate it without the reveal transition clobbering the card's own hover transitions (see Scroll-in reveals).
variant (stega-cleaned) picks the layout:
standard— a responsive card grid. Column count adapts to how many items exist (sm:grid-cols-2 lg:grid-cols-3at 3+,sm:grid-cols-2at 2, single column at 1).featured— the first item rendered as asize="hero"card, the rest in a 3-up grid below it.mosaic— an asymmetric 12-col-feel grid (lg:grid-cols-6) with a fixed rhythm per row.
The mosaic rhythm is hardcoded: for each five-item row, slots 0 and 3 are wide (lg:col-span-4, size="wide") and the rest are standard (lg:col-span-2).
const row = Math.floor(idx / 5);
const slot = idx % 5;
// Mosaic rhythm per 5-item row: [wide, std, std, wide, std]
const isWide = slot === 0 || slot === 3;
When the list is empty, PostGrid renders a single bordered empty-state box with the emptyLabel (or a generic fallback). PostCard itself — with size: standard | hero | wide — is shared across both grids and the "related" rails; see Components.
Portfolio bodies run through SectionRenderer
The defining feature of portfolioPost is that its body is a page builder. PortfolioPost.astro renders post.sections[] through the same <SectionRenderer /> the homepage uses, and it threads the reference fallbacks all the way through:
<SectionRenderer
sections={sections}
clients={clients}
testimonials={testimonials}
caseStudies={caseStudies}
/>
Those clients / testimonials / caseStudies props are the featured/recent fallback lists fetched alongside the post (getPortfolioPostBySlug projects featuredClientsProjection, featuredTestimonialsProjection, recentCaseStudiesProjection). So a logoWallSection, testimonialsSection, or workSection dropped inside a portfolio post falls back to featured/recent docs exactly as it would on the homepage when its own picks are empty — no special-casing. This same threading is wired into all three portfolio variants (centered, sidebar, editorial) and into the index singletons' sectionsBelow.
The sections[] array is coerced to [] defensively (Array.isArray(post.sections) ? post.sections : []) because Sanity returns null, not undefined, for a missing array field — a JS default parameter wouldn't catch it. The sidebar variant additionally shows an editor-facing "No sections added yet — drop some in Studio." box when the body is empty.
Blog posts do not use the page builder — post.body is plain rich Portable Text rendered by renderRichText. Only portfolioPost is page-builder-driven on the detail page.
Index head: heading vs title
A subtlety worth flagging: the index <h1> renders the singleton's heading field (the accent-bearing display string), not its title:
<h1
class="font-display font-extrabold text-h1 text-ink max-w-[20ch]"
set:html={renderAccentHeading(idx.heading)}
/>
title is the internal/Studio listing title and the default <title> fallback; heading is the on-page H1 with *asterisk* accents. Don't conflate them. eyebrow and intro render plainly above and below it. Because heading is set:html'd through renderAccentHeading, accent words must be authored as *the workshop*, not hand-typed HTML spans (see Brand tokens & theme for how the accent color is surface-derived).
Resilience: 503, not 404
Every blog/portfolio route wraps its fetch in a try/catch and returns 503 with Retry-After: 60 on an upstream Sanity outage, distinct from the 404 returned when the document genuinely doesn't exist:
try {
data = await getBlogIndex(await getRouteClient(Astro.cookies));
} catch (err) {
// Upstream outage, not a missing page — don't serve 404 to crawlers.
return new Response(null, {status: 503, headers: {'Retry-After': '60'}});
}
if (!data?.blogIndex) {
return new Response(null, {status: 404});
}
This matters for SEO: a transient outage that 404'd would tell crawlers the page is gone. The 503 says "try again." The detail routes (/blog/[slug], /portfolio/[slug]) follow the same pattern and also export getStaticPaths() — which enumerates published slugs for the static public build and is harmlessly ignored in the SSR staging build (see Two-build split). Both routes pick their Sanity client via getRouteClient(Astro.cookies), so draft preview and published builds use the right client automatically (see Visual editing).
Where this lives
Routes
website/src/pages/blog/index.astro—/blog, theblogIndexsingleton + auto-listed posts +sectionsBelow.website/src/pages/blog/[slug].astro—/blog/<slug>post detail +RelatedPosts.website/src/pages/portfolio/index.astro—/portfolio, theportfolioIndexsingleton + auto-listed portfolio posts.website/src/pages/portfolio/[slug].astro—/portfolio/<slug>detail, threading the reference fallbacks.
Components
website/src/components/blog/BlogPost.astro— three variants; sidebar TOC + author rail.website/src/components/portfolio/PortfolioPost.astro— three variants; body viaSectionRenderer, "More work" rail.website/src/components/PostGrid.astro— the polymorphicmode/variantgrid.website/src/components/cards/PostCard.astro— shared card (standard/hero/wide).
Queries
website/src/sanity/queries/blogIndex.ts/portfolioIndex.ts— singleton + auto-listed docs + fallbacks.website/src/sanity/queries/post.ts/portfolioPost.ts— single-doc fetch + slug enumeration.
Schemas (Studio)
studio/schemaTypes/post.ts,portfolioPost.ts,caseStudy.ts— the three content types.studio/schemaTypes/blogIndex.ts,portfolioIndex.ts— the index singletons.
Decision log
TODO.md— "caseStudy vs portfolioPost": the unresolved coexistence/migration decision and the field-by-field migration plan.