TL;DR
Builder.io is the visual headless CMS — drag-and-drop page builder, React/Next.js SDK, A/B testing, targeting, structured data, works with any framework. Plasmic is the visual builder for React — drag-and-drop component builder, generates clean React code, Figma import, slots/variants, integrates with existing codebases. Makeswift is the visual editor for Next.js — inline page editing, component registration, live preview, built specifically for Next.js sites. In 2026: Builder.io for visual CMS with A/B testing, Plasmic for code-generating visual design, Makeswift for Next.js inline editing.
Key Takeaways
- Builder.io: @builder.io/sdk-react ~40K weekly downloads — visual CMS, A/B testing
- Plasmic: @plasmicapp/loader-react ~10K weekly downloads — visual builder, code gen
- Makeswift: @makeswift/runtime ~5K weekly downloads — Next.js visual editor
- Builder.io has the most CMS features (targeting, scheduling, analytics)
- Plasmic generates the cleanest React component code
- Makeswift provides the tightest Next.js integration with inline editing
Builder.io
Builder.io — visual headless CMS:
Setup with Next.js
npm install @builder.io/sdk-react
// app/[[...page]]/page.tsx — catch-all route for Builder pages
import { builder } from "@builder.io/sdk"
import { RenderBuilderContent } from "@/components/builder"
builder.init(process.env.NEXT_PUBLIC_BUILDER_API_KEY!)
export default async function Page({ params }: { params: { page?: string[] } }) {
const urlPath = "/" + (params.page?.join("/") || "")
const content = await builder
.get("page", {
userAttributes: { urlPath },
prerender: false,
})
.toPromise()
if (!content) {
return notFound()
}
return <RenderBuilderContent content={content} model="page" />
}
// Generate static paths:
export async function generateStaticParams() {
const pages = await builder.getAll("page", {
fields: "data.url",
options: { noTargeting: true },
})
return pages
.map((page) => ({
page: page.data?.url?.split("/").filter(Boolean) || [],
}))
.filter((p) => p.page.length > 0)
}
Custom components
// components/builder.tsx
"use client"
import { BuilderComponent, useIsPreviewing } from "@builder.io/react"
import { builder, Builder } from "@builder.io/sdk"
builder.init(process.env.NEXT_PUBLIC_BUILDER_API_KEY!)
// Register custom components for the visual editor:
Builder.registerComponent(
({ name, downloads, version, tags }) => (
<div className="rounded-xl border p-6 shadow-sm">
<h3 className="text-xl font-bold">{name}</h3>
<p className="text-3xl font-black text-blue-500 mt-2">
{downloads?.toLocaleString()} <span className="text-sm text-gray-400">/week</span>
</p>
<div className="mt-3 flex gap-2">
<span className="rounded bg-green-100 px-2 py-1 text-xs text-green-700">
v{version}
</span>
{tags?.map((tag: string) => (
<span key={tag} className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600">
{tag}
</span>
))}
</div>
</div>
),
{
name: "PackageCard",
inputs: [
{ name: "name", type: "string", defaultValue: "react" },
{ name: "downloads", type: "number", defaultValue: 25000000 },
{ name: "version", type: "string", defaultValue: "19.0.0" },
{ name: "tags", type: "list", subFields: [{ name: "tag", type: "string" }] },
],
}
)
Builder.registerComponent(
({ packages, title }) => (
<section className="py-12">
<h2 className="text-3xl font-bold text-center mb-8">{title}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-6xl mx-auto">
{packages?.map((pkg: any) => (
<div key={pkg.name} className="rounded-xl border p-6">
<h3 className="font-bold text-lg">{pkg.name}</h3>
<p className="text-2xl font-black text-blue-500">
{pkg.downloads?.toLocaleString()}
</p>
</div>
))}
</div>
</section>
),
{
name: "PackageGrid",
inputs: [
{ name: "title", type: "string", defaultValue: "Popular Packages" },
{
name: "packages",
type: "list",
subFields: [
{ name: "name", type: "string" },
{ name: "downloads", type: "number" },
],
},
],
}
)
export function RenderBuilderContent({ content, model }: any) {
const isPreviewing = useIsPreviewing()
if (!content && !isPreviewing) return null
return <BuilderComponent content={content} model={model} />
}
Structured data and targeting
// Fetch structured data (not pages):
import { builder } from "@builder.io/sdk"
builder.init(process.env.NEXT_PUBLIC_BUILDER_API_KEY!)
// Structured data model:
const featuredPackages = await builder.getAll("featured-packages", {
limit: 10,
options: { noTargeting: true },
})
// With targeting:
const heroContent = await builder
.get("hero-section", {
userAttributes: {
urlPath: "/",
device: "mobile",
country: "US",
},
})
.toPromise()
// A/B testing — Builder.io handles variants automatically:
// Create variants in the visual editor
// SDK automatically serves the right variant
// Track conversions:
builder.trackConversion(revenue)
// Content scheduling:
// Set publish/unpublish dates in the visual editor
// Content auto-publishes and auto-unpublishes
// Webhooks:
// Builder.io sends webhooks on content publish
// POST /api/webhooks/builder
export async function POST(req: Request) {
const body = await req.json()
if (body.operation === "publish") {
// Revalidate the page:
await revalidatePath(body.data.url)
}
return Response.json({ success: true })
}
Plasmic
Plasmic — visual builder for React:
Setup with Next.js
npm install @plasmicapp/loader-nextjs
// plasmic-init.ts
import { initPlasmicLoader } from "@plasmicapp/loader-nextjs"
export const PLASMIC = initPlasmicLoader({
projects: [
{
id: process.env.PLASMIC_PROJECT_ID!,
token: process.env.PLASMIC_API_TOKEN!,
},
],
preview: process.env.NODE_ENV === "development",
})
// app/[[...catchall]]/page.tsx
import { PlasmicComponent, ComponentRenderData, extractPlasmicQueryData } from "@plasmicapp/loader-nextjs"
import { PLASMIC } from "@/plasmic-init"
export default async function Page({ params }: { params: { catchall?: string[] } }) {
const path = "/" + (params.catchall?.join("/") || "")
const plasmicData = await PLASMIC.maybeFetchComponentData(path)
if (!plasmicData) return notFound()
const queryData = await extractPlasmicQueryData(
<PlasmicComponent component={plasmicData.entryCompMetas[0].displayName} />
)
return (
<PlasmicComponent
component={plasmicData.entryCompMetas[0].displayName}
prefetchedData={plasmicData}
prefetchedQueryData={queryData}
/>
)
}
export async function generateStaticParams() {
const pages = await PLASMIC.fetchPages()
return pages.map((page) => ({
catchall: page.path.split("/").filter(Boolean),
}))
}
Code components (register React components)
// plasmic-init.ts — register code components
import { PLASMIC } from "./plasmic-init"
// Register a code component that Plasmic users can drag in:
PLASMIC.registerComponent(
({ name, downloads, version, tags, className }) => (
<div className={`rounded-xl border p-6 shadow-sm ${className}`}>
<h3 className="text-xl font-bold">{name}</h3>
<p className="text-3xl font-black text-blue-500 mt-2">
{downloads?.toLocaleString()}
<span className="text-sm text-gray-400 ml-1">/week</span>
</p>
<div className="mt-3 flex gap-2 flex-wrap">
<span className="rounded bg-green-100 px-2 py-1 text-xs text-green-700">
v{version}
</span>
{tags?.map((tag: string) => (
<span key={tag} className="rounded bg-gray-100 px-2 py-1 text-xs">
{tag}
</span>
))}
</div>
</div>
),
{
name: "PackageCard",
props: {
name: { type: "string", defaultValue: "react" },
downloads: { type: "number", defaultValue: 25000000 },
version: { type: "string", defaultValue: "19.0.0" },
tags: { type: "array", itemType: { type: "string" } },
},
}
)
// Component with slots (children):
PLASMIC.registerComponent(
({ title, children, columns, className }) => (
<section className={`py-12 ${className}`}>
<h2 className="text-3xl font-bold text-center mb-8">{title}</h2>
<div className={`grid grid-cols-1 md:grid-cols-${columns} gap-6 max-w-6xl mx-auto`}>
{children}
</div>
</section>
),
{
name: "PackageGrid",
props: {
title: { type: "string", defaultValue: "Packages" },
columns: { type: "choice", options: ["2", "3", "4"], defaultValue: "3" },
children: { type: "slot" },
},
}
)
// Data-fetching component:
PLASMIC.registerComponent(
({ children, endpoint }) => {
const [data, setData] = useState(null)
useEffect(() => {
fetch(endpoint).then((r) => r.json()).then(setData)
}, [endpoint])
if (!data) return <div>Loading...</div>
return <DataProvider name="apiData" data={data}>{children}</DataProvider>
},
{
name: "DataFetcher",
props: {
endpoint: { type: "string" },
children: { type: "slot" },
},
providesData: true,
}
)
Figma integration
// Plasmic Figma import workflow:
// 1. Design in Figma
// 2. Use Plasmic Figma plugin to import
// 3. Components become Plasmic components
// 4. Bind data, add interactions in Plasmic
// 5. Export as clean React code or use loader
// Generated component (when using codegen mode):
// components/plasmic/PackageCard.tsx — auto-generated
import * as React from "react"
import { PlasmicPackageCard } from "./plasmic/PlasmicPackageCard"
interface PackageCardProps {
name?: string
downloads?: number
version?: string
className?: string
}
function PackageCard(props: PackageCardProps) {
return <PlasmicPackageCard {...props} />
}
export default PackageCard
Makeswift
Makeswift — visual editor for Next.js:
Setup
npm install @makeswift/runtime
// makeswift/client.ts
import { Makeswift } from "@makeswift/runtime/next"
export const client = new Makeswift(process.env.MAKESWIFT_SITE_API_KEY!)
// app/[[...path]]/page.tsx
import { client } from "@/makeswift/client"
import { MakeswiftPage } from "@makeswift/runtime/next"
export default async function Page({ params }: { params: { path?: string[] } }) {
const path = "/" + (params.path?.join("/") || "")
const snapshot = await client.getPageSnapshot(path)
if (!snapshot) return notFound()
return <MakeswiftPage snapshot={snapshot} />
}
export async function generateStaticParams() {
const pages = await client.getPages()
return pages.map((page) => ({
path: page.path.split("/").filter(Boolean),
}))
}
Register components
// makeswift/components.tsx
import {
Style,
TextInput,
Number,
List,
Shape,
Select,
Slot,
} from "@makeswift/runtime/controls"
import { ReactRuntime } from "@makeswift/runtime/react"
// Register PackageCard:
function PackageCard({
name,
downloads,
version,
tags,
className,
}: {
name: string
downloads: number
version: string
tags: Array<{ tag: string }>
className?: string
}) {
return (
<div className={`rounded-xl border p-6 shadow-sm ${className}`}>
<h3 className="text-xl font-bold">{name}</h3>
<p className="text-3xl font-black text-blue-500 mt-2">
{downloads.toLocaleString()}
<span className="text-sm text-gray-400 ml-1">/week</span>
</p>
<div className="mt-3 flex gap-2 flex-wrap">
<span className="rounded bg-green-100 px-2 py-1 text-xs text-green-700">
v{version}
</span>
{tags?.map((t, i) => (
<span key={i} className="rounded bg-gray-100 px-2 py-1 text-xs">
{t.tag}
</span>
))}
</div>
</div>
)
}
ReactRuntime.registerComponent(PackageCard, {
type: "package-card",
label: "Package Card",
props: {
className: Style(),
name: TextInput({ label: "Package name", defaultValue: "react" }),
downloads: Number({ label: "Downloads", defaultValue: 25000000 }),
version: TextInput({ label: "Version", defaultValue: "19.0.0" }),
tags: List({
label: "Tags",
type: Shape({
type: { tag: TextInput({ label: "Tag" }) },
}),
getItemLabel: (item) => item?.tag || "Tag",
}),
},
})
// Register grid layout:
function PackageGrid({
title,
columns,
children,
className,
}: {
title: string
columns: string
children: React.ReactNode
className?: string
}) {
return (
<section className={`py-12 ${className}`}>
<h2 className="text-3xl font-bold text-center mb-8">{title}</h2>
<div className={`grid grid-cols-1 md:grid-cols-${columns} gap-6 max-w-6xl mx-auto px-4`}>
{children}
</div>
</section>
)
}
ReactRuntime.registerComponent(PackageGrid, {
type: "package-grid",
label: "Package Grid",
props: {
className: Style(),
title: TextInput({ label: "Title", defaultValue: "Popular Packages" }),
columns: Select({
label: "Columns",
options: [
{ label: "2 Columns", value: "2" },
{ label: "3 Columns", value: "3" },
{ label: "4 Columns", value: "4" },
],
defaultValue: "3",
}),
children: Slot(),
},
})
Live editing and data
// Dynamic data in Makeswift components:
import { ReactRuntime } from "@makeswift/runtime/react"
import { TextInput, Style } from "@makeswift/runtime/controls"
function LivePackageStats({ packageName, className }: { packageName: string; className?: string }) {
const [stats, setStats] = useState<any>(null)
useEffect(() => {
fetch(`/api/packages/${packageName}`)
.then((r) => r.json())
.then(setStats)
}, [packageName])
if (!stats) return <div className={className}>Loading...</div>
return (
<div className={`rounded-xl bg-slate-900 p-6 text-white ${className}`}>
<h3 className="text-2xl font-bold">{stats.name}</h3>
<p className="text-gray-400 mt-1">{stats.description}</p>
<div className="mt-4 grid grid-cols-3 gap-4">
<div>
<div className="text-sm text-gray-500">Downloads</div>
<div className="text-xl font-bold text-blue-400">
{stats.downloads.toLocaleString()}
</div>
</div>
<div>
<div className="text-sm text-gray-500">Version</div>
<div className="text-xl font-bold text-green-400">v{stats.version}</div>
</div>
<div>
<div className="text-sm text-gray-500">License</div>
<div className="text-xl font-bold">{stats.license}</div>
</div>
</div>
</div>
)
}
ReactRuntime.registerComponent(LivePackageStats, {
type: "live-package-stats",
label: "Live Package Stats",
props: {
className: Style(),
packageName: TextInput({ label: "Package name", defaultValue: "react" }),
},
})
Feature Comparison
| Feature | Builder.io | Plasmic | Makeswift |
|---|---|---|---|
| Type | Visual CMS | Visual builder | Visual editor |
| Framework support | React, Vue, Svelte, etc. | React | Next.js only |
| Drag-and-drop | ✅ | ✅ | ✅ (inline) |
| Code generation | ❌ (SDK rendering) | ✅ (codegen mode) | ❌ (SDK rendering) |
| Custom components | ✅ | ✅ (code components) | ✅ |
| Figma import | ✅ | ✅ (native) | ❌ |
| A/B testing | ✅ (built-in) | ❌ | ❌ |
| Targeting/personalization | ✅ | ❌ | ❌ |
| Structured data | ✅ | ✅ | ❌ |
| Scheduling | ✅ | ❌ | ❌ |
| Localization | ✅ | ✅ | ❌ |
| Slots/children | ✅ | ✅ | ✅ |
| Data fetching | Via integrations | ✅ (data providers) | Via components |
| Self-hosted | ❌ | ❌ | ❌ |
| Free tier | ✅ (generous) | ✅ | ✅ |
| Open source | SDK only | SDK only | SDK only |
Production Performance and Core Web Vitals
Visual page builders introduce performance risks that require proactive management. Builder.io's SDK renders content client-side using the BuilderComponent React component, which hydrates after the HTML is received — if the Builder API is slow (>200ms), it becomes the critical path for Largest Contentful Paint. Use Next.js App Router's server-side data fetching with builder.get() in an async server component to ensure Builder content is rendered server-side in the initial HTML, then set aggressive CDN cache headers on the response. Plasmic's PlasmicComponent can be used in server components with the Next.js App Router, but the extractPlasmicQueryData function requires careful placement to avoid double-fetching. Makeswift's MakeswiftPage renders server-side by default, making it the easiest to integrate with Next.js App Router's performance model.
TypeScript Integration and Component Registration
The component registration API is where TypeScript integration matters most for visual page builders. Builder.io's Builder.registerComponent accepts a React component plus an inputs array — the inputs array is a runtime specification rather than TypeScript types, meaning typos in input names are not caught at compile time. Plasmic's PLASMIC.registerComponent function is similar, though the props object's keys can be constrained by TypeScript when the component's prop types are explicit. Makeswift's control system uses TypeScript generics that tie the control definitions to the component's prop types — TextInput() returns a control typed to string, and the combined props object type must match the registered component's props interface, providing compile-time validation that the control configuration is consistent with the component's TypeScript API.
Content Modeling and Structured Data
Beyond visual page building, all three platforms support structured content models that decouple content from layout. Builder.io's model system allows creating "data" models (not pages) that hold structured content — a featured-packages model stores content that different pages query and render in different layouts. This hybrid approach supports both visual page building and headless CMS-style content delivery from a single platform. Plasmic's data provider system enables fetching external API data and making it available to visual builder components through a named data context — build a PackageDataProvider component that fetches npm registry data and exposes it to Plasmic's visual editor as a data source. Makeswift's focused Next.js integration doesn't support structured data models — it's purely a visual editor for Next.js pages.
A/B Testing and Personalization Implementation
Builder.io's built-in A/B testing creates content variants in the visual editor and serves them based on configurable traffic splits and targeting rules — no separate A/B testing platform required. The SDK handles variant assignment client-side using a cookie-based split, and conversion tracking via builder.trackConversion() sends conversion events to Builder's analytics. For sophisticated experimentation (multi-variate tests, statistical significance calculations, segment-level analysis), integrate Builder.io with a dedicated experimentation platform like LaunchDarkly or Statsig rather than relying solely on Builder's basic A/B testing. Plasmic and Makeswift have no built-in A/B testing — implement experimentation at the Next.js middleware layer using feature flags, and render different Plasmic or Makeswift pages based on the flag value.
Migration and Lock-in Risk Management
All three platforms store page content in a proprietary format on their cloud infrastructure — there is no self-hosted option for any of them. The practical lock-in risk is the content stored in the visual editor: migrating away requires either exporting the visual content (possible but tedious) or rebuilding the visually edited pages as static code. The code components registered with the visual editor are your own React components and are fully portable. To minimize lock-in, keep the visual editor's role narrow: use it for marketing pages and landing pages where non-developers need editing access, and keep complex application pages as static React code. Document which pages are managed by the visual builder versus which are in the codebase, so the migration scope is clear if you need to change platforms.
Component Registration Best Practices
The component registration API is the primary integration surface between your codebase and the visual editor, and how you structure registrations determines how maintainable the integration is over time. Keep registration files co-located with the components they register — a PackageCard.builder.tsx file alongside PackageCard.tsx makes it clear which components are exposed to the visual editor and which are internal. For Builder.io, call Builder.registerComponent in a file that is imported during client-side initialization only — registrations executed on the server cause hydration mismatches. Plasmic's PLASMIC.registerComponent calls should happen in a file imported before any PlasmicComponent renders, typically in the root layout. Makeswift's ReactRuntime.registerComponent must be called before the MakeswiftPage component renders, making a dedicated makeswift/components.ts bootstrap file the right pattern. All three platforms support registering the same React component multiple times with different names, which is useful for creating simplified versions of complex components with fewer configurable props for non-developer editors.
Webhook Architecture and Cache Invalidation
When a content editor publishes a change in the visual editor, your Next.js deployment needs to be notified to invalidate cached pages and re-render the updated content. Each platform delivers this capability differently. Builder.io's webhook system sends a POST request to your configured endpoint whenever content is published, unpublished, or deleted — your webhook handler calls Next.js's revalidatePath() or revalidateTag() to invalidate the relevant cached page without triggering a full deployment. Plasmic's webhook support is available on paid plans and sends events for component and page changes — useful for triggering revalidation of Plasmic-managed pages. Makeswift's tight Next.js integration uses Next.js's built-in draft mode for live preview — editors preview in-progress changes in real time without publishing, and the production site uses ISR (Incremental Static Regeneration) with on-demand revalidation triggered by the Makeswift API. For all three platforms, implement idempotent webhook handlers that can safely process duplicate deliveries, and verify the webhook signature to prevent unauthorized cache invalidation from malicious requests.
When to Use Each
Use Builder.io if:
- Need a visual CMS with A/B testing, targeting, and scheduling
- Want framework-agnostic visual editing (React, Vue, Svelte)
- Building marketing pages where non-developers need to edit content
- Need structured data models alongside visual page building
Use Plasmic if:
- Want to generate clean React code from visual designs
- Need Figma-to-React import workflow
- Prefer visual design that outputs real, maintainable components
- Building a design system where designers create production components
Use Makeswift if:
- Building exclusively with Next.js and want the tightest integration
- Need inline visual editing with live preview
- Prefer a focused, Next.js-specific editing experience
- Want the simplest setup for adding visual editing to a Next.js site
Methodology
Download data from npm registry (weekly average, March 2026). Feature comparison based on @builder.io/sdk-react v3.x, @plasmicapp/loader-nextjs v1.x, and @makeswift/runtime v0.x.
Compare visual builders and developer CMS tools on PkgPulse →
See also: React vs Vue and React vs Svelte, Contentful vs Sanity vs Hygraph.