Content Engine

Relations & Advanced Modeling

Connect content types with foreign keys, junction tables, field groups, and repeatable child records.

Content Engine uses real relational modeling rather than unstructured JSON blobs. To-many relations generate indexed junction tables, repeatables generate child tables with foreign keys, and groups map to structured columns.

Quick start: To-One Relation

Connect an article to a single category:

1. Declare the Field

plugins/blog/src/content/article.ts
import { defineContentType, field } from "@vitnode/core/content"
import { categoryContentType } from "./category"

export const articleContentType = defineContentType({
  id: "blog.article",
  tableName: "blog_articles",
  fields: {
    title: field.text({ required: true }),
    category: field.relation({
      required: true,
      onDelete: "restrict",
      target: () => categoryContentType,
    }),
  },
})

2. Connect Drizzle References

When a relation targets another content type, provide the referenced primary key column in createContentModel:

plugins/blog/src/database/articles.ts
import { createContentModel } from "@vitnode/core/content/server"
import { articleContentType } from "../content/article"
import { blogCategoriesTable } from "./categories"

export const articleContent = createContentModel(articleContentType, {
  references: {
    category: () => blogCategoriesTable.id,
  },
})

export const blogArticlesTable = articleContent.table

Self-referencing relations (self: true), field.user, and field.file resolve their references automatically and do not require entries in references.


Relation Shapes

ShapeDeclarationDatabase Structure
To-Onefield.relation({ target })Foreign key column on base table (categoryId).
To-Manyfield.relation({ target, multiple: true })Auto-generated junction table ({tableName}_{field}).
Self-Referencingfield.relation({ self: true })Foreign key referencing the same table's id.
User Relationfield.user({ multiple?: boolean })FK to core_users.id (or junction table if multiple: true).
File Relationfield.file({ multiple?: boolean })FK to core_files.id (or junction table if multiple: true).

To-Many Relations & Junction Tables

Support multi-category selection or ordered co-authors:

plugins/blog/src/content/post.ts
fields: {
  categories: field.relation({
    multiple: true,
    min: 1, // Enforce at least 1 selected
    onDelete: "cascade", // "cascade" or "restrict" (cannot be "set null" on junction)
    target: () => categoryContentType,
  }),
  authors: field.user({
    multiple: true,
    ordered: true, // Preserves drag-and-drop order
  }),
}

Exporting Junction Tables for Migrations

To-many relations generate junction tables accessible via model.advancedTables.junctions. Export them so Drizzle Kit generates their schema migrations:

plugins/blog/src/database/posts.ts
import { createContentModel } from "@vitnode/core/content/server"
import { postContentType } from "../content/post"
import { blogCategoriesTable } from "./categories"

export const postContent = createContentModel(postContentType, {
  references: {
    categories: () => blogCategoriesTable.id,
  },
})

export const blogPostsTable = postContent.table
export const blogPostsCategoriesJunctionTable =
  postContent.advancedTables.junctions.categories

Field Groups (field.group)

Group related fields into a single nested object:

fields: {
  seo: field.group({
    fields: {
      metaTitle: field.text({ maxLength: 70 }),
      metaDescription: field.textarea({ maxLength: 160 }),
      noIndex: field.boolean({ defaultValue: false }),
    },
  }),
}

Columns are created with snake_case prefixes on the base table (seo_meta_title, seo_meta_description, seo_no_index).


Repeatable Child Rows (field.repeatable)

Create structured arrays backed by a real child database table:

fields: {
  faqItems: field.repeatable({
    min: 1,
    max: 10, // Defaults to 100
    fields: {
      question: field.text({ required: true }),
      answer: field.textarea({ required: true }),
    },
  }),
}

Exporting Repeatable Tables for Migrations

Repeatable child tables are stored under model.advancedTables.repeatables. Export them alongside your base table:

plugins/blog/src/database/posts.ts
export const blogPostsTable = postContent.table
export const blogPostsFaqItemsChildTable =
  postContent.advancedTables.repeatables.faqItems

Each repeatable row carries id, itemId (FK cascading to the parent record), position, createdAt, and the child field columns.


field.relation Options

Prop

Type

Learn More