Building a Zero-JS PageBuilder Engine with Astro and Sanity

Share:
astro-sanity-pagebuilder-engine

In Part 1 - Why I Rebuilt Cohesion Studio in Astro and Sanity, I broke down why I migrated Cohesion Studio from WordPress to Astro and Sanity, jumping from an 83 to a 99 Google PageSpeed Insights score. Performance gains are not just about switching frameworks. They come down to how you structure your component architecture.

One of the main challenges with modern content management is balancing editor flexibility with code performance.

In traditional WordPress or React builds, modular page builders come with a heavy tax: massive theme runtimes, heavy DOM trees, and client-side page loading that slows execution.

Here is how I built a dynamic PageBuilder engine using Astro and Sanity that delivers total editorial freedom while shipping pure static HTML to the browser.

1. Server-side block mapping architecture

In Sanity CMS, pages are built using an array of flexible content blocks such as pageHero, richTextSection, workGrid, and revealHeadline.

Instead of rendering these blocks in the browser using a client framework runtime, Astro evaluates the entire block array at build time on the server.

I created a single PageBuilder.astro component that acts as the routing engine. It receives the raw block array from Sanity and maps each block type directly to its corresponding Astro component:

Typescript
---
// src/components/PageBuilder.astro
import PageHero from './PageHero.astro';
import RichTextSection from './RichTextSection.astro';
import WorkGrid from './WorkGrid.astro';
import RevealHeadline from './RevealHeadline.astro';

interface Block {
  _type: string;
  _key: string;
  [key: string]: any;
}

interface Props {
  blocks?: Block[];
}

const { blocks = [] } = Astro.props;

// Explicit mapping between Sanity schema types and Astro components
const componentMap: Record<string, any> = {
  pageHero: PageHero,
  richTextSection: RichTextSection,
  workGrid: WorkGrid,
  revealHeadline: RevealHeadline,
};
---

<div class="cs-page-builder">
  {blocks.map((block) => {
    const Component = componentMap[block._type];
    return Component ? <Component {...block} key={block._key} /> : null;
  })}
</div>

Why this works:

Because Astro evaluates componentMap entirely on the server, the client receives zero framework overhead. Whether a page has two blocks or twenty, the browser downloads 0kB of client-side PageBuilder JavaScript, keeping Total Blocking Time locked at 0ms.

2. Defensive CMS schema design

A PageBuilder engine is only as good as the guardrails inside your CMS. If editors can accidentally insert unpredictable HTML, your carefully crafted typography hierarchy breaks.

For example, standard rich-text editors often auto-convert typed numbers into HTML ordered lists. On a studio site with strict typography rules, that automatic formatting breaks custom line spacing.

Inside richTextSection.ts, I constrained Sanity's Portable Text schema to explicitly disable automatic numbered lists while retaining standard bullet lists:

Typescript
// src/schemas/richTextSection.ts
import {defineField, defineType} from 'sanity'

export const richTextSection = defineType({
  name: 'richTextSection',
  title: 'Rich Text / WYSIWYG',
  type: 'object',
  fields: [
    defineField({
      name: 'content',
      title: 'Content',
      type: 'array',
      of: [
        {
          type: 'block',
          styles: [
            {title: 'Normal', value: 'normal'},
            {title: 'Heading 2', value: 'h2'},
            {title: 'Heading 3', value: 'h3'},
            {title: 'Quote', value: 'blockquote'},
          ],
          // Explicitly restrict available list types to prevent auto-numbering glitches
          lists: [
            {title: 'Bullet', value: 'bullet'},
          ],
          marks: {
            decorators: [
              {title: 'Strong', value: 'strong'},
              {title: 'Emphasis', value: 'em'},
            ],
            annotations: [/* Custom URL Link Schema */],
          },
        },
      ],
    }),
  ],
})

By enforcing these constraints at the schema level, I can publish content with full autonomy without worrying about design drift or broken markup.

3. Attaching scroll motion to server-rendered HTML

Because the PageBuilder outputs clean HTML, animating these components with GSAP is straightforward.

Instead of attaching separate scroll listeners to every block, which causes scroll stutter on mobile devices, I use GSAP's ScrollTrigger.batch() to observe all text reveals across the page in a single calculation pass:

Typescript
// src/scripts/revealAnimation.ts
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

export function initUniversalTextReveals() {
  const revealTargets =
    ".reveal-up:not(.reveal-headline-row .reveal-up)";
  
  const targets = document.querySelectorAll<HTMLElement>(revealTargets);
  if (targets.length === 0) return;

  // Single-pass intersection batching for all server-rendered text blocks
  ScrollTrigger.batch(revealTargets, {
    start: "top 88%",
    once: true,
    onEnter: (batch) =>
      gsap.to(batch, {
        autoAlpha: 1,
        y: 0,
        duration: 0.9,
        ease: "power2.out",
        stagger: 0.15,
        overwrite: "auto",
      }),
  });
}

By decoupling server-side content generation from client-side motion, the site achieves fluid 60 FPS motion on top of static HTML.

What comes next

In Part 3 - The 99/100 Payoff: Bypassing Plugin Overhead for Native Edge Speed, I break down the media transformation pipeline, dynamic LCP preloading, font subsetting, and how eliminating forced reflows helped achieve a near-perfect PageSpeed score.

If you are planning a technical web redesign or evaluating platform architecture options, explore Cohesion Studio's web sprint options to see how we approach custom development.