M2M Relationships

This guide covers how M2M relationships work in the auto-admin interface.

M2M Relationships

This guide covers how M2M relationships work in the auto-admin interface.

Overview

The admin module automatically displays M2M relationships from your Drizzle schema. Relations appear as multi-select fields in edit forms with zero or minimal configuration.

📝 Note: M2M relations are auto-detected from Drizzle FK references. See the API M2M Guide for how auto-detection works.


Quick Start

1. Define Drizzle Schema

// modules/blog/schema.ts
import { relations } from 'drizzle-orm'

export const articleCategories = sqliteTable('article_categories', {
  articleId: integer('article_id')
    .references(() => articles.id, { onDelete: 'cascade' }),
  categoryId: integer('category_id')
    .references(() => categories.id, { onDelete: 'cascade' }),
}, (table) => ({
  pk: primaryKey({ columns: [table.articleId, table.categoryId] })
}))

// Define relations
export const articleCategoriesRelations = relations(articleCategories, ({ one }) => ({
  article: one(articles, {
    fields: [articleCategories.articleId],
    references: [articles.id],
  }),
  category: one(categories, {
    fields: [articleCategories.categoryId],
    references: [categories.id],
  }),
}))

2. Optional: Customize Labels

// nuxt.config.ts
export default defineNuxtConfig({
  autoApi: {
    m2m: {
      // Auto-detection enabled by default
      relations: {
        articles: {
          categories: {
            label: 'Categories',  // ← Custom label for admin UI
            help: 'Select categories for this article',  // ← Help text
            displayField: 'name',  // ← Field shown in dropdown
          }
        }
      }
    }
  }
})

3. That's It!

The admin UI automatically:

  • ✅ Detects M2M relations from Drizzle schema
  • ✅ Displays them as multi-select fields
  • ✅ Shows them in both modal and page edit forms
  • ✅ Handles sync operations
  • ✅ Updates when you add new relations

Naming Convention Support

The M2M auto-detection system supports both camelCase and snake_case naming conventions. Your junction tables will be auto-detected regardless of naming style.

Supported Patterns

// ✅ snake_case (works automatically)
export const articleCategories = sqliteTable('article_categories', {
  articleId: integer('article_id')
    .references(() => articles.id, { onDelete: 'cascade' }),
  categoryId: integer('category_id')
    .references(() => categories.id, { onDelete: 'cascade' }),
}, (table) => ({
  pk: primaryKey({ columns: [table.articleId, table.categoryId] })
}))

// ✅ camelCase (works automatically)
export const articleCategories = sqliteTable('articleCategories', {
  articleId: integer('articleId')
    .references(() => articles.id, { onDelete: 'cascade' }),
  categoryId: integer('categoryId')
    .references(() => categories.id, { onDelete: 'cascade' }),
}, (table) => ({
  pk: primaryKey({ columns: [table.articleId, table.categoryId] })
}))

// ✅ Plural forms (works automatically)
export const articleCategories = sqliteTable('articles_categories', { ... })

// ✅ Mixed conventions (works automatically)
export const articleCategories = sqliteTable('article_categories', {
  articleId: integer('articleId'),  // camelCase column
  categoryId: integer('category_id'),  // snake_case column
})

Junction Table Hiding

Auto-detected junction tables are automatically:

  • ✅ Hidden from admin sidebar
  • ✅ Excluded from resource list
  • ✅ Shown as M2M fields in edit forms

No configuration needed!

Manual Override

If you need to control detection for a specific table:

// Force junction table hidden
autoAdmin: {
  resources: {
    customJunction: {
      type: 'junction'  // Force hide from sidebar
    }
  }
}

// Force regular resource visible (despite junction pattern)
autoAdmin: {
  resources: {
    orderItems: {
      type: 'resource',  // Show in sidebar
      displayName: 'Order Items',
    }
  }
}

For more details on auto-detection, see the API M2M Guide.


Configuration

Labels, Help Text, and Display Field

Configure how M2M relations appear in the admin UI:

