← Back to Blog Drupal

Drupal's Entity/Field System in Node.js: How drop.js Works

April 2026 · 10 min read

Drupal’s content modeling system is the best in the CMS world. That is not marketing. It is a factual statement backed by 20 years of iteration on entity types, field types, entity references, paragraphs, taxonomy, revisions, and content workflows. No other CMS has this depth of structured content modeling.

The problem is that the system is locked inside a PHP application. drop.js unlocks it.

drop.js implements Drupal’s entity/field architecture natively in Node.js with TypeScript. Not “inspired by.” Not “similar to.” The same data model, the same database schema, the same concepts, with the same names. The schema is byte-identical to Drupal 11.

This article walks through every major Drupal concept and shows its drop.js equivalent side by side.

Content Types

In Drupal, content types are defined through YAML configuration files (or the admin UI, which generates YAML).

Drupal (YAML)

# config/sync/node.type.article.yml
langcode: en
status: true
name: Article
type: article
description: 'Use articles for time-sensitive content like news and blog posts.'
new_revision: true
preview_mode: 1
display_submitted: true

drop.js (JSON)

{
  "type": "article",
  "label": "Article",
  "description": "Use articles for time-sensitive content like news and blog posts.",
  "revision": true
}

The core concept is identical: a content type is a bundle that defines the structure for a group of entities. In both systems, creating a content type gives you a base set of fields (title, status, created, changed, author) automatically.

The difference is format and tooling. Drupal uses YAML and the configuration management system. drop.js uses JSON files. Both can also be managed through admin UIs.

Fields

Drupal supports dozens of field types through core and contrib modules. drop.js ships 18 field types that cover the core content modeling needs.

Drupal (YAML Field Storage + Field Instance)

In Drupal, adding a field requires two config files: field storage (defines the field globally) and field instance (attaches it to a content type).

# field.storage.node.field_subtitle.yml
langcode: en
status: true
id: node.field_subtitle
field_name: field_subtitle
entity_type: node
type: string
settings:
  max_length: 255
cardinality: 1

# field.field.node.article.field_subtitle.yml
langcode: en
status: true
id: node.article.field_subtitle
field_name: field_subtitle
entity_type: node
bundle: article
label: Subtitle
required: false
settings: {}

drop.js (JSON)

{
  "type": "article",
  "label": "Article",
  "fields": {
    "field_subtitle": {
      "type": "string",
      "label": "Subtitle",
      "max_length": 255,
      "required": false
    }
  }
}

One file instead of two. The field definition is inline with the content type definition. The storage details are inferred from the field type.

Supported Field Types

drop.js TypeDrupal EquivalentStorage
stringstringVARCHAR(255)
text_longtext_longLONGTEXT with format
integerintegerINT
floatfloatFLOAT
booleanbooleanTINYINT
emailemailVARCHAR(254)
datedatetimeVARCHAR(20)
linklinkVARCHAR(2048) + title
filefileFile reference
imageimageFile reference + alt/title
colorcolor_field (contrib)VARCHAR(7)
entity_referenceentity_referenceTarget ID + type
list_stringlist_stringVARCHAR(255)
list_integerlist_integerINT
list_floatlist_floatFLOAT
decimaldecimalNUMERIC(precision, scale)
telephonetelephoneVARCHAR(256)
timestamptimestampINT

Field cardinality works the same way: cardinality: 1 for single value, cardinality: -1 for unlimited, or any positive integer for a fixed maximum.

Entity References

Entity references are how Drupal models relationships between content. An article references an author (user), a category (taxonomy term), or related articles (other nodes). drop.js handles this identically.

Drupal

# field.storage.node.field_related_articles.yml
type: entity_reference
settings:
  target_type: node
cardinality: 3

# field.field.node.article.field_related_articles.yml
settings:
  handler: default:node
  handler_settings:
    target_bundles:
      article: article

drop.js

{
  "field_related_articles": {
    "type": "entity_reference",
    "label": "Related Articles",
    "target_type": "node",
    "target_bundle": "article",
    "cardinality": 3
  }
}

In both systems, entity references store the target entity ID and resolve it when the entity is loaded. The JSON:API and GraphQL responses include relationship data that your frontend can follow to load related content.

# Fetch an article with related articles included
curl http://localhost:3000/api/node/article/1?include=field_related_articles

The ?include= parameter works the same way as Drupal’s JSON:API module because drop.js implements the same JSON:API specification.

Taxonomy

Drupal’s taxonomy system is one of its best features: vocabularies contain terms, terms can be hierarchical, and content references terms through entity reference fields.

Drupal

# taxonomy.vocabulary.tags.yml
vid: tags
name: Tags
description: 'Free-form tagging for articles'
weight: 0

