DEV Community

Cover image for Designing Dynamic Mongoose Schemas for a Drag-and-Drop Page Builder
Malek Mostafa
Malek Mostafa

Posted on

Designing Dynamic Mongoose Schemas for a Drag-and-Drop Page Builder

Most full-stack tutorials cover standard CRUD applications where the data model is strictly defined: a post has a title, a body, and an author. But what happens when you need to build a system where the user dictates the layout?

When architecting dynamic SaaS tools, like a drag-and-drop brochure builder for marketing teams, the database must be flexible enough to store UI components in any order, yet structured enough to query efficiently. Here is how to design a scalable, array-based schema with Mongoose and render it in React.

The Limitations of Rigid Schemas

In a traditional application, your database schema mirrors a static UI. For a standard blog, the schema looks like this:

{ title: String, content: String, imageUrl: String }
Enter fullscreen mode Exit fullscreen mode

This rigid structure breaks down for dynamic page builders. If a user wants to stack a text block, an image gallery, a call-to-action, and then another text block, a static schema can't represent it. You end up with messy workarounds like content_1, content_2, or giant, unsearchable HTML strings stored in the database.

To build a true component-based architecture, the database must store an ordered array of independent building blocks.

Structuring Array-Based Mongoose Schemas

The solution is an array of subdocuments where each item represents one UI component. A type identifier lets different data structures live in the same array.

const mongoose = require('mongoose');

const BlockSchema = new mongoose.Schema({
  type: {
    type: String,
    required: true,
    enum: ['TEXT', 'IMAGE_GALLERY', 'CALL_TO_ACTION', 'VIDEO'],
  },
  // The shape of `data` varies based on the block type
  data: {
    type: mongoose.Schema.Types.Mixed,
    required: true,
  },
  order: {
    type: Number,
    required: true,
  },
}); // each block keeps its default _id, which we'll use as a stable React key

const BrochureSchema = new mongoose.Schema(
  {
    title: { type: String, required: true },
    author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
    blocks: [BlockSchema],
    isPublished: { type: Boolean, default: false },
  },
  { timestamps: true }
);

module.exports = mongoose.model('Brochure', BrochureSchema);
Enter fullscreen mode Exit fullscreen mode

With data: mongoose.Schema.Types.Mixed, the nested payload is schema-less. A TEXT block might store { text: "Hello World", align: "center" }, while an IMAGE_GALLERY block stores { urls: ["img1.jpg", "img2.jpg"], layout: "grid" }.

Gotcha: Mongoose can't detect changes inside a Mixed field. If you modify a block's data in place, call brochure.markModified('blocks') before save(), or the change silently won't persist.

Note: MongoDB arrays already preserve insertion order, so the order field is technically optional. I keep it explicit because it makes reordering and debugging easier when blocks are dragged around.

A stricter alternative: discriminators

When you want per-type validation inside the schema itself, Mongoose supports embedded discriminators on document arrays:

const BlockSchema = new mongoose.Schema(
  { order: { type: Number, required: true } },
  { discriminatorKey: 'type' }
);

const BrochureSchema = new mongoose.Schema({
  title: { type: String, required: true },
  blocks: [BlockSchema],
});

const blocksPath = BrochureSchema.path('blocks');

blocksPath.discriminator(
  'TEXT',
  new mongoose.Schema({ text: String, align: String })
);
blocksPath.discriminator(
  'IMAGE_GALLERY',
  new mongoose.Schema({ urls: [String], layout: String })
);
Enter fullscreen mode Exit fullscreen mode

Mixed gives you speed of iteration in an early-stage product. Discriminators give you stronger guarantees once the block types stabilize.

Mapping Data to React Components

Once your Node.js backend serves this array, the React frontend needs to translate the JSON back into UI components.

Instead of writing a long if/else chain, use a component dictionary. It keeps the codebase modular and the rendering logic flat.

import TextBlock from './blocks/TextBlock';
import ImageGallery from './blocks/ImageGallery';
import CtaBlock from './blocks/CtaBlock';
import VideoBlock from './blocks/VideoBlock';

// 1. Create a component dictionary
const ComponentMap = {
  TEXT: TextBlock,
  IMAGE_GALLERY: ImageGallery,
  CALL_TO_ACTION: CtaBlock,
  VIDEO: VideoBlock,
};

export default function BrochureRenderer({ blocks }) {
  // 2. Sort blocks by their order property
  const sortedBlocks = [...blocks].sort((a, b) => a.order - b.order);

  return (
    <div className="brochure-container">
      {sortedBlocks.map((block) => {
        // 3. Dynamically resolve the component
        const BlockComponent = ComponentMap[block.type];

        if (!BlockComponent) {
          return <p key={block._id}>Unknown block type</p>;
        }

        return <BlockComponent key={block._id} data={block.data} />;
      })}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Using the block's _id as the key (instead of the array index) matters in a builder: when users reorder blocks, index keys make React reuse the wrong component state.

This architecture scales well. When the business asks for a new feature, say an interactive map block, you add MAP to the Mongoose enum and map it to a new <MapBlock /> in React. The core rendering engine never needs to change.

Production Considerations

1. Validate and sanitize the payload.
Because Mixed bypasses Mongoose's type checking, the validation burden moves to your controllers. Validate each block's data against a per-type schema (Zod, Joi, or similar) before saving:

const { z } = require('zod');

const dataSchemas = {
  TEXT: z.object({
    text: z.string().max(5000),
    align: z.enum(['left', 'center', 'right']).default('left'),
  }),
  IMAGE_GALLERY: z.object({
    urls: z.array(z.string().url()).max(20),
    layout: z.enum(['grid', 'carousel']),
  }),
};

function validateBlock(block) {
  const schema = dataSchemas[block.type];
  if (!schema) throw new Error(`Unsupported block type: ${block.type}`);
  return schema.parse(block.data);
}
Enter fullscreen mode Exit fullscreen mode

If any block renders user-supplied HTML, sanitize it (for example with DOMPurify) before it reaches dangerouslySetInnerHTML. React escapes plain text by default, but not that.

2. Lazy-load heavy blocks.
If your builder includes heavy components (3D viewers, large video players), code-split the component map with React.lazy and Suspense, so the browser only downloads the JavaScript for block types that actually appear on the page:

import { lazy, Suspense } from 'react';

const ComponentMap = {
  TEXT: TextBlock,
  IMAGE_GALLERY: ImageGallery,
  VIDEO: lazy(() => import('./blocks/VideoBlock')),
};

// Wrap the rendered list once:
<Suspense fallback={<div className="block-skeleton" />}>
  {/* ...sortedBlocks.map(...) */}
</Suspense>
Enter fullscreen mode Exit fullscreen mode

3. Index the parent, not the payload.
Don't index the unstructured data object inside blocks. It adds index size without helping your queries. Index the parent fields you actually filter on, like author and isPublished, to keep fetch queries fast.

4. Mind the document size limit.
A MongoDB document is capped at 16 MB. That's plenty for blocks that store text and URLs, but store media as URLs (S3, Cloudinary, etc.), never as embedded binary data.

Wrapping Up

Building a custom SaaS tool requires shifting from a page-based mindset to a component-based mindset, not just in React, but all the way down to the database schema.

If you're building something similar, I'd like to hear how you handle block validation and versioning. Let me know in the comments.

Top comments (0)