How to use the new Sanity features
A guide to the Page Builder — building marketing pages in Sanity without a developer.
The Studio groups blog-style content under Blog Post:
- Pages — live documents (blog posts, webinars, legal pages, …).
- Templates — reusable starters copied onto new pages.
The builder now has 34 section types to choose from — see docs/sections-reference.md for the full catalog with every field explained. This file is the workflow: how to create a page, publish it, and the rules that keep pages from breaking.
Content folder
Blog Post is the single source of truth for categorized site content — blog posts, webinars, case studies, thank-you pages, contact pages, gated content, legal pages, and use cases. Path-based marketing landings live under Landing Pages instead. There is no separate "Blogs" type anymore: the legacy blogPost document type is retired (hidden from the sidebar, blocked from Create in both workspaces). Every blog post that used to live there has been migrated into a page document with the same category and slug, so its live URL never changed.
Both workspaces (Development and Production) show the same shape — All Blog Content is a folder with Pages and Templates inside (mirroring Landing Pages):
- Level 1: Blog Post sidebar item.
- Level 2: Pages or Templates.
- Level 3: one row per category (Blog, Resources, Webinars, Events,
Case Studies, Press, Updates, Thank you, Contact Us, Gated Content, Legal, Use Case).
- Level 4: that category's documents.
A document is "categorized" when it has a Category set — that category picks the live URL and layout. Category is assigned when you create from a sidebar category row and is not shown in the form afterward (on both pages and templates).
Creating a new page: open the right category row → + → Create from template or Duplicate an existing page. A modal lets you search and pick a source; its fields and sections are copied in. Cancel closes the editor and discards the draft — you cannot leave a blank page behind. There is no "start from scratch" option for pages.
Creating a new template: open Blog Post → Templates → [category row] → + → e.g. Blog template (category pre-filled).
> Known Studio quirk: nesting a list inside another list (levels 1→2→3) can hit > an upstream Sanity bug (sanity-io/sanity#13703) > where a pane renders blank right after you create and close a document. It's > cosmetic — reload the Studio tab and the list reappears. Studio is on v4, which > includes the fix. See the comment above pageOrTemplateFolder in > studio/structure.ts.
Landing pages (no category, slug only) live under Landing Pages in the sidebar — separate from Blog Post. They publish to /{slug} via the page builder (multi-segment slugs like solutions/customs-ai are allowed).
Slug rules
Every page uses Slug — one field for all URLs:
| Page type | Slug example | Live URL |
|---|---|---|
| Landing page (Landing Pages in Studio) | solutions/customs-ai | /solutions/customs-ai |
| Categorized page (Blog Post → Pages) | my-post | /blog/my-post (category picks the folder) |
Slugs must be unique across every page. Studio blocks publish on a collision.
On any categorized page, the Sections stack is optional extra layout after the category template (article, webinar hero, case study, legal body, …). Every section type in the picker renders if you add it — not only Conversion Panel. It is not a replacement for Body / Lead paragraph.
| Category | Live URL |
|---|---|
| Blog, Resources, Press, Updates, Gated Content | /blog/[slug] |
| Webinars | /webinar/[slug] and …/watch |
| Events | /events/[slug] |
| Case Studies | /case-studies/[slug] |
| Thank you, Contact Us | /[slug] |
| Legal | /legal/[slug] |
| Use Case | /use-cases/[slug] |
| (none) | /{slug} |
A slug cannot match any other document's slug (any page, any category, including the ones migrated from blogPost). Studio blocks publish until you pick a unique slug.
Create page from template copies category and fields, not the slug or slug. Set a slug on the new draft, then Open preview (localhost:3000 unless SANITY_STUDIO_PREVIEW_ORIGIN is set) — it opens the real live URL, not a separate preview route.
Related-post pickers only see other Pages (or the legacy caseStudy type) — never the retired blogPost type.
Empty marketing URL / slug is a warning, not a publish blocker. A slug that is already used anywhere is a publish blocker.
Site chrome (blog listing, header, footer, banner, newsletter defaults)
These are not pages. They are singleton documents under Site chrome in Studio (Development and Production workspaces), same idea as Site Popup: edit once, then any page that uses the Sanity layout loads them.
| Document | What it is |
|---|---|
| Blog listing | The filter pills on /blog (All, Blog, Resources, Webinars, Events, Case Studies, Press). Drag to reorder. Turn off Show on site to hide a pill without deleting it. Each pill maps default page categories (which content types land there automatically). Add a Card Presentation section on pages for listing-card imagery. |
| Announcement banner | The top bar (tag, headline, ticker, link). Toggle Show on the site to hide it. |
| Header | Nav items, dropdowns, Login, header button. Leave Login's link empty to use the platform sign-in URL. Resources dropdown links (/blog?category=blog, etc.) are edited here — they do not auto-update when you change Blog listing. |
| Footer | Top CTA buttons, link columns, copyright, legal links. Hide the top CTA on takes paths (/login) or prefixes (/thank-you-*). |
| Newsletter defaults | No longer holds copy or a form itself. Instead it references two Newsletter Signup configs — Default homepage newsletter (band) and Default sidebar newsletter (sidebar) — used wherever a page doesn't pick its own. Show on the site hides the homepage band and the sidebar fallback everywhere at once; Show homepage band hides only the homepage band. |
Newsletter forms and copy now live under the separate Newsletter Signups list (next to HubSpot Forms), not on this document — see below.
The live site loads these on every page. Edit in Development, publish, and the marketing site updates (ISR, about 60 seconds). The Development dataset already has Banner / Header / Footer documents seeded with the current live copy. If a fetch fails, the same copy in code is used so the site is never missing a header.
How to test (Development)
- Studio at
http://localhost:3333/development(workspace **Tru marketing -
Development**). Do not use the Production workspace for this test.
- Site chrome → open Blog listing, Announcement banner, Header, Footer, Newsletter defaults.
Banner / Header / Footer should already be published. Change a nav label, publish, refresh http://localhost:3000. The newsletter band already shows an email field. Under Newsletter Signups, pick a saved HubSpot form or paste an ID on the config referenced by Newsletter defaults, publish, then the homepage band and article/webinar sidebars switch to that form.
- Blog Post → Templates → [any category row] → create a template (category is
pre-filled) → Create page from template → set a slug → publish → open the Live URL. That slug must be unique across every page or Studio blocks publish.
- There is no live
blogPostfallback to compare against —pageis the
only place blog/webinar/case-study/etc. content lives.
- Local
.env.localmust haveSANITY_DATASET=development(or
NEXT_PUBLIC_SANITY_DATASET=development) so the site reads the same dataset you are editing. Restart pnpm dev after changing env. Production Studio and the production dataset are unchanged.
Getting started
cd studio && pnpm devThe Studio opens at http://localhost:3333/development. The sidebar shows Blog Post, Landing Pages, HubSpot Forms, Newsletter Signups, Site chrome, and Site Popup. Open Blog Post → Pages or → Templates for categorized content; open Landing Pages for path-based marketing pages and their templates.
Part 1 — Build a categorized page
Blog posts, webinars, legal pages, and the other category layouts use Blog Post → Pages.
1. Create it
Blog Post → Pages → [category row] → + → Create from template or Duplicate an existing page. Pick a source in the modal; the draft opens with content already filled in. Set a unique slug, then edit title, sections, and SEO as needed.
2. Show it on /blog (optional)
Most categorized pages have Show on blog and Blog filter pill under Content. These control whether the page appears as a card on the main blog grid (/blog) and which filter tab it sits under.
| Setting | What it does |
|---|---|
| Show on blog (off by default on new pages) | When on, the page can appear on /blog. When off, it stays off the grid even if its category would normally list there. |
| Blog filter pill | Which pill tab lists this page (Blog, Resources, Webinars, Events, …). The picker only shows pills marked Show on site in Site chrome → Blog listing. |
Typical cases:
- Blog / Resources / Webinars / Events / Case Studies / Press pages usually
land on /blog automatically from their category. Turn Show on blog on if a new page is missing from the grid, or pick a different pill to override the default tab (e.g. a Press-category page listed under Blog).
- Legal, Use Case, Thank you, Contact Us pages are not blog listings — leave
Show on blog off.
Add a Card Presentation section if you want a custom card image or style on the blog grid. Publish Site chrome → Blog listing first if you need a new pill or tab order change site-wide.
3. Custom HTML (Layout group)
Use Custom HTML when you need a third-party widget or one-off snippet that other sections cannot build.
| Field | Tips |
|---|---|
| Variant (background) | Brand colours only — Navy, Cream, Sand, Moss, Rust, Muted panel, White, or page default. |
| HTML | Paste a fragment (<div>…</div>, <style>…</style>). Do not paste a full <!DOCTYPE html> document — it is injected inside the page. |
| JavaScript | Paste raw code only — no <script> tags. Example: document.getElementById("btn").addEventListener("click", …) |
Where it shows: category picks the URL — Webinars → /webinar/[slug], Blog → /blog/[slug], Events → /events/[slug]. If you expect /webinar/… but the page is category Blog, use /blog/… or recreate the page under Webinars.
Try the demo: from the repo root, cd studio && set -a && source ../.env.local && set +a && npx tsx migrations/seed-custom-html-webinar-demo.ts writes a working HTML/CSS/JS example onto slug cbp-hq-ruling-ai-2026 in the development dataset.
Part 1b — Build a landing page
Solution landings, campaign pages, and other path-based URLs use Landing Pages (not under Blog Post).
1. Create it
Landing Pages → Pages → + → Create from landing template or Duplicate an existing landing page. Pick a source in the modal, then set a Slug — e.g. solutions/customs-ai → tru.sh/solutions/customs-ai. Sections are the full page layout (Hero, Feature Grid, forms, etc.).
| Field | What it's for |
|---|---|
| Internal title | Only shown inside the Studio, to help you find the page later. Never appears on the site. |
| Slug | Where the page lives — no leading slash; nesting is allowed (up to 3 levels). |
| Sections | The page content (stacked below the category layout when both apply). |
2. Choose the slug
Type the slug without a leading slash. Nesting is allowed, up to 3 levels.
solutions/customs-ai → tru.sh/solutions/customs-ai
industries/retail/apparel → tru.sh/industries/retail/apparel
customs-ai → tru.sh/customs-aiRules: lowercase letters, numbers and hyphens only. No spaces, no underscores, no leading or trailing slash. Max 3 levels deep.
The Slug field blocks publish when the path is invalid (format) or already used by another document (any page, template, or legacy type with the same slug).
| If you type… | You'll see | Why |
|---|---|---|
pricing while another page already has slug pricing | *"Another page already uses slug…"* | Slugs must be unique across every page |
Foo | *"Use lowercase only — try foo"* | Path format rules |
Slug with leading/trailing / | *"Store the canonical form…"* | Paths are stored without slashes |
Redirect from (Advanced tab) still rejects paths that would never work as redirects (blog/case-study namespaces, redirect-only URLs, etc.).
Main nav URLs (/pricing, /company, /cbp-join-program, …) are edited under Site Pages → Pages so the live site matches what visitors see. A Landing Page can use the same slug string only when no other document already claims it — if both exist, uniqueness validation blocks publish.
Show on /blog (optional)
Landing pages are not on the blog grid by default. To feature one there:
- Turn on Show on blog (Content tab).
- Pick a Blog filter pill (same list as categorized pages — configured under Site chrome → Blog listing).
- Add an Excerpt and a Card Presentation section so the card has copy and an image.
- Set Published at so the card sorts with other posts.
The card links to the landing page URL (/{slug}), not /blog/….
3. Add sections
Under Sections, click Add item. The picker is grouped into six categories, so you don't have to scroll a flat list of 34. It also hides section types that don't match this page's category — e.g. Webinar Registration Hero only appears on Webinar pages, Article Header only on Blog/Resources/Press/Updates/Gated Content, Case Study Hero only on Case Studies. Card Presentation is also available on landing pages (for /blog listing cards when Show on blog is on):
| Group | What lives there |
|---|---|
| Hero | The page opener |
| Content | Body copy, feature grids, FAQs, steps, comparison tables, and more |
| Media | Images and video — bands, galleries, image+copy rows, feature rows with images |
| Social proof | Stats, testimonials, logo bars |
| Conversion | Forms, the booking calendar, newsletter signup, CTA bands |
| Layout | Decorative texture strips, the sticky two-column layout |
Full details on every section — what each field does, and when a section is skipped for missing content — are in docs/sections-reference.md.
Drag sections to reorder them. A typical page:
Hero → Stats → Feature Grid → Rich Text → FAQ → Conversion Panel4. Some sections are capped at one per page
A handful of sections can only appear once on a page — Hero, the three form sections, Newsletter, Countdown, the Booking Calendar, and the Sticky Two-Column layout. This isn't a bug: two Heroes means two H1s (bad for accessibility and SEO), and two lead-capture forms on one page split traffic and duplicate analytics events.
There's also a cross-type rule: a page can only have one "primary conversion" ask in total, even if it's a mix of different form sections. For example, a Gated Form + Hero *and* a HubSpot Form on the same page will be blocked, even though neither alone exceeds its own limit — they're competing for the same job.
If you genuinely need two conversion asks on one page (say, a long page with a mid-page form and a footer form), open the Advanced tab and turn on Allow multiple conversions. This does not relax the one-Hero rule — that one always stays a hard limit.
5. Fill in the SEO & Social tab
Everything here is optional and falls back sensibly.
| Field | If you leave it empty |
|---|---|
| Meta title | Uses the internal title |
| Meta description | Left blank |
| Social share image | No custom preview image |
| Canonical URL | Uses this page's own URL — only set it if the original lives elsewhere |
| Hide from search engines | Page is indexed normally |
| Exclude from sitemap | Page is listed in the sitemap |
Length warnings over 60 / 160 characters are advisory — they won't block publishing.
6. Publish
Hit Publish. The page is live within seconds; the site is notified automatically rather than waiting on a cache timer.
Part 1c — Build a site page
Main marketing routes like / (homepage), /platform, /solutions/government, and /pricing live under Site Pages (not Landing Pages). The homepage uses slug home in Studio; its live URL is / and preview is /preview/site/home. They are built from the same page-builder Sections as landing pages (Hero, Feature Grid, FAQ, …). Marketing edits copy in Studio — no code deploy for text changes.
Pricing uses the Pricing Plans section: tier names, prices, and credits load from the billing API at runtime; you edit feature bullets, Enterprise copy, and FAQs in Studio.
Site pages never appear on /blog (no Show on blog, no Card Presentation).
Create or edit
Site Pages → Pages → open a page → edit Sections (add, reorder, change headlines and body). Set Slug for the future live URL (e.g. platform → /platform).
New pages: Site Pages → Pages → + → Create from site template (or duplicate an existing site page).
While coded routes are still live, check your work at /preview/site/{slug} (e.g. /preview/site/platform). That URL only works when the page exists in Sanity.
Engineering seeds starter copy with npx tsx migrations/seed-site-pages.ts from studio/ — after that, marketing owns the sections in Studio.
Part 2 — Templates
Use a template when you'll build the same *shape* of page more than once.
Create a template
Blog Post → Templates → [category row] → + → [Category] template (e.g. Blog template). Fill in:
- Template name — e.g. "Solution landing page"
- When to use it — a sentence for whoever picks it next
- Owner — who maintains it, so people know who to ask before changing it
- Sections — the layout, with placeholder copy
Templates follow the same per-type caps and one-conversion rule as a normal page.
Use a template
Two ways:
- From Pages: Blog Post → Pages → [category] → + → Create from template — pick a
template (or page template document) in the modal. Category stays tied to the row you started from.
- From inside a template: open the template → ⋮ menu (next to Publish) →
Create page from template — creates a separate draft page with a copy of the template's fields and sections. Click Open the new page in the success dialog — the sidebar should highlight that template's category (e.g. Blog Post → Pages → Webinars for a webinar template), not Blog.
Replace placeholder copy, set a unique slug, publish.
Important: templates are copies, not links
Editing a template does not change pages already created from it.
template ──copy──▶ Page A (independent)
└─────copy──▶ Page B (independent)
edit the template → Pages A and B are unchangedThis is deliberate: one template edit can never accidentally alter live pages. The trade-off is that fixing a mistake in a template doesn't retroactively fix pages built from it — you'd update those individually.
Part 3 — Forms and the booking calendar
Three sections take a HubSpot form: Gated Form + Hero, HubSpot Form, and Resource Download. Newsletter Signup configs (under the Newsletter Signups list) use the same field. All of them offer the same choice:
- Pick a saved form — choose from forms your team has already registered, by name.
- Paste a form ID — grab the ID from HubSpot directly, the same way you already
do for blog sidebar forms.
Either way works. Picking a saved form is preferred when one exists, since it shows a name instead of a raw ID and can be retired centrally if the form is ever replaced. If the form you need isn't in the saved list yet, paste its ID — ask engineering to register it as a saved form later if you'll reuse it often.
The Booking Calendar section only offers a fixed list of approved meeting types (e.g. "Intro call", "Compliance discovery call") — you can't paste an arbitrary Cal.com link here. If the meeting type you need isn't listed, ask engineering to add it.
Things worth knowing
Some sections hide themselves if incomplete. Rather than render something broken, a section is skipped when required content is missing — e.g. a Media + Copy Row with no heading, or an Image Gallery with only one image. Newsletter Signup requires picking a config, but that config stays visible without a HubSpot form (email fallback). See docs/sections-reference.md for the exact requirement per section.
If a section doesn't appear on the published page, that's why. Nothing else on the page is affected.
Unsafe links are dropped. If a button's link doesn't point somewhere valid on this site or a normal https:// address, the button silently won't render. Check the link if a button goes missing.
Nothing is dropped silently *inside* a section. If you add 6 stats to a section built for 4, all 6 render (slightly tighter) — the system never shows a subset of what you entered.
Renaming a published page's URL. Add the old slug to Redirect from in the Advanced tab, or the old URL will 404 for anyone who bookmarked or linked it.
Troubleshooting
"I can't publish — the path field is red." Read the message under the field. On Slug, it is usually invalid format or another document already uses that slug (search Site Pages and Landing Pages). Redirect from may still mention blog/case-study namespaces or legacy redirect paths.
"My page is published but the URL 404s." Check the path has no leading slash and is exactly what you typed in the browser. Paths are case-sensitive and lowercase-only.
"A section I added isn't showing." See *Things worth knowing* above, and check the requirement for that section in docs/sections-reference.md — it's most often a missing required field or fewer items than the minimum.
"I can't publish — it says two sections are both trying to be the primary conversion" / "two sections are both a Hero." You have two sections competing for the same job on one page. For the Hero conflict, remove one — there's no override. For a conversion conflict (two forms, or a form and the Booking Calendar), either remove one or turn on Allow multiple conversions in the Advanced tab if the page genuinely needs both.
"Create page from template is greyed out, or shows an error." The template is either empty, or contains a section type that's been retired. The error names the section — remove it from the template first.
"I picked a template but got an error / nothing happened." The modal picker applies changes through the open editor. If the source was deleted, try again. Retired section types block stamping — fix the source template first.
"Cancel on the create modal left a blank page open." It should not — Cancel discards the draft and returns you to the list. Reload the Studio tab if the pane looks stuck.
"Open the new page opened the right document but Blog is highlighted in the sidebar." That was a Studio routing bug (fixed in studio/lib/categoryIntentChecker.ts). The page itself is fine — category on the document is correct. Reload Studio after a deploy; if it persists on a fresh build, report it to engineering.
"I changed a template but the pages built from it didn't change." Working as intended — templates are copied, not linked. See above.
For developers
| What | Where |
|---|---|
| Section definitions (variants, limits, defaults, groups, caps) | src/lib/sections/registry.ts |
| Composition / conflict rules | src/lib/sections/composition.ts |
| Section → component mapping | src/lib/sections/renderSection.tsx |
| URL path rules | src/lib/routing/path.ts |
| Slug uniqueness + format (Studio Slug field) | studio/lib/slugCollision.ts, src/lib/content/slugCollision.ts |
| Reserved paths (generated) — runtime catch-all + Redirect from validation, not Slug | src/lib/routing/reservedPaths.generated.ts, src/lib/routing/reserved.ts |
| Studio schemas | studio/schemaTypes/page.ts, pageTemplate.ts, sections/ |
| Template copy logic | src/lib/sections/stampTemplate.ts |
| Category-scoped section picker | src/lib/sections/sectionPicker.ts, pickerCategories in registry.ts |
| Page create modal / structure | studio/components/PageSourceDialog.tsx, studio/structure.ts |
| HubSpot form allowlist | src/lib/sections/hubspotForms.ts, studio/schemaTypes/hubspotForm.ts |
| Cal.com schedule registry (code-owned) | src/lib/cal/schedules.ts |
| Current authoring architecture and implementation status | GTM implementation tracker |
| Historical page-builder proposal | Original page-builder plan (superseded; context only) |
| Marketer-facing section catalog | docs/sections-reference.md |
Adding a section type: add an entry to registry.ts, then a Studio schema in studio/schemaTypes/sections/, a mapper in renderSection.tsx, and a GROQ projection in src/lib/sanity/pageQueries.ts. The last two are enforced at compile time — tsc fails until they exist, so the registry can't drift. Also add a card to src/app/global-components/page.tsx and a row to docs/sections-reference.md. Run pnpm --dir studio typegen after schema changes to regenerate the tracked studio/schema.json and studio/sanity.types.ts; include their changes in the same PR. This command generates local files without publishing content or schemas.
Reserved paths regenerate on pnpm dev / pnpm build. Add a new hand-built route and it becomes unavailable to editors automatically.
Retiring a section: set status: "deprecated" in the registry to hide it from the picker while existing pages keep rendering, then "retired" to stop rendering it. Retired types block template stamping.
Third-party IDs are never free text into executable context. A HubSpot form ID is data passed to an embed script, so it's allowlisted and re-checked at render time. A Cal.com link is interpolated into a <Script> body, so it is code-owned in src/lib/cal/schedules.ts — Sanity only ever stores a lookup key, never the link itself. Don't add a free-text Cal.com link field; add a new entry to the schedules registry instead.
Environment variables
SANITY_REVALIDATE_SECRET needs to be set in the hosting environment and in the Sanity webhook config for instant publishing to work. Without it, pages still go live but on the slower cache timer.
Known outstanding items
Follow the GTM implementation tracker. The committed public/robots.txt and public/sitemap.xml conflict with the generated routes; the static sitemap is currently served in local development and references a different domain. Confirm the public origin, select one served source, and make generated sitemap URLs absolute before switching sources. Next.js emits sitemap URLs as provided; metadataBase does not resolve relative sitemap entries.