# A term is created through the UI or API:
# POST /jsonapi/taxonomy_term/tags
# { "data": { "attributes": { "name": "JavaScript" } } }

drop.js

{
  "vocabulary": "tags",
  "label": "Tags",
  "description": "Free-form tagging for articles"
}

Terms are created the same way:

curl -X POST http://localhost:3000/api/taxonomy_term/tags \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "type": "taxonomy_term--tags",
      "attributes": {
        "name": "JavaScript"
      }
    }
  }'

Hierarchical taxonomy works the same way. Set a parent field on a term to nest it under another term. This gives you category trees, navigation menus, and multi-level classification without any extra configuration.

# Create a child term
curl -X POST http://localhost:3000/api/taxonomy_term/tags \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "type": "taxonomy_term--tags",
      "attributes": {
        "name": "React",
        "parent": [{ "target_id": 1 }]
      }
    }
  }'

Paragraphs

Drupal’s Paragraphs module is the standard approach for building flexible, component-based content. Instead of dumping everything into a single body field, editors compose pages from reusable paragraph types: text blocks, image galleries, CTAs, accordions, and whatever else you define.

This is one of the things other CMS platforms struggle to replicate. drop.js implements it as a core feature.

Drupal

In Drupal, you install the Paragraphs module, define paragraph types with fields, and add an entity reference revisions field to your content type that targets paragraphs.

# paragraphs.paragraphs_type.text_block.yml
id: text_block
label: 'Text Block'

# field.field.paragraph.text_block.field_body.yml
field_name: field_body
entity_type: paragraph
bundle: text_block
label: Body

drop.js

{
  "type": "text_block",
  "label": "Text Block",
  "entity_type": "paragraph",
  "fields": {
    "field_body": {
      "type": "text_long",
      "label": "Body",
      "required": true
    }
  }
}

Then reference paragraphs from a content type:

{
  "type": "page",
  "label": "Page",
  "fields": {
    "field_content": {
      "type": "entity_reference",
      "label": "Content Sections",
      "target_type": "paragraph",
      "target_bundle": ["text_block", "image_gallery", "call_to_action"],
      "cardinality": -1
    }
  }
}

The parent-child relationship between the node and its paragraphs is maintained automatically. When you load a page, its paragraphs are available through the entity reference field. When you delete a page, its paragraphs are cleaned up.

Fetching Paragraphs via API

# Get a page with all its content sections
curl http://localhost:3000/api/node/page/1?include=field_content

Or with GraphQL:

query {
  nodePage(id: "1") {
    title
    fieldContent {
      ... on ParagraphTextBlock {
        fieldBody {
          value
        }
      }
      ... on ParagraphImageGallery {
        fieldImages {
          url
          alt
        }
      }
      ... on ParagraphCallToAction {
        fieldHeading
        fieldLink {
          uri
          title
        }
      }
    }
  }
}

This is how you build flexible page layouts with a headless CMS without reaching for a visual page builder. The structure is defined. The content is structured. Your frontend renders the components based on the paragraph type.

Revisions

Every entity in Drupal can have revision history. Every edit creates a new revision. You can view previous revisions, compare them, and revert. drop.js implements the same system with the same storage model.

How It Works

Both systems store the current revision in the entity base table and all revisions in a separate revision table. Field data is also versioned in *_revision tables.

# Get all revisions for an article
curl http://localhost:3000/api/node/article/1/revisions
{
  "data": [
    {
      "id": "1",
      "revision_id": 3,
      "attributes": {
        "title": "Updated Title",
        "changed": "2026-04-07T14:30:00Z",
        "revision_log": "Fixed typo in title"
      }
    },
    {
      "id": "1",
      "revision_id": 2,
      "attributes": {
        "title": "Original Title (v2)",
        "changed": "2026-04-06T10:00:00Z",
        "revision_log": "Added featured image"
      }
    },
    {
      "id": "1",
      "revision_id": 1,
      "attributes": {
        "title": "Original Title",
        "changed": "2026-04-05T09:00:00Z",
        "revision_log": "Initial creation"
      }
    }
  ]
}
# Load a specific revision
curl http://localhost:3000/api/node/article/1/revisions/2

# Revert to a previous revision
curl -X POST http://localhost:3000/api/node/article/1/revisions/2/revert

Because the database schema is the same, revision data migrated from Drupal is immediately accessible through drop.js. Your content history survives the migration.

Views (Query Builder)

Drupal Views is arguably the most powerful feature in Drupal. It is a visual query builder that generates lists, grids, tables, and feeds from your content with filters, sorts, relationships, exposed filters, and pagination.

drop.js implements the same concept through its views system.

Drupal (Views UI generates config)

# views.view.recent_articles.yml
display:
  default:
    display_options:
      filters:
        status:
          value: '1'
        type:
          value: article
      sorts:
        created:
          order: DESC
      pager:
        type: full
        options:
          items_per_page: 10