autoApi: {
  m2m: {
    relations: {
      articles: {
        categories: {
          // ... junction config
          label: 'Article Categories',        // Field label in form
          help: 'Select categories to organize this article',  // Help text below field
          displayField: 'name',              // What shows in dropdown options
        }
      }
    }
  }
}

If not provided:

  • label - Defaults to capitalized relation name ("Categories")
  • help - Not shown
  • displayField - Tries common fields automatically: 'name', 'title', 'label', 'email'

Display Field Examples:

// For categories (has 'name' field)
categories: {
  displayField: 'name'  // Shows: "Technology", "Business", etc.
}

// For users (has 'email' and 'name')
users: {
  displayField: 'email'  // Shows: "user@example.com"
}

// For articles (has 'title')
relatedArticles: {
  displayField: 'title'  // Shows: "How to Build..."
}

// Custom format (if needed)
products: {
  displayField: 'sku'  // Shows: "PROD-12345"
}

Display Modes

Control how edit/view forms open:

autoAdmin: {
  ui: {
    editMode: 'page',  // 'modal' | 'page'
    viewMode: 'modal'  // 'modal' | 'page'
  }
}

Modal Mode (default):

  • Quick edits
  • Compact view
  • Good for simple forms

Page Mode (recommended for M2M):

  • Full-page layout
  • Better for complex forms with M2M relations
  • More space for multiple relation fields

How It Works

Automatic Detection

When you open an edit form, the admin automatically:

  1. Fetches M2M config from the API module
  2. Loads current relations for the record
  3. Displays multi-select for each relation
  4. Syncs changes when you save

Manual Override

You can manually configure M2M fields in admin config (not recommended):

autoAdmin: {
  resources: {
    articles: {
      formFields: {
        edit: [
          // Regular fields
          { name: 'title', widget: 'TextInput' },
          
          // Manual M2M field (not recommended - use API config instead)
          {
            name: 'categories',
            widget: 'MultiRelationSelect',
            label: 'Categories',
            options: {
              resource: 'categories',
              displayField: 'name',
              junctionTable: 'articleCategories',
              junctionLeftKey: 'articleId',
              junctionRightKey: 'categoryId',
            }
          }
        ]
      }
    }
  }
}

⚠️ Not Recommended: Manual admin config is verbose and duplicates API config. Use API module config instead!


UI Behavior

Edit Form

M2M relations appear as cards below the main form:

┌─────────────────────────────┐
│ Title: [..................] │
│ Content: [................] │
│ [Save] [Cancel]             │
└─────────────────────────────┘

┌─────────────────────────────┐
│ Categories                   │
│ Select categories for...     │
│                              │
│ [Multi-select dropdown]     │
│ [Save Categories] [Reset]   │
└─────────────────────────────┘

┌─────────────────────────────┐
│ Tags                         │
│                              │
│ [Multi-select dropdown]     │
│ [Save Tags] [Reset]          │
└─────────────────────────────┘

View Modal

M2M relations shown as read-only chips:

┌─────────────────────────────┐
│ Article Title                │
│ by John Doe                  │
│                              │
│ Categories                   │
│ [Technology] [Business]      │
│                              │
│ Tags                         │
│ [JavaScript] [Vue] [Nuxt]    │
└─────────────────────────────┘

Permissions

M2M fields respect resource permissions:

// modules/blog/auth.ts
export const articlesAuth = {
  async canUpdate(user) {
    // Controls both form fields AND M2M relations
    return user?.role === 'editor'
  }
}

If user lacks update permission:

  • M2M fields shown but disabled
  • Save buttons are disabled
  • Or hidden based on unauthorizedButtons config

Advanced

Hiding Relations

Hide specific relations from admin UI:

autoAdmin: {
  resources: {
    articles: {
      hiddenFields: ['internalCategoryId'],  // Won't show in any form
    }
  }
}

Custom Relation Widget

Create custom widget for special cases:

<!-- components/admin/CustomRelationWidget.vue -->
<template>
  <div>
    <!-- Your custom multi-select UI -->
  </div>
</template>

