Building a Next.js Website Editor

Introduction
Over the past two weeks, I set out to build a multi-tenant, drag-and-drop website editor, partly out of curiosity about how they work and partly because I thought it would be a great addition to my portfolio.
As I built it, I quickly realized what an incredible learning experience it was. Despite the complexity of website editors, there are surprisingly few resources available on the topic. That’s why I decided to write this article - not just to share what I learned, but to explain why I believe every web developer should build one at some point.
While I used Next.js and Tailwind CSS for this project, the core concepts apply to any tech stack.
If you're stuck, curious, or just want to see it in action, check out the GitHub Repo for a live demo!
If you find this project helpful, dropping a star on GitHub would mean a ton!
Why Build a Website Editor?
Most people build to-do apps, Reddit clones, or blog platforms when learning web development. Sure, you’ll pick up the basics like state management, APIs, and JSX syntax. But those projects rarely push you beyond that.
A website editor, on the other hand, forces you to think like a developer and problem solver. It’s not just about fetching data and displaying it, it’s about:
Figuring out how to store entire web pages in a database
Managing complex state across multiple components.
Building a system that saves and loads changes seamlessly.
and so much more.
For beginners, this is a perfect challenge to move past simple CRUD apps.
But even for experienced developers, building a website editor is incredibly useful. On one hand, it’s a great addition to your portfolio, helping it stand out. On the other hand, the logic behind website editors is rarely discussed in the development world, making it a unique and valuable project to explore.
Defining the Core Structure of the Editor
Building a website editor requires a well-defined structure to manage elements, ensure data persistence and most importantly, making it easy for us developers to add new components like img
or h1
.
Structuring Elements
At the heart of the editor is a flexible data model representing each page and its elements. An element consists of:
A unique identifier (typically a
cuid
)A name, visible only in the editor (e.g.
Container
)A content attribute, which can either be an array of other Editor Elements (for containers) or custom values, such as the
href
for a linkCSS attributes like
width
,height
,color
, etc.
type EditorElement = {
id: string;
styles: React.CSSProperties;
name: string;
type: ElementTypes;
content: EditorElement[] | { href?: string; innerText?: string };
};
type ElementTypes =
| "text"
| "container"
| "section"
| "link"
| "2Col"
| "3Col"
| "video"
| "image"
| "__body"
| null;
Structuring the Editor
To manage our elements
, we introduce the Editor
, a central structure that keeps track of everything happening within the editor. This Editor
will be accessible to all components wrapped in the EditorProvider
, making global state management straightforward and efficient.
type Editor = {
pageId: string;
liveMode: boolean;
previewMode: boolean;
visible: boolean;
elements: EditorElement[];
selectedElement: EditorElement;
device: DeviceTypes;
};
The visible
attribute determines whether a page is accessible to all users or only the creator.
Additionally, most modern software includes undo and redo functionality. While I won’t cover their implementation in this article to stay on topic, you can check out how I handled them in the GitHub Repo.
Storing Pages in the Database
To ensure persistence, we obviously need a database. For the ORM, I chose Prisma.
model Page {
id String @id @default(cuid())
userId String
title String
visible Boolean @default(false)
content String? @db.LongText
subdomain String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([id, userId])
}
You might notice that the page content is stored as a string. While this works, it's not the most efficient approach. Ideally, using something like an S3 bucket would reduce database load and improve query performance, at the cost of added complexity. I’ll be exploring this further in the future—if you're interested, consider starring the repo so you don’t miss any updates!
State Management
State management is arguably the most important part of the editor, ensuring smooth communication between components like the settings sidebar and the renderer. A well-structured state allows for dynamic content updates, real-time user interactions, and efficient rendering while keeping the UI responsive.
Each state update is handled by a reducer, ensuring actions are processed in an immutable manner. For example, adding an element works recursively to find the correct container and insert new content:
const editorReducer = (
state: EditorState = initialState,
action: EditorAction
): EditorState => {
switch (action.type) {
case "ADD_ELEMENT":
const updatedEditorState = {
...state.editor,
elements: addElement(state.editor.elements, action),
};
const newEditorState = {
...state,
editor: updatedEditorState,
};
return newEditorState;
// Remaining reducer code...
This setup ensures that interactions in the UI directly reflect in the state, keeping everything in sync. Finally, the EditorProvider wraps the application, exposing the state and dispatcher via context:
type EditorProps = {
children: React.ReactNode;
pageId: string;
pageDetails: Page; // this refers to the prisma model "Page"
};
const EditorProvider = (props: EditorProps) => {
const [state, dispatch] = useReducer(editorReducer, initialState);
return (
<EditorContext.Provider
value={{
state,
dispatch,
pageId: props.pageId,
pageDetails: props.pageDetails,
}}
>
{props.children}
</EditorContext.Provider>
);
};
Rendering & Editing Elements
To ensure every component within the Editor Page has access to the reducer we just created, the entire site is wrapped inside the EditorProvider
. This provider takes the pageId
from the URL parameters (/editor/123) and fills pageDetails
with data retrieved from a simple database query. Make sure you're not caching this query as for the editor we always want the newest state.
Inside the PageEditor component, we can now use the useEditor
hook we just built to access state
and dispatch
. This allows us to render elements recursively, starting with the body tag:
const { state, dispatch } = useEditor();
// Other logic
{state.editor.elements.map((childElement) => (
<Recursive key={childElement.id} element={childElement} />
))}
Recursive Element Rendering
The Recursive component handles rendering different types of elements by mapping them to their corresponding components. Each element type determines the structure and behavior of the rendered UI:
function Recursive({ element }: { element: EditorElement }) {
switch (element.type) {
case "__body":
case "container":
case "2Col":
case "3Col":
return <Container element={element} />;
case "text":
return <TextComponent element={element} />;
default:
return null;
}
}
This ensures that the editor dynamically processes and displays elements based on their types. This approach also makes it incredibly easy for you to add new custom components or even entire page sections.
Rendering a Container
The Container
component is responsible for rendering nested elements and handling drag-and-drop interactions within the editor. It allows users to structure content in a way of their liking.
function Container({ element }: { element: EditorElement }) {
// Previous logic...
const handleOnDrop = (e: React.DragEvent) => {
e.stopPropagation();
setIsDraggingOver(false);
const componentType = e.dataTransfer.getData("componentType") as ElementTypes;
dispatch({
type: "ADD_ELEMENT",
payload: { containerId: id, elementDetails: createNewElement(componentType) },
});
};
return (
<div
className={clsx("relative group", { "outline-selected": isSelected })}
style={styles}
onDrop={handleOnDrop}
onDragOver={(e) => { e.preventDefault(); setIsDraggingOver(true); }}
onClick={() => dispatch({ type: "CHANGE_SELECTED_ELEMENT", payload: { elementDetails: element } })}
>
//Badge with name and Trash icon...
<div className="p-4 w-full">
{content.map((childElement) => (
<Recursive key={childElement.id} element={childElement} />
))}
</div>
</div>
);
}
The styles
attribute is added to the container making sure any style changes such as a custom background color stored in the database are applied correctly. We also show a custom outline depending on different states like the container being selected or dragged over.
Multi-Tenant Page Rendering
The final step is configuring how and, most importantly, where a page is rendered. My project follows a multi-tenant approach, allowing users to host their custom sites on a subdomain of mine, such as test.framely.site
.
This logic is primarily handled in the middleware.ts, along with some required DNS setup. You can check out my implementation in the GitHub Repo or refer to this great blog post by Vercel explaining how to set it up.
Fetching and Rendering Pages
To render a page, we first fetch the page data. It's crucial to cache this data to prevent unnecessary database queries and improve performance.
Once we have the data, we simply render a PageEditor, passing the pageId
and enabling liveMode
to hide any editor components such as the settings sidebar.
We also need to check the visibility status of the requested page. If the page is private, we should inform the user accordingly.
It's important to note that session validation is handled on the server-side. This means that if a page is private, the client never receives its data - only an error message is returned. This approach ensures better security by preventing unauthorized users from accessing private page details.
export default async function Page({ params }: Props) {
const { domain } = params;
const response = await getPageByDomain(domain);
if (!response.success || !response.page) return notFound();
if (response.private) {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center">
<h1 className="text-xl font-medium text-gray-900">{response.msg}</h1>
</div>
);
}
return (
<EditorProvider pageDetails={response.page} pageId={response.page.id}>
<PageEditor pageId={response.page.id} liveMode />
</EditorProvider>
);
}
Conclusion
Building a multi-tenant, drag-and-drop website editor was an incredible learning experience that pushed me far beyond traditional CRUD apps.
If you're looking for a challenge that will sharpen your frontend and backend skills while teaching you practical problem-solving, I highly recommend building one yourself!
If you found this guide helpful, feel free to check out and star the GitHub Repo as it helps out a ton!
Feel free to ask any questions you might have, I'd love to help!
Subscribe to my newsletter
Read articles from Bela Strittmatter directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