drop.js (JSON)

{
  "id": "recent_articles",
  "label": "Recent Articles",
  "entity_type": "node",
  "bundle": "article",
  "filters": {
    "status": { "value": true }
  },
  "sorts": {
    "created": { "direction": "DESC" }
  },
  "pagination": {
    "items_per_page": 10
  }
}

Views in drop.js generate API endpoints:

# The view is accessible at:
curl http://localhost:3000/api/views/recent_articles

# With exposed filters:
curl http://localhost:3000/api/views/recent_articles?field_tags=5&page=2

Exposed filters work the same way: define which filters visitors can control, and the API accepts those as query parameters.

Content Workflow

Drupal’s Content Moderation module provides a state machine for editorial workflows: Draft, Review, Published, Archived. Transitions between states are controlled by permissions.

Drupal

# workflows.workflow.editorial.yml
type_settings:
  states:
    draft:
      label: Draft
    review:
      label: Review
    published:
      label: Published
    archived:
      label: Archived
  transitions:
    submit_for_review:
      from: [draft]
      to: review
    publish:
      from: [review]
      to: published
    archive:
      from: [published]
      to: archived
    back_to_draft:
      from: [review, archived]
      to: draft

drop.js

{
  "workflow": "editorial",
  "label": "Editorial Workflow",
  "states": {
    "draft": { "label": "Draft" },
    "review": { "label": "Review" },
    "published": { "label": "Published" },
    "archived": { "label": "Archived" }
  },
  "transitions": {
    "submit_for_review": {
      "from": ["draft"],
      "to": "review",
      "label": "Submit for Review"
    },
    "publish": {
      "from": ["review"],
      "to": "published",
      "label": "Publish"
    },
    "archive": {
      "from": ["published"],
      "to": "archived",
      "label": "Archive"
    },
    "back_to_draft": {
      "from": ["review", "archived"],
      "to": "draft",
      "label": "Back to Draft"
    }
  }
}

Each transition can be gated by permissions. An author can move content from Draft to Review, but only an editor can move it from Review to Published. This maps directly to how Drupal handles editorial workflows.

# Transition an article to "review" state
curl -X PATCH http://localhost:3000/api/node/article/1 \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "attributes": {
        "moderation_state": "review"
      }
    }
  }'

Permissions and RBAC

Drupal’s permission system is straightforward: roles have permissions, users have roles, and content access is checked against the user’s permissions.

drop.js implements the same model with 19 core permissions:

administer content types     create [bundle] content
edit own [bundle] content    edit any [bundle] content
delete own [bundle] content  delete any [bundle] content
administer taxonomy          create terms in [vocabulary]
edit terms in [vocabulary]   delete terms in [vocabulary]
administer users             access user profiles
administer permissions       access content overview
view own unpublished content view any unpublished content
administer url aliases       use editorial transition [name]
access administration pages

Assigning Permissions

# Create a role with specific permissions
curl -X POST http://localhost:3000/api/user/role \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "attributes": {
        "id": "editor",
        "label": "Editor",
        "permissions": [
          "create article content",
          "edit any article content",
          "delete own article content",
          "use editorial transition publish",
          "access content overview"
        ]
      }
    }
  }'

Every API request is checked against the authenticated user’s permissions. Anonymous users can only access published content. Editors can create and edit. Admins can do everything. This is the same security model Drupal has used for years.

URL Aliases

Drupal’s Pathauto module generates human-readable URL aliases from content fields. /node/47 becomes /blog/my-article-title. drop.js includes this as a core feature.

Pattern-Based Aliases

{
  "pattern": "/blog/[node:title]",
  "entity_type": "node",
  "bundle": "article"
}

When an article titled “Getting Started with drop.js” is created, it automatically gets the alias /blog/getting-started-with-dropjs. The title is transliterated, lowercased, and hyphenated.

Custom Aliases

You can also set aliases manually:

curl -X POST http://localhost:3000/api/url-alias \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "attributes": {
        "path": "/node/47",
        "alias": "/about/our-team"
      }
    }
  }'

Custom aliases override pattern-generated ones. This is the same precedence behavior as Drupal.

The Bigger Picture

This is not a surface-level similarity. drop.js implements Drupal’s data model at the database level. The same tables. The same column types. The same relationships between entities, fields, and revisions.

For Drupal teams moving to Node.js, this means your mental model transfers completely. For teams evaluating headless CMS options, this means you get 20 years of content modeling design decisions without the PHP dependency.

The gap between Drupal’s content modeling capabilities and what most headless CMS platforms offer is significant. Features like paragraphs, revision history, taxonomy hierarchies, content workflows, and granular permissions are afterthoughts or enterprise add-ons in other systems. In drop.js, they are the foundation.