How to Pass Data from Next.js Directly to Builder.io Components

Skip integrations and maintain full control over your data layer while giving content creators visual editing

·Matija Žiberna·
How to Pass Data from Next.js Directly to Builder.io Components

I was building a Next.js headless commerce site when I hit a frustrating limitation with Builder.io's data integrations. The built-in Shopify integration couldn't handle our complex metaobject structure, and the API integration felt like giving up control over our carefully crafted data layer. After diving deep into Builder.io's documentation and experimenting with different approaches, I discovered you can bypass all integrations entirely by passing data directly from your Next.js server components.

This guide shows you exactly how to maintain complete control over your data fetching, caching, and transformation while still giving content creators the visual editing experience they need.

The Problem with Built-in Integrations

When you rely on Builder.io's integrations, you're essentially handing over control of your data layer. You become dependent on their API formats, caching strategies, and update frequencies. For complex applications where data relationships matter or where you have specific performance requirements, this creates unnecessary constraints.

What I needed was a way to fetch data server-side in Next.js using our existing infrastructure, then make that data available in Builder.io's visual editor. The solution lies in Builder.io's data prop - a powerful but underutilized feature that lets you inject any data directly into the visual editor's Content State.

Understanding the Data Flow

The approach works by leveraging Next.js server components to fetch data, then passing it to Builder.io's Content component via the data prop. This makes your server data immediately available in Builder.io's visual editor under the "Content State" panel, where non-technical users can bind it to components without knowing anything about your backend implementation.

Here's how the flow works: your Next.js page fetches data server-side, transforms it into the shape you want, passes it to Builder.io's Content component, and that data becomes available in the visual editor as state.yourData.

Setting Up Server-Side Data Fetching

Let's start with a practical example using Shopify metaobjects, though this pattern works with any data source. First, we'll create a server component that fetches both Builder.io content and our custom data in parallel.

// File: app/events/page.tsx
import {
  Content,
  fetchOneEntry,
  getBuilderSearchParams,
  isPreviewing,
} from "@builder.io/sdk-react";
import { getMetaobjectsByType } from "lib/shopify";
import { Suspense } from "react";
import { Metadata } from "next";

interface PageProps {
  searchParams: Promise<Record<string, string>>;
}

const PUBLIC_API_KEY = process.env.NEXT_PUBLIC_BUILDER_IO_PUBLIC_KEY!;

export default async function EventsPage(props: PageProps) {
  const urlPath = "/events";
  const searchParams = await props.searchParams;

  try {
    // Fetch both Builder.io content and your data in parallel
    const [content, eventsMetaobjects] = await Promise.all([
      fetchOneEntry({
        options: getBuilderSearchParams(searchParams),
        apiKey: PUBLIC_API_KEY,
        model: "page",
        userAttributes: { urlPath },
      }),
      getMetaobjectsByType("events") // Your custom data fetching
    ]);

    const canShowContent = content || isPreviewing(searchParams);

    if (!canShowContent) {
      return (
        <div className="container mx-auto px-4 py-8 text-center">
          <h1 className="text-4xl font-bold mb-4">Events</h1>
          <p className="text-gray-600">
            This page is managed by Builder.io, but no content has been published yet.
          </p>
        </div>
      );
    }

    // Transform your data for Builder.io
    const events = eventsMetaobjects?.edges?.map(({ node }) => {
      const getFieldValue = (key: string) =>
        node.fields.find(field => field.key === key)?.value || '';

      return {
        id: node.id,
        handle: node.handle,
        name: getFieldValue('name'),
        startDate: getFieldValue('startdatum'),
        endDate: getFieldValue('enddatum'),
        address: getFieldValue('adresse'),
        description: getFieldValue('beschreibung'),
        image: getFieldValue('bild'),
        link: getFieldValue('eventlink'),
      };
    }) || [];

    // Prepare data for Builder.io
    const builderData = {
      events,
      eventsCount: events.length,
      hasEvents: events.length > 0,
      // Add helper data for content creators
      upcomingEvents: events.filter(event => {
        if (!event.startDate) return false;
        const startDate = new Date(event.startDate);
        return startDate >= new Date();
      }),
      pastEvents: events.filter(event => {
        if (!event.endDate && !event.startDate) return false;
        const endDate = new Date(event.endDate || event.startDate);
        return endDate < new Date();
      }),
    };

    return (
      <Content
        data={builderData}
        content={content}
        apiKey={PUBLIC_API_KEY}
        model="page"
      />
    );

  } catch (error) {
    console.error('Error loading events page:', error);

    return (
      <div className="container mx-auto px-4 py-8 text-center">
        <h1 className="text-4xl font-bold mb-4 text-red-600">Error Loading Events</h1>
        <p className="text-gray-600">
          Sorry, there was an error loading the events page.
        </p>
      </div>
    );
  }
}

