DEV Community

Bela Strittmatter
Bela Strittmatter

Posted on

1 1 1 1 1

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.

Framely Website Editor Demo Image

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 text.

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 link
  • CSS attributes like width, height, color, etc.
type EditorElement = {
  id: string;
  styles: React.CSSProperties;
  name: string;
  type: ElementTypes;
  content: EditorElement[] | { href?: string; innerText?: string };
};
Enter fullscreen mode Exit fullscreen mode
type ElementTypes =
  | "text"
  | "container"
  | "section"
  | "link"
  | "2Col"
  | "3Col"
  | "video"
  | "image"
  | "__body"
  | null;
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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} />
))}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 Containercomponent 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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!

Image of Datadog

Create and maintain end-to-end frontend tests

Learn best practices on creating frontend tests, testing on-premise apps, integrating tests into your CI/CD pipeline, and using Datadog’s testing tunnel.

Download The Guide

Top comments (2)

Collapse
 
gamergirl06 profile image
gamergirl06 • Edited

Great technical insight into how web editors work, I‘m considering building one of my own now! 🙌

Collapse
 
belastrittmatter profile image
Bela Strittmatter

Love to hear it, make sure to let me know if you do so!

Billboard image

Create up to 10 Postgres Databases on Neon's free plan.

If you're starting a new project, Neon has got your databases covered. No credit cards. No trials. No getting in your way.

Try Neon for Free →