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

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:
- Select any container element (div, section, etc.)
- Go to the Data tab
- Set "Repeat on" to
state.events
- 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