This implementation fetches both Builder.io content and your custom data simultaneously using Promise.all, ensuring optimal performance. The key insight here is the builderData object - this becomes available in Builder.io's visual editor as state.events, state.eventsCount, and so on.

Transforming Data for Visual Editors

The data transformation step is crucial because you're preparing data specifically for non-technical users who will be working in a visual interface. Notice how I've included helper properties like upcomingEvents and pastEvents - these make it easier for content creators to work with filtered data without needing to understand complex logic.

The transformation also flattens complex nested structures into simple key-value pairs that are intuitive to work with in Builder.io's binding interface. Instead of requiring content creators to navigate deep object hierarchies, they can simply access item.name or item.startDate.

Making Data Available in the Visual Editor

Once you pass data through the data prop, it becomes immediately available in Builder.io's visual editor under the "Content State" panel in the Data tab. Content creators can now bind this data to any component without writing code or understanding your backend implementation.

Basic Data Binding

For displaying individual pieces of data, they can use expressions like:

  • {{state.eventsCount}} - Shows total number of events
  • {{state.hasEvents ? 'We have events!' : 'No events scheduled'}} - Conditional content
  • {{state.events.length}} - Dynamic count display

Working with Lists

For lists of data, content creators can:

  1. Select any container element (div, section, etc.)
  2. Go to the Data tab
  3. Set "Repeat on" to state.events
  4. Inside repeated elements, access individual properties:
    • {{item.name}} - Event name
    • {{item.startdatum}} - Formatted start date
    • {{item.adresse}} - Event address
    • {{item.beschreibung}} - Event description

Using Filtered Arrays

You can also use the pre-filtered arrays:

  • Repeat on state.upcomingEvents for future events only
  • Repeat on state.pastEvents for historical events
  • This eliminates the need for content creators to write complex filtering logic

For comprehensive data binding documentation, see Builder.io's official guide: Data Binding and State Management

This approach gives you the best of both worlds: developers maintain complete control over data fetching, caching, and transformation, while content creators get an intuitive visual interface for building pages with that data.

Simplifying Data for Content Creators

One crucial step in preparing data for Builder.io is transforming complex technical formats into user-friendly, display-ready values. Content creators shouldn't need to parse ISO dates or extract text from complex JSON structures - they should get clean, ready-to-use data.

The Challenge: Complex Data Structures

Raw data from APIs often comes in technical formats that are difficult for non-technical users to work with:

// Before: Complex, technical data
{
  startdatum: "2025-09-10T07:00:00Z",        // ISO date format
  enddatum: "2025-09-14T16:00:00Z",          // ISO date format
  beschreibung: `{                           // Nested JSON structure
    "type": "root",
    "children": [{
      "type": "paragraph", 
      "children": [{
        "type": "text",
        "value": "Halle 2, Stand: M 12"
      }]
    }]
  }`
}

The Solution: Data Transformation Utilities

Create utility functions that transform complex data into simple, display-ready formats:

// File: lib/utils.ts
export const formatDateToGerman = (isoDateString: string): string => {
  if (!isoDateString) return '';

  try {
    const date = new Date(isoDateString);
    if (isNaN(date.getTime())) return '';

    const options: Intl.DateTimeFormatOptions = {
      day: 'numeric',
      month: 'long', 
      year: 'numeric',
      hour: '2-digit',
      minute: '2-digit',
      timeZone: 'Europe/Berlin'
    };

    return date.toLocaleString('de-DE', options);
  } catch (error) {
    console.warn('Error formatting date:', error);
    return '';
  }
};

export const extractPlainTextFromRichText = (jsonString: string): string => {
  if (!jsonString || typeof jsonString !== 'string') return '';

  try {
    const parsed = JSON.parse(jsonString);

    const extractText = (node: any): string => {
      if (!node || typeof node !== 'object') return '';

      if (node.type === 'text' && node.value) {
        return node.value;
      }

      if (node.children && Array.isArray(node.children)) {
        return node.children
          .map((child: any) => extractText(child))
          .filter(Boolean)
          .join(' ');
      }

      return '';
    };

    return extractText(parsed).trim();
  } catch (error) {
    console.warn('Error parsing rich text JSON:', error);
    return jsonString; // Fallback to original string
  }
};

