In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
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.
typescript
// File: app/events/page.tsximport {
Content,
fetchOneEntry,
getBuilderSearchParams,
isPreviewing,
} from"@builder.io/sdk-react";
import { getMetaobjectsByType } from"lib/shopify";
import { Suspense } from"react";
import { Metadata } from"next";
interfacePageProps {
searchParams: Promise<Record<string, string>>;
}
constPUBLIC_API_KEY = process.env.NEXT_PUBLIC_BUILDER_IO_PUBLIC_KEY!;
exportdefaultasyncfunctionEventsPage(props: PageProps) {
const urlPath = "/events";
const searchParams = await props.searchParams;
try {
// Fetch both Builder.io content and your data in parallelconst [content, eventsMetaobjects] = awaitPromise.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 (
<divclassName="container mx-auto px-4 py-8 text-center"><h1className="text-4xl font-bold mb-4">Events</h1><pclassName="text-gray-600">
This page is managed by Builder.io, but no content has been published yet.
</p></div>
);
}
// Transform your data for Builder.ioconst events = eventsMetaobjects?.edges?.map(({ node }) => {
constgetFieldValue = (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.ioconst builderData = {
events,
eventsCount: events.length,
hasEvents: events.length > 0,
// Add helper data for content creatorsupcomingEvents: events.filter(event => {
if (!event.startDate) returnfalse;
const startDate = newDate(event.startDate);
return startDate >= newDate();
}),
pastEvents: events.filter(event => {
if (!event.endDate && !event.startDate) returnfalse;
const endDate = newDate(event.endDate || event.startDate);
return endDate < newDate();
}),
};
return (
<Contentdata={builderData}content={content}apiKey={PUBLIC_API_KEY}model="page"
/>
);
} catch (error) {
console.error('Error loading events page:', error);
return (
<divclassName="container mx-auto px-4 py-8 text-center"><h1className="text-4xl font-bold mb-4 text-red-600">Error Loading Events</h1><pclassName="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
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:
typescript
// Before: Complex, technical data
{
startdatum: "2025-09-10T07:00:00Z", // ISO date formatenddatum: "2025-09-14T16:00:00Z", // ISO date formatbeschreibung: `{ // 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:
Now content creators work with simplified, display-ready data:
typescript
// After: Clean, user-friendly data
{
startdatum: "10. September 2025, 07:00", // Human-readable German formatenddatum: "14. September 2025, 16:00", // Human-readable German formatbeschreibung: "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.
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.
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.