Builder.io vs Plasmic vs Makeswift: Visual Page Builders (2026)
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 |
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 →