Applying Transformations

Use these utilities in your data transformation pipeline:

// Transform metaobjects for Builder.io with simplified data structure
const events = eventsMetaobjects?.edges?.map(({ node }) => {
  const getFieldValue = (key: string) =>
    node.fields.find(field => field.key === key)?.value || '';

  const rawStartDatum = getFieldValue('startdatum');
  const rawEndDatum = getFieldValue('enddatum');
  const rawBeschreibung = getFieldValue('beschreibung');

  return {
    id: node.id,
    handle: node.handle,
    name: getFieldValue('name'),
    startdatum: formatDateToGerman(rawStartDatum),         // ✨ Simplified
    enddatum: formatDateToGerman(rawEndDatum),             // ✨ Simplified  
    adresse: getFieldValue('adresse'),
    beschreibung: extractPlainTextFromRichText(rawBeschreibung), // ✨ Simplified
    bild: getFieldValue('bild'),
    eventlink: getFieldValue('eventlink'),
  };
}) || [];

The Result: Clean, Usable Data

Now content creators work with simplified, display-ready data:

// After: Clean, user-friendly data
{
  startdatum: "10. September 2025, 07:00",   // Human-readable German format
  enddatum: "14. September 2025, 16:00",     // Human-readable German format
  beschreibung: "Halle 2, Stand: M 12"       // Plain text, ready to display
}

This transformation eliminates confusion and makes data binding straightforward. Instead of complex expressions, content creators can simply use {{item.startdatum}} and get perfectly formatted output.

Adding Error Handling and Loading States

Production applications need robust error handling and loading states. The implementation above includes both error boundaries and loading states to ensure a smooth user experience even when things go wrong.

// File: app/events/page.tsx (additional loading component)
function EventsPageSkeleton() {
  return (
    <div className="container mx-auto px-4 py-8">
      <div className="animate-pulse">
        <div className="h-8 bg-gray-200 rounded w-1/4 mb-6"></div>
        <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
          {[...Array(6)].map((_, i) => (
            <div key={i} className="bg-gray-100 rounded-lg p-6">
              <div className="h-4 bg-gray-200 rounded mb-2"></div>
              <div className="h-4 bg-gray-200 rounded w-2/3 mb-4"></div>
              <div className="h-32 bg-gray-200 rounded"></div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

// Then wrap your Content component
<Suspense fallback={<EventsPageSkeleton />}>
  <Content
    data={builderData}
    content={content}
    apiKey={PUBLIC_API_KEY}
    model="page"
  />
</Suspense>

The error handling ensures that if your data fetching fails, users still see a meaningful message rather than a broken page. The loading states provide immediate feedback while server-side rendering completes, creating a professional user experience.

Extending to Any Data Source

This pattern works with any data source, not just Shopify. Whether you're fetching from a headless CMS, a REST API, a database, or even multiple sources, the approach remains the same: fetch server-side, transform for your needs, and pass through the data prop.

// File: app/blog/page.tsx (example with different data source)
const [content, blogPosts, authors] = await Promise.all([
  fetchOneEntry({...}),
  fetchBlogPosts(),
  fetchAuthors()
]);

const builderData = {
  posts: blogPosts.map(post => ({
    title: post.title,
    excerpt: post.excerpt,
    author: authors.find(a => a.id === post.authorId),
    publishedAt: post.publishedAt,
    category: post.category
  })),
  categories: [...new Set(blogPosts.map(p => p.category))],
  featuredPosts: blogPosts.filter(p => p.featured)
};

The key is thinking about how content creators will want to use the data and structuring it accordingly. Include computed properties, filtered arrays, and helper values that make their job easier.

By taking this approach, you've eliminated dependency on Builder.io's integrations while giving content creators even more power than they'd have with built-in integrations. You control the data pipeline completely - from fetching and caching strategies to the exact shape of data that reaches the visual editor.

This method has transformed how I build headless applications with Builder.io. Instead of fighting against integration limitations, I now design my data layer exactly how I want it, then make it available to content creators through a clean, intuitive interface. The result is faster, more maintainable applications where both developers and content creators can work effectively within their areas of expertise.

Let me know in the comments if you have questions about implementing this pattern with your specific data sources, and subscribe for more practical development guides.

Thanks, Matija

0

Comments

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in