Using an Explicit Join Model for Ember Data Many-to-Many Associations
When you need attributes on the link between two Ember Data models, model the join explicitly with a join record, keep relationships async, and let the store keep both sides in sync.
14 Jul 2026, 19:04 UTC

Tags on posts look simple until you need to record who assigned the tag or when it was added. A pure hasMany‑hasMany link gives you no place for that metadata, forcing you to either duplicate data or manage manual state. The useful takeaway is to model the association with an explicit join record (PostTag) and keep both sides async. This gives you a dedicated place for relationship attributes, predictable loading, and automatic store consistency.
The problem: tagging with metadata
When you start tracking extra information on the link between two models, the implicit many‑to‑many pattern breaks down. Ember Data’s hasMany API works for simple navigation, but it cannot store attributes on the relationship itself without leaking concerns into the models or creating ad‑hoc solutions.
Thesis: model the join explicitly
Introduce a join model that represents the association as a first‑class resource. Post has many PostTag, Tag has many PostTag, and PostTag belongsTo both Post and Tag. This mirrors a classic join table and lets you store columns such as assignedBy, assignedAt, or a position field.
Defining the models
Define the three models with async relationships so data is fetched only when needed.
// app/models/post.js
import Model from '@ember-data/model';
import { hasMany } from '@ember-data/model';
export default class PostModel extends Model {
@hasMany('post-tag', { async: true, inverse: 'post' }) postTags;
}
// app/models/tag.js
import Model from '@ember-data/model';
import { hasMany } from '@ember-data/model';
export default class TagModel extends Model {
@hasMany('post-tag', { async: true, inverse: 'tag' }) postTags;
}
// app/models/post-tag.js
import Model from '@ember-data/model';
import { belongsTo } from '@ember-data/model';
export default class PostTagModel extends Model {
@belongsTo('post', { async: true, inverse: 'postTags' }) post;
@belongsTo('tag', { async: true, inverse: 'postTags' }) tag;
// relationship attributes live here
// assignedBy, assignedAt, etc.
}
Worked example: attaching a tag
In a route or component action you create a PostTag record, set its relationships and any attributes, then save it. The store updates both sides automatically.
// app/routes/posts/edit.js
import Route from '@ember/routing/route';
import { action } from '@ember/object';
export default class PostsEditRoute extends Route {
@action
async attachTag(post, tag) {
const store = this.store;
const postTag = store.createRecord('post-tag', {
post,
tag,
assignedBy: 'current-user',
assignedAt: new Date()
});
await postTag.save();
}
}
To run the generator for the join model, use:
ember generate model post-tag
This command creates the file app/models/post-tag.js; no special permissions are required beyond having the project checked out.
Async loading and store consistency
Because the relationships are async, accessing post.postTags triggers a separate request for the join records. Ember Data’s identity map ensures that a PostTag created on one side is instantly visible from the inverse side.
Practical check: open the Ember Inspector network tab, navigate to a post, and verify that the initial payload does not contain post‑tag data. Then inspect the post.postTags property in a template or console; you should see a subsequent GET to /post-tags?postId=. After creating a PostTag via the action above, a second request to POST /post-tags appears and the UI updates without a manual refresh.
Trade‑off: large collections
If a tag is attached to thousands of posts, loading all PostTag records for that tag can block rendering and increase memory use. Pair the join endpoint with server‑side pagination (e.g., ?page=1&per_page=50) and load only the slice needed for the current view.
Actionable closing
Start with an explicit join model, keep the relationships async, and expose only the join records you need for the current view. Verify loading behavior with the network tab and store inspection, and add pagination on the join endpoint before collections grow. This approach gives you a clean place for relationship metadata, keeps the store in sync, and avoids manual state tracking.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.