<script setup>
const props = defineProps<{
  modelValue: number[]
  options: any[]
}>()

const emit = defineEmits<{
  'update:modelValue': [value: number[]]
}>()
</script>

Register in admin config:

autoAdmin: {
  resources: {
    articles: {
      formFields: {
        edit: [
          {
            name: 'categories',
            widget: 'CustomRelationWidget',  // Your widget
            options: {
              // Custom options
            }
          }
        ]
      }
    }
  }
}

Metadata Support

Junction tables with metadata columns:

// Schema
export const articleCategories = sqliteTable('article_categories', {
  articleId: integer('article_id')...
  categoryId: integer('category_id')...
  sortOrder: integer('sort_order'),  // Metadata
  isPrimary: integer('is_primary', { mode: 'boolean' }),
})

// Config
autoApi: {
  m2m: {
    relations: {
      articles: {
        categories: {
          junctionTable: 'articleCategories',
          leftKey: 'articleId',
          rightKey: 'categoryId',
          metadataColumns: ['sortOrder', 'isPrimary'],
        }
      }
    }
  }
}

The admin UI will:

  • Show basic multi-select (no metadata editing by default)
  • Metadata preserved on save
  • Custom widget needed for editing metadata

Best Practices

1. Configure in API Module

// ✅ GOOD: Single source of truth
autoApi: {
  m2m: {
    relations: {
      articles: {
        categories: {
          junctionTable: 'articleCategories',
          leftKey: 'articleId',
          rightKey: 'categoryId',
          label: 'Categories',  // For admin
        }
      }
    }
  }
}

// ❌ BAD: Duplicating config in admin module
autoAdmin: {
  resources: {
    articles: {
      formFields: {
        edit: [
          { /* manual M2M config */ }
        ]
      }
    }
  }
}

2. Use Descriptive Labels

label: 'Article Categories',  // ✅ Clear
help: 'Organize this article by selecting relevant categories'  // ✅ Helpful

label: 'cats',  // ❌ Unclear

3. Use Page Mode for Complex Forms

autoAdmin: {
  ui: {
    editMode: 'page',  // ✅ Better for M2M
  }
}

4. Test Permissions

Verify M2M fields respect permissions:

  • Disabled when no update permission
  • Hidden/shown based on config

Troubleshooting

Relations Not Showing

Problem: M2M fields don't appear in edit form

Solutions:

  1. Check API module config is correct
  2. Verify junction table is registered
  3. Check browser console for errors
  4. Test M2M endpoint directly: GET /api/articles/10/relations/categories

Can't Save Relations

Problem: Save button doesn't work or shows errors

Solutions:

  1. Check permissions (need canUpdate)
  2. Verify related records exist
  3. Check browser network tab for API errors
  4. Ensure junction table has proper foreign keys

Wrong Labels

Problem: Relation shows wrong label

Solution: Update label in API config:

autoApi: {
  m2m: {
    relations: {
      articles: {
        categories: {
          // ...
          label: 'Correct Label Here',  // ← Update this
        }
      }
    }
  }
}

Examples

Basic Setup

// nuxt.config.ts
export default defineNuxtConfig({
  autoApi: {
    m2m: {
      // Auto-detection enabled by default
      // Only customize labels if needed
      relations: {
        articles: {
          categories: {
            label: 'Categories',
            displayField: 'name',
          },
          tags: {
            label: 'Tags',
            help: 'Add relevant tags',
          }
        }
      }
    }
  },

  autoAdmin: {
    ui: {
      editMode: 'page',  // Use page mode for M2M
    }
  }
})

Multiple Resources

autoApi: {
  m2m: {
    // Auto-detection finds all junction tables
    // Only customize labels/help as needed
    relations: {
      articles: {
        categories: {
          label: 'Categories',
        },
        tags: {
          label: 'Tags',
        },
      },
      users: {
        roles: {
          label: 'User Roles',
          help: 'Assign roles to control permissions',
        }
      },
      products: {
        categories: {
          label: 'Product Categories',
        }
      }
    }
  }
}

See Also

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.