Build your first Discourse plugin in 2026 — modifyClass, hot reload & what Ember 5 changed
If you searched "discourse develop plugin" and landed on a 2022 tutorial telling you to call Discourse.TopicController.reopen(...), close the tab — that pattern was deprecated three Ember versions ago and finally stripped out during the Ember 5 migration. Here's a working walkthrough, tested against Discourse main as of 2026-04, with plugin.rb, the current api.modifyClass pattern, the dev loop with bin/ember-cli, a server-side endpoint, settings, tests, and the actual rebuild incantation.
withPluginApi / modifyClass fix — no need to grep through Discourse's source for the new signature.
TL;DR — what you actually need
- Env: Discourse dev container (
docker_managerordiscourse_dev), Ruby 3.3+, Node 20+, PostgreSQL 15, Redis 7. Ember CLI runs on:4200alongside Rails on:3000. - Files:
plugin.rb(manifest, registration, server hooks) +assets/javascripts/discourse/(Ember client) + optionalconfig/locales/,app/controllers/,db/migrate/. - Plugin lifecycle:
plugin.rbtop-level →after_initializeblock → JSapi-initializers/*.jswithapiInitializerorwithPluginApi. - Hot reload:
bin/ember-clifor JS / templates / SCSS — no rebuild../launcher rebuild apponly whenplugin.rbor migrations change. - Testing: JS tests in
test/javascripts/run viabin/rake javascript:test. Ruby specs atplugins/<name>/spec/run viabin/rspec plugins/<name>/spec.
Why plugin docs are confusing in 2026
Discourse's plugin API is older than its current Ember version by a wide margin. The official meta tutorial — "Beginner's Guide to Developing Discourse Plugins" — has been updated piecewise, but every blog post and Stack Overflow answer indexed by Google still references one of three different generations of the API:
| Era | What you'll see in old guides | Status in 2026 |
|---|---|---|
| Ember 1.x (pre-2017) | Discourse.TopicController.reopen({ … }) | Removed. Throws undefined at runtime. |
| Ember 2/3 (2018–2021) | import { default as TopicController } from "discourse/controllers/topic"; TopicController.reopen(...) | Deprecated and dropped during Ember 5 migration. |
| Plugin API 0.x (2019–2022) | withPluginApi("0.8.31", api => api.modifyClass("controller:topic", { … })) | Works, but lots of helpers gained pluginId requirement. |
| Plugin API 1.x + apiInitializer (2024–) | export default apiInitializer("1.6.0", api => { … }) | Current. What you should write. |
Two separate things break old tutorials. First, the plugin API has versioned — withPluginApi("0.8.31", ...) still mostly works, but newer hooks (sidebar sections, the new admin nav, glimmer header buttons) only exist at 1.x and require you to bump the version string. Second, the underlying Ember went 3 → 4 → 5 across 2023–2025, and that migration deleted .reopen(), made template colocation mandatory, and changed how component arguments are typed (@arg vs this.arg in templates).
If a tutorial doesn't show pluginId on its modifyClass calls, it predates Ember 4 — usable for inspiration, not for copy-paste.
Plugin anatomy — what goes where
A Discourse plugin lives in one folder under plugins/<name>/. Here's the canonical layout:
plugins/my-discourse-plugin/
├── plugin.rb # ← manifest + Ruby registrations
├── README.md
├── LICENSE
├── about.json # optional, for the plugin browser
├── config/
│ ├── settings.yml # site settings UI
│ └── locales/
│ ├── client.en.yml # JS-side translations
│ └── server.en.yml # Ruby-side translations
├── app/
│ ├── controllers/
│ │ └── my_plugin/
│ │ └── items_controller.rb
│ ├── models/
│ │ └── my_plugin_item.rb
│ └── serializers/
│ └── my_plugin_item_serializer.rb
├── db/
│ └── migrate/
│ └── 20260428120000_create_my_plugin_items.rb
├── assets/
│ ├── javascripts/discourse/
│ │ ├── api-initializers/
│ │ │ └── badge.js # ← runs once on app boot
│ │ ├── components/
│ │ │ └── my-badge.gjs # template-colocated component
│ │ └── connectors/
│ │ └── topic-above-posts/
│ │ └── my-banner.hbs # plugin-outlet override
│ └── stylesheets/
│ └── my-discourse-plugin.scss
├── spec/
│ └── requests/
│ └── items_controller_spec.rb
└── test/
└── javascripts/
└── acceptance/
└── badge-test.js
| Path | Purpose |
|---|---|
plugin.rb | Top-level manifest. Registers the plugin name, version, dependencies, asset paths, server-side hooks, and routes. |
assets/javascripts/discourse/api-initializers/ | One-shot Ember initializers that call withPluginApi or apiInitializer. Most plugins start here. |
assets/javascripts/discourse/connectors/<outlet>/ | Templates that render into named plugin outlets — the safest, lowest-coupling extension point. |
config/locales/ | i18n YAML. client.* compiles into the JS bundle, server.* stays Ruby-side. |
config/settings.yml | Plugin settings. Auto-rendered in admin, accessible as SiteSetting.foo server-side and this.siteSettings.foo client-side. |
app/models/, app/controllers/, app/serializers/ | Standard Rails MVC. Put them under a namespace folder (my_plugin/) to avoid collisions. |
db/migrate/ | ActiveRecord migrations. Run automatically on ./launcher rebuild app. |
spec/ & test/javascripts/ | Ruby and JS test suites, picked up by Discourse's main test runners. |
Hello world plugin — full code listing
Goal: render a small "Hi from plugin" badge above the topic list, controlled by a site setting. Three files. We'll build them against Plugin API 1.6.0, which is current on Discourse main as of April 2026.
1. plugin.rb — the manifest
# name: my-discourse-plugin
# about: A "Hello world" Discourse plugin demonstrating the 2026 plugin API
# version: 0.1.0
# authors: Vibers
# url: https://github.com/example/my-discourse-plugin
# required_version: 3.4.0
enabled_site_setting :my_plugin_enabled
register_asset "stylesheets/my-discourse-plugin.scss"
after_initialize do
# Server-side hooks live here.
# Anything outside `after_initialize` runs before Rails is fully booted —
# most things you want (models, serializers, route helpers) need this block.
module ::MyPlugin
PLUGIN_NAME = "my-discourse-plugin"
end
# Example: register a custom field on Topic so we can persist a flag
Topic.register_custom_field_type("my_plugin_highlighted", :boolean)
add_to_serializer(:topic_view, :my_plugin_highlighted) do
object.topic.custom_fields["my_plugin_highlighted"]
end
end
The header comments (# name:, # about:, etc.) are not Ruby comments to Discourse — they're parsed as plugin metadata and shown in the admin UI. Get them wrong and your plugin loads but the version column is blank.
2. config/settings.yml
plugins:
my_plugin_enabled:
default: true
client: true
my_plugin_badge_text:
default: "Hi from plugin"
client: true
type: string
client: true exposes the value to the JS bundle. Without it, SiteSetting lookups still work in Ruby but this.siteSettings.my_plugin_badge_text returns undefined in Ember.
3. assets/javascripts/discourse/api-initializers/badge.js
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer("1.6.0", (api) => {
const siteSettings = api.container.lookup("service:site-settings");
if (!siteSettings.my_plugin_enabled) return;
api.modifyClass(
"component:topic-list",
(Superclass) =>
class extends Superclass {
// Each modifyClass on a class needs a stable pluginId, otherwise
// Ember's HMR will warn "modifyClass called twice for X".
pluginId = "my-discourse-plugin";
get badgeText() {
return siteSettings.my_plugin_badge_text;
}
}
);
api.renderInOutlet(
"topic-list-before",
<template>
<div class="my-plugin-badge">{{@outletArgs.badgeText}}</div>
</template>
);
});
Three things worth flagging in this snippet, because every one of them changed in the last 18 months:
apiInitializerinstead ofwithPluginApi+ manual export. Same result, fewer boilerplate lines.withPluginApiis still supported and you'll see it in older code; for new files preferapiInitializer.- Class-callback signature for
modifyClass. The old object-mixin form ({ pluginId: "...", method() {...} }) still works for classic Ember classes but breaks on native classes (anything with@trackedproperties or@actiondecorators). The callback form —(Superclass) => class extends Superclass { … }— is the only signature that works for both. - Inline
<template>tag. Discourse templates are now colocated. You can either ship.gjs/.gtsfiles (template + JS in one) or use the<template>tag inside a regular.jsfile when the build is set up for it.
pluginId. Without it, every hot reload re-applies your modifyClass mixin, the console fills with "Calling modifyClass twice for the same class" warnings, and after enough reloads your dev environment gets weird (event handlers fire twice). It costs nothing and prevents a class of bugs that take an hour to track down.
The dev loop — rebuild vs hot reload
Plugin authors lose more time to "did I need to rebuild for this change?" than to any actual bug. Here's the rule, as it stands on Discourse main:
| Change | What you run | Time |
|---|---|---|
| JS / Ember component / handlebars template / SCSS | Save file. bin/ember-cli rebuilds. | ~1s, browser auto-reloads |
| Ruby controller / model / serializer body | Save file. Next request reloads it (Rails dev mode). | ~2s on next page load |
plugin.rb registrations / new asset paths | Restart Rails: bin/rails s. | ~10s |
New gem in plugin.rb gem directive | bundle install + Rails restart. | ~30s |
| New migration / DB schema change | bin/rake db:migrate + Rails restart. | ~10s |
Production install / app.yml change | cd /var/discourse && ./launcher rebuild app | 2–5 min downtime |
The local dev container's bin/ember-cli proxies API calls through to the Rails server on :3000 while serving the Ember app on :4200 with full HMR. Always develop against :4200, not :3000 — the Rails-served bundle isn't watching your files.
discourse-doctor when something refuses to load
If your plugin builds fine locally but doesn't appear in /admin/plugins after a rebuild, run:
cd /var/discourse
./discourse-doctor
The script dumps versions, container status, free disk, free RAM, and — most relevant for plugin authors — the plugin manifests it was able to parse, plus the ones that errored out. A typical output for a broken plugin looks like:
== Plugins ==
✓ discourse-chat-integration 2.4.1
✓ discourse-solved 1.9.0
✗ my-discourse-plugin ERROR — undefined local variable or method
`register_assett' for top-level
(plugin.rb:8)
✓ discourse-akismet 3.1.2
5 plugins discovered, 1 failed to load.
See /shared/log/rails/production.log for full backtrace.
If your plugin shows up in discourse-doctor but the Ember side is broken, the rails console is the next stop:
./launcher enter app
rails c
> SiteSetting.my_plugin_enabled # confirm setting registered
> Topic.last.custom_fields # confirm custom field saves
> Plugin::Instance.find_all("/var/www/discourse/plugins").map(&:name)
Common breaking patterns from older guides
If you're porting a 2020-era plugin (or following a tutorial from that era), these are the changes that bite hardest. Each one is a real Ember 5 deprecation that became a hard error.
1. Discourse.X.reopen → api.modifyClass
// ❌ BEFORE (Ember 2/3 era)
import TopicController from "discourse/controllers/topic";
TopicController.reopen({
actions: {
customAction() { this.set("foo", "bar"); }
}
});
// ✅ AFTER (current)
import { apiInitializer } from "discourse/lib/api";
import { action } from "@ember/object";
export default apiInitializer("1.6.0", (api) => {
api.modifyClass(
"controller:topic",
(Superclass) =>
class extends Superclass {
pluginId = "my-discourse-plugin";
@action
customAction() {
this.foo = "bar"; // @tracked, no .set() needed
}
}
);
});
2. this.set(...) on a tracked property
Ember 5 uses native classes with @tracked for reactivity. this.set("foo", "bar") still works on classic classes but throws "Assertion failed" on tracked ones. Just assign: this.foo = "bar".
3. Template colocation
Old: app/components/my-badge.js + app/templates/components/my-badge.hbs.
New: app/components/my-badge.hbs next to app/components/my-badge.js, or a single app/components/my-badge.gjs with <template> tag. The legacy split-folder layout silently fails to find the template after Ember 5.
4. {{action "foo"}} in templates
The {{action}} helper is gone. Replace with {{on "click" this.foo}} and decorate the JS method with @action:
{{!-- ❌ BEFORE --}}
<button {{action "submit"}}>Send</button>
{{!-- ✅ AFTER --}}
<button {{on "click" this.submit}}>Send</button>
5. Computed properties → getters
// ❌ BEFORE
import { computed } from "@ember/object";
fullName: computed("firstName", "lastName", function() {
return `${this.firstName} ${this.lastName}`;
})
// ✅ AFTER (when the deps are @tracked)
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
Adding a server-side endpoint
Most non-trivial plugins eventually need a JSON endpoint — either a custom admin tool or an API consumed by their own JS. The mechanics:
Controller: app/controllers/my_plugin/items_controller.rb
module ::MyPlugin
class ItemsController < ::ApplicationController
requires_plugin "my-discourse-plugin"
before_action :ensure_logged_in
def index
items = MyPluginItem
.where(user_id: current_user.id)
.order(created_at: :desc)
.limit(50)
render_json_dump(
items: ActiveModel::ArraySerializer.new(
items, each_serializer: MyPluginItemSerializer
).as_json
)
end
def create
item = MyPluginItem.new(
user_id: current_user.id,
body: params.require(:body)
)
if item.save
render_json_dump(item: MyPluginItemSerializer.new(item).as_json)
else
render_json_error(item)
end
end
end
end
Routes: registered from plugin.rb
after_initialize do
load File.expand_path("../app/controllers/my_plugin/items_controller.rb", __FILE__)
load File.expand_path("../app/models/my_plugin_item.rb", __FILE__)
load File.expand_path("../app/serializers/my_plugin_item_serializer.rb", __FILE__)
Discourse::Application.routes.append do
namespace :my_plugin, path: "/my-plugin", defaults: { format: :json } do
resources :items, only: %i[index create]
end
end
end
Test it the simplest way:
curl -b cookies.txt http://localhost:3000/my-plugin/items.json
# → {"items":[{"id":1,"body":"first","created_at":"2026-04-28..."}]}
render_json_dump wraps the response with the consistent envelope Discourse uses elsewhere and sets the right content-type. Always inherit from ::ApplicationController (the leading :: escapes your MyPlugin namespace), and always call requires_plugin — it returns 404 if the plugin is disabled, instead of leaking your endpoint.
Adding a model and migration
Migration: db/migrate/20260428120000_create_my_plugin_items.rb
class CreateMyPluginItems < ActiveRecord::Migration[7.1]
def change
create_table :my_plugin_items do |t|
t.integer :user_id, null: false
t.text :body, null: false
t.boolean :archived, default: false, null: false
t.timestamps
end
add_index :my_plugin_items, :user_id
add_index :my_plugin_items, [:user_id, :archived]
end
end
Model: app/models/my_plugin_item.rb
class ::MyPluginItem < ActiveRecord::Base
self.table_name = "my_plugin_items"
belongs_to :user
validates :body, presence: true, length: { maximum: 5_000 }
scope :active, -> { where(archived: false) }
end
type (Rails STI), read, order, or group — they shadow ActiveRecord internals and produce weird PostgreSQL errors at write time. Stick to descriptive names like item_kind or read_at. Also avoid clashing with Discourse's well-known fields: topic_id, user_id, post_id are fine and conventional, but category_id implicitly enables association magic that can surprise you.
Persisting plugin settings
Site settings are the cleanest way to expose configuration. Anything you put in config/settings.yml appears in the admin under Settings → Plugins → my-discourse-plugin automatically — no UI work required.
plugins:
my_plugin_enabled:
default: true
client: true
my_plugin_max_items_per_user:
default: 50
type: integer
min: 1
max: 1000
client: false
my_plugin_allowed_groups:
default: ""
type: group_list
client: true
my_plugin_excerpt_length:
default: 280
type: integer
my_plugin_api_key:
default: ""
secret: true # ← masks the value in the admin UI
Read them server-side:
if SiteSetting.my_plugin_enabled
max = SiteSetting.my_plugin_max_items_per_user
groups = SiteSetting.my_plugin_allowed_groups_map
# …
end
And client-side, in any Ember component:
import Component from "@glimmer/component";
import { service } from "@ember/service";
export default class MyBadge extends Component {
@service siteSettings;
get visible() {
return this.siteSettings.my_plugin_enabled;
}
}
secret: true is the right setting type for API keys and webhook secrets — it masks the value in the admin UI and excludes it from the JS bundle even if you set client: true.
Testing your plugin
Discourse's test runners pick up plugin tests automatically as long as you put them in the right place. Two suites:
Ruby specs
# plugins/my-discourse-plugin/spec/requests/items_controller_spec.rb
require "rails_helper"
describe MyPlugin::ItemsController do
fab!(:user)
before { sign_in(user) }
describe "POST /my-plugin/items" do
it "creates an item for the current user" do
expect {
post "/my-plugin/items.json", params: { body: "hello" }
}.to change { MyPluginItem.count }.by(1)
expect(response.status).to eq(200)
expect(MyPluginItem.last.user_id).to eq(user.id)
end
it "rejects empty body" do
post "/my-plugin/items.json", params: { body: "" }
expect(response.status).to eq(422)
end
end
end
Run with:
bin/rspec plugins/my-discourse-plugin/spec
# or a single file
bin/rspec plugins/my-discourse-plugin/spec/requests/items_controller_spec.rb
JavaScript acceptance tests
// plugins/my-discourse-plugin/test/javascripts/acceptance/badge-test.js
import { test } from "qunit";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { visit } from "@ember/test-helpers";
import { assert } from "qunit";
acceptance("My plugin badge", function (needs) {
needs.user();
needs.settings({
my_plugin_enabled: true,
my_plugin_badge_text: "Hi from plugin",
});
test("renders the badge above the topic list", async function (assert) {
await visit("/latest");
assert
.dom(".my-plugin-badge")
.hasText("Hi from plugin", "badge appears with configured text");
});
});
Run JS tests headless:
bin/rake javascript:test:plugins[my-discourse-plugin]
# or open http://localhost:4200/tests in a browser for live debugging
Shipping and installing
Discourse plugins ship as git repositories — there's no package registry, no npm publish, no plugin store. Push your plugin to GitHub (public or private + deploy key), then point your production container at it:
# /var/discourse/containers/app.yml — partial
hooks:
after_code:
- exec:
cd: $home/plugins
cmd:
- git clone https://github.com/discourse/docker_manager.git
- git clone https://github.com/example/my-discourse-plugin.git
# Pin to a specific tag/commit if you don't want surprises:
# - git clone -b v0.1.0 https://github.com/example/my-discourse-plugin.git
Then rebuild:
cd /var/discourse
./launcher rebuild app
The rebuild step:
- Pulls the latest Discourse base image.
- Clones every plugin listed in
app.ymlfrom scratch (so plugin updates are just./launcher rebuild app). - Runs
bundle installand asset precompilation inside the container. - Runs
db:migrate, including any plugin migrations. - Stops the running container, starts the new one.
Expect 2–5 minutes of downtime per rebuild on a default 2 GB droplet — longer if your plugin pulls a heavy gem. The image roughly doubles in size for every C-extension gem you add (nokogiri, image_processing, anything with oj), so think twice before reaching for one. If you can do it in pure Ruby, do it in pure Ruby.
Multi-container setups (separate web + data containers) need the rebuild on the web container only — data is untouched.
When the rebuild fails
The number-one cause of a failed rebuild on a previously-working site is a plugin pinning to a Discourse version it no longer supports. The # required_version: 3.4.0 header in plugin.rb is checked at load time; if your Discourse base just upgraded past the plugin's max version (or below its min), the plugin halts the boot. Check ./launcher logs app for:
Plugin "my-discourse-plugin" requires Discourse version >= 3.4.0
but the current version is 3.3.2. Plugin will not be loaded.
Either downgrade the plugin (pin the git URL to an older tag) or upgrade Discourse first.
When to hand it off to AI
Plugin development on Discourse has a high "is this still the way?" cost — APIs migrate, the docs lag, and a lot of the institutional knowledge lives in Meta forum threads. SimpleReview closes that loop:
- Push your plugin branch to GitHub.
- Install the SimpleReview Chrome extension. Open your dev or staging Discourse.
- If the admin shows the plugin in error state, click on the error row.
- Type "fix the deprecated API calls" and click Fix it.
- SimpleReview reads the file referenced in the error trace, finds the deprecated
reopen/{{action}}/ un-pluginId-edmodifyClass, and prepares a fix with the current-API replacement. You review the diff, merge, rebuild.
Useful when you're porting an old plugin you didn't write, when a Discourse upgrade just broke five plugins at once, or when you don't want to spend an afternoon mapping every {{action}} in a 2 000-line template tree.
Stop guessing which Discourse API got renamed this quarter
SimpleReview reads your plugin error, finds the deprecated call, and prepares a fix with the current-API fix.
Install SimpleReview Chrome Extension →Want it in your repo CI instead? Vibers — Human-in-the-loop Code Review → looks at every change before it lands.
Frequently Asked Questions
plugin.rb manifest) and Ember.js for the client (JS files under assets/javascripts/discourse/ using withPluginApi or apiInitializer). Most plugins also include SCSS in assets/stylesheets/ and translations in config/locales/.bin/ember-cli hot-reloads on save. ./launcher rebuild app is only needed for plugin.rb changes, new gems, or new migrations. For pure Ruby controller/model edits, the Rails dev reloader picks them up on the next request.Discourse.SomeClass.reopen pattern. Call it inside withPluginApi / apiInitializer, pass an identifier like "component:topic-list-item", and supply a class-callback whose pluginId field prevents duplicate-application warnings under HMR.app/controllers/<plugin_name>/items_controller.rb. Register routes in plugin.rb with Discourse::Application.routes.append { ... } using standard Rails routing DSL. Inherit from ::ApplicationController, call requires_plugin "my-discourse-plugin" in a before_action, and use render_json_dump to serialise.config/settings.yml with the setting name, default value, type, and an optional client: true flag if the JS bundle should see it. Read it from Ruby with SiteSetting.my_plugin_enabled and from JS with this.siteSettings.my_plugin_enabled. The setting appears automatically under Admin → Settings → Plugins./var/discourse/containers/app.yml, add the plugin's git URL under hooks: after_code:, then run cd /var/discourse && ./launcher rebuild app. Expect 2–5 minutes of downtime. To update later, just rebuild — the git URL is fetched fresh each time. Pin a branch or tag in app.yml if you want stability.bin/ember-cli silently shimmed in dev — check the Discourse main CHANGELOG for removed APIs and run bin/rake javascript:test:plugins; (2) your plugin.rb references a file that wasn't committed, or a gem you only installed locally. Always test via ./launcher rebuild on a staging container before pointing production at it.Related Discourse guides
Sources
- meta.discourse.org — Beginner's guide to developing Discourse plugins (part 1)
- meta.discourse.org — #dev:plugin category (recent breaking changes)
- github.com/discourse/discourse — bundled plugin examples
- meta.discourse.org — Install plugins in Discourse (production)
- github.com/discourse/discourse — CHANGELOG (Ember 5 migration notes)
- emberjs.com — Ember 5 release announcement