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.

discourse.example.com/admin/plugins
SimpleReview extension
Discourse Admin › Plugins
PluginVersionStatus
discourse-chat-integration 2.4.1 Enabled
discourse-solved 1.9.0 Enabled
my-discourse-plugin 0.1.0 Error
NoMethodError: undefined method `modifyClass' on undefined — assets/javascripts/discourse/api-initializers/badge.js:7
discourse-akismet 3.1.2 Enabled
discourse-data-explorer 0.6 Disabled
SettingsLogsBackups
✓ my-discourse-plugin 0.1.0 — loaded after rebuild
All plugins healthy
Comment×
This plugin won't load — fix the api.modifyClass call?|
Fix it ✓ Done
waiting for selection…
Detected
Pluginmy-discourse-plugin
Filebadge.js:7
Fix plan
Wrap call in withPluginApi("1.6.0", api => …) · pass pluginId to modifyClass
Result
Plugin compiles · admin shows Enabled · Ember 5 deprecation cleared.
✓ Fix ready
fix: wrap modifyClass in withPluginApi
badge.js · 4 lines
SimpleReview spots Discourse plugin errors and opens a site fix — no rebuild guesswork
Got a plugin that errors after a Discourse rebuild? → SimpleReview reads the admin error trace, finds the deprecated API in your repo, and opens a one-line site fix with the 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_manager or discourse_dev), Ruby 3.3+, Node 20+, PostgreSQL 15, Redis 7. Ember CLI runs on :4200 alongside Rails on :3000.
  • Files: plugin.rb (manifest, registration, server hooks) + assets/javascripts/discourse/ (Ember client) + optional config/locales/, app/controllers/, db/migrate/.
  • Plugin lifecycle: plugin.rb top-level → after_initialize block → JS api-initializers/*.js with apiInitializer or withPluginApi.
  • Hot reload: bin/ember-cli for JS / templates / SCSS — no rebuild. ./launcher rebuild app only when plugin.rb or migrations change.
  • Testing: JS tests in test/javascripts/ run via bin/rake javascript:test. Ruby specs at plugins/<name>/spec/ run via bin/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:

EraWhat you'll see in old guidesStatus 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 versionedwithPluginApi("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
PathPurpose
plugin.rbTop-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.ymlPlugin 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:

Pitfall: forgetting 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:

ChangeWhat you runTime
JS / Ember component / handlebars template / SCSSSave file. bin/ember-cli rebuilds.~1s, browser auto-reloads
Ruby controller / model / serializer bodySave file. Next request reloads it (Rails dev mode).~2s on next page load
plugin.rb registrations / new asset pathsRestart Rails: bin/rails s.~10s
New gem in plugin.rb gem directivebundle install + Rails restart.~30s
New migration / DB schema changebin/rake db:migrate + Rails restart.~10s
Production install / app.yml changecd /var/discourse && ./launcher rebuild app2–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.reopenapi.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
Pitfall: reserved column names. Don't name a column 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:

  1. Pulls the latest Discourse base image.
  2. Clones every plugin listed in app.yml from scratch (so plugin updates are just ./launcher rebuild app).
  3. Runs bundle install and asset precompilation inside the container.
  4. Runs db:migrate, including any plugin migrations.
  5. 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:

  1. Push your plugin branch to GitHub.
  2. Install the SimpleReview Chrome extension. Open your dev or staging Discourse.
  3. If the admin shows the plugin in error state, click on the error row.
  4. Type "fix the deprecated API calls" and click Fix it.
  5. SimpleReview reads the file referenced in the error trace, finds the deprecated reopen / {{action}} / un-pluginId-ed modifyClass, 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

What language are Discourse plugins written in?
Dual-stack: Ruby on Rails for the server (controllers, models, migrations, the 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/.
Do I need to rebuild Discourse to test plugin changes?
No — for JS, hbs templates, and SCSS, 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.
What is api.modifyClass in Discourse?
The supported way to extend an existing Ember class (component, controller, route, service) from a plugin. Replaces the deprecated 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.
Where do I put server endpoints in a Discourse plugin?
Inside the plugin folder at 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.
How do I add a setting to a Discourse plugin?
Create 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.
How do I install a Discourse plugin in production?
Edit /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.
Why does my plugin work in dev but break after rebuild?
Two common causes: (1) you're using a deprecated API that 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