Developing Discourse Plugins - Part 1 - Create a basic plugin

Building a plugin in Discourse can be really simple, once you learn a couple of quirks. The goal of this post is to create a skeleton plugin and introduce you to the basics.

Your development environment

Make sure you have a development environment of Discourse running on your computer. I recommend you use the appropriate setup guide and come back when you’re done.


:tada: Use GitHub - discourse/discourse-plugin-skeleton: Template for Discourse plugins to create a complete discourse plugin skeleton in your plugins directory :tada:

When Discourse starts up, it looks in the plugins directory for subdirectories containing a plugin.rb file. The plugin.rb file has two purposes: it is the manifest for your plugin with the required information about your plugin including: its name, contact information and a description. The second purpose is to initialize any ruby code necessary to run your plugin.

In our case, we won’t be adding any ruby code but we still need the plugin.rb. Let’s create the directory basic-plugin with the file plugin.rb inside it, with the following contents:


# name: basic-plugin
# about: A super simple plugin to demonstrate how plugins work
# version: 0.0.1
# authors: Awesome Plugin Developer
# url:

Once you’ve created this file, you should restart your local server and the plugin should be loaded.

An important Gotcha!

If you’re used to regular rails development you might notice that plugins aren’t quite as nice when it comes to reloading. In general, when you make changes to your plugin, you should Ctrl+c the server to stop it running, then run it again using bin/ember-cli -u.

My changes weren’t picked up! :warning:

Sometimes the cache isn’t cleared fully, especially when you create new files or delete old files. To get around this issue, remove your tmp folder and start rails again. On a mac you can do it in one command: rm -rf tmp; bin/ember-cli -u.

Checking that your plugin was loaded

Once you’ve restarted your local server, visit the url /admin/plugins (make sure you’re logged in as an admin account first, as only admins can see the plugin registry).

If everything worked, you should see your plugin in the list:

Congratulations, you just created your first plugin!

Let’s add some Javascript

Right now your plugin doesn’t do anything. Let’s add a javascript file that will pop up an alert box when discourse loads. This will be super annoying to any user and is not recommended as an actual plugin, but will show how to insert Javascript into our running application.

Create the following file:


export default {
  name: 'alert',
  initialize() {
    alert('alert boxes are annoying!');

Now if you restart your local server, you should see “alert boxes are annoying!” appear on the screen. (If you did not, see the “My Changes weren’t picked up” heading above).

Let’s step through how this worked:

  1. Javascript files placed in assets/javascripts/discourse/initializers are executed automatically when the Discourse application loads up.

  2. This particular file exports one object, which has a name and an initialize function.

  3. The name has to be unique, so I just called it alert.

  4. The initialize() function is called when the application loads. In our case, all it does is execute our alert() code.

You’re now an official Discourse plugin developer!

More in the series

Part 1: This topic
Part 2: Plugin Outlets
Part 3: Site Settings
Part 4: git setup
Part 5: Admin interfaces
Part 6: Acceptance tests
Part 7: Publish your plugin

Plugin API documentations?
Connecting to database with a plugin
How to create a new plugins?
How do you learn to build Discourse plugins?
Separating View from Data, Plugins, Modularity
Categories Topic Replies
Overwriting controllers in plugin
How do you learn to build Discourse plugins?
Create fully custom header
Added custom initializer
How can I add "dislike" button?
(Superseded) Plugin Tutorial #1 - How to manipulate the text in the composer?
Which files can you override from a plugin?
Customizing handlebars templates
How to properly display jalali dates for Persian language
Topic Ratings Plugin
Enable Like-Button for not logged-in visitors
Why do I need a block storage?
Custom Field not working
Rails plugin generator
Why GNU License?
Code reading help. ES6, plugin-outlet `_connectorCache`, custom-html `_customizations`, appEvents
Customizing handlebars templates
Application Files after Digital Ocean Setup
Application Files after Digital Ocean Setup
Application Files after Digital Ocean Setup
Make an external http request from discourse
Specify user by external id
How can I make my own Discourse plugins?
Reputation and level on member profile
How to Change User Profile Picture
Dev Category sidebar
Something like shortcodes for Discourse?
Calendar plugin features to make it really useful for us
Plugin Documentation Style Guide
Any options for over-riding the username restrictions?
How would updating effect custom overrides?
I would like to change the template completely
Adding a second tag description
Rails plugin generator
Creating a plugin
Fatal: Not a git repository (or any parent up to mount point /discourse)
Fatal: Not a git repository (or any parent up to mount point /discourse)
Adding command line tools support for user api keys
Discourse Code Modification for personal use
Plugin routing problems
[PAID] Mentorship needed
Plugin API documentations?
Insert tag based upon word match
Place for total coding beginners to learn how to customise?
Custom navigation way Classified fillter
Add additional button post menu
The Ignore User feature (now in 2.3.0.beta7)
Create Custom APIs
How to make only a group of users can send messages to staff
How might we better structure #howto?
Theme-Component v Plugin: What's the difference
Creating Parsing Extensions
Ruby script to send automatic welcome message to new users
How can I customized my own site
All latest images in posts from a category
Discourse Encrypt (for Private Messages)
Discourse Encrypt (for Private Messages)
(Ruby) Selecting only posts that are made by non-original posters
Notification when added to a group
Hosting discourse on our infrastructure AWS
Display FullName instead of UserName in the Profile
Calling Python scripts w/ arguments on the backend via plugin?
How to add custom fields to models
Bot writer's tip: Processing every post
Add user to group after purchase
Support for proposed plug-in
Autocompletion for a user field at signup
Custom discourse with limited features
Overriding user_guardian.rb in a plugin (no fork necessary!)
Problems with git clone
Friendly user profile url
Website Integration
Discourse developement environment setup
Rake task "assets:precompile" is failing due to JS Compilation issue
Change code does not work in VS Studio code editor
Edit Code
Discourse Chain Topics Plugin
Install Discourse on Ubuntu for Development
(Superseded) Plugin Tutorial #1 - How to manipulate the text in the composer?
(Superseded) Plugin tutorial #3 - How to add a button after every post?
Contributing to Discourse
How to edit topics/show.html.erb
Install Discourse on macOS for development
Store pdf and doc files as raw text in the database - Where to start?
Developing Discourse Plugins - Part 3 - Add custom site settings
Developing Discourse Plugins - Part 4 - Setup git
Developing Discourse Plugins - Part 7 - Publish your plugin
Developing Discourse Plugins - Part 5 - Add an admin interface
Developing Discourse Plugins - Part 6 - Add acceptance tests
Discourse Development Contributor Guidelines
Learn how to start building stuff for Discourse if you're newbie (like myself)
Developing Discourse Plugins - Part 2 - Connect to a plugin outlet
Customizing CSS via plugin
About the Plugin category
About the Plugin category
Visual diagraming to add visual dimension to Conversations?
Custom Onebox engine
Allow reply-to individual instead of topic/forum (mailing list feature)
Adapt github changes to my own site
Push to digital ocean from command line and rebuild
How to convince my university to use/install Discourse?
Hack to enable invitations for Trust level 1 users
Dropcaps in Discourse - cannot override span tag
Getting certain posts to Zapier
Plugin: add a menu icon (next to search)
Error When creating custom plugin
Error When creating custom plugin
IOTA as a currency for Discourse
New columns in directory (/users)
Bootstrap fails when plugin file specifies gem version range
Running my own discourse image
How to add a job to cron/anacron when creating Docker container
How to transfer data from plugin to app/views templates?
Which is Better? Discourse or Flarum?
Hide features for non-admin users through plugin

I am able to create plugin in development, it works fine. Thanks.
But I don’t see a way to install other’s plugin in development
So first question is how can I fork someone’s plugin in development and then build on top of that(hope copy paste that repo is not the recommended way)?
Second question is, it seems my created plugin is part of my repo, is there a way to create a separate github repo for the plugin automatically which others can directly use to install?

I have a preferred method to do this. First, I have a ~/code directory where I have all my git projects. So I check out discourse and the plugin in that directory so it’s something like this:



Then, within the ~/code/discourse/plugins folder I create a symlink to the plugin:

$ cd ~/code/discourse/plugins
$ ln -s ~/code/discourse-some-plugin .

Then for good measure:

$ cd ~/code/discourse
$ rm -rf tmp
$ bundle exec rails server

Now you can fork the plugin in ~/code/discourse-some-plugin, make pull requests or whatever you want. It’ll be used by discourse.


What is the best way to render ajax response inside a widget?
I was able to console.log the ajax response, but it is not rendering inside the widget.

Tried to call this.scheduleRerender() after getting ajax response but both results in an infinite loop.

1 Like

I am getting following error when trying to load a helper module inside my widget

Could you please help me to resolve this error?

You should make sure your widget can contain state. After the ajax request, set it on the state object and trigger a this.scheduleRerender and it should appear. For an example look at how the post menu shows who liked something.

Looks like you have the wrong path to your helper. One way to see all the paths that Discourse has resolved is by typing require._eak_seen in your console. Look for the correct path name.

Thanks Robin Ward for your help. I was able to load my module after investigating with require._eak_seen.

Below is the code for my widget. The problem is that this.scheduleRerender() is causing an infinite loop. The div always shows loading animation (even without this.scheduleRerender) which seems this.state.loading is not being set.

import { createWidget } from 'discourse/widgets/widget';
import { getTopic } from 'discourse/plugins/my-plugin/discourse/helpers/topics';
import { ajax } from 'discourse/lib/ajax';
import { h } from 'virtual-dom';

export default createWidget('topic-widget', {
  tagName: '',
  defaultState() {
    return { loading: false};

  refreshTopic() {
    if (this.state.loading) { return; }
    this.state.loading = true;
    this.state.topic = 'empty';
    getTopic(this).then((result) => {
      this.state.topic = result;
      this.state.loading = false;

  html(attrs, state) {
    if (!state.topic) {
    const result = [];
    if (state.loading) {
      result.push(h('div.spinner-container', h('div.spinner')));
    } else if (state.topic !== 'empty') {
    } else {
      result.push(h('', 'No topic.'))

    return result;

Could you help to resolve this error?

Your code looks mostly fine, however, all widgets that deal with state require a key attribute. You should have seen a warning about this, although maybe the warning only appears when running tests?

Try adding a buildKey function like buildKey: () => 'topic-widget'.

That should probably fix it. Also, a much less serious issue is you might want to add topic: null to your state object. Javascript is much faster when it knows the shape of the object in advance.


Thanks Robin Ward.
Adding buildKey function worked fine for me.

1 Like

@eviltrout I got bitten by this in development and didn’t get a warning about setting a key attribute.


Ah, it only warns in Ember.testing:

We might want to just raise an error if that happens now? It is pretty dangerous for Widgets to do that.


:thumbsup: for raising an error instead. Maybe we can extend that to development too? :thought_balloon:


Hopefully this will prevent others from making this mistake:



Is this url correct? Shouldn’t this be ,

I’m new here. please correct me if im wrong :slight_smile:

In Discourse, mainly there are two big JavaScript sections “discourse” and “admin”. Also you can see few more in

In plugins, to differentiate admin & normal user section JavaScript files we use “discourse” and “admin” keywords in between like below


Also it will just work even without identification keywords like you mentioned :slight_smile:


@vinothkannans Thank you a lot for the explanation. :slight_smile:

I’m experiencing the same issue with getting symlinks to work (I’m on Linux) - the ln -s appears to work, but from inside the docker container the path is inaccessible. The plugin works when it is directly copied into the plugins folder, but I like the symlink workflow as it enables a more sensible Git arrangment.

This SO question appears to suggest that symlinks won’t work this way unless we also add the linked-to plugin volume to the docker run command Mount host directory with a symbolic link inside in docker container - Stack Overflow

Anyone any thoughts on this? What are the pro plugin developers doing for a sensible Git workflow?


I’m using symlinks but my dev setup is Docker-frei.

Symlinks are a really nice way of being able to include or exclude plugins on a ‘build’ very quickly without disturbing the codebase.


Here’s what I’ve had to do in order to preserve a similar workflow to using symlinks (softlinks):

I edited /bin/docker/boot_dev in Discourse (outside the container) so that the plugin I want to work on is added as a volume and mounted in the /src/plugins/ directory (inside the container).

In my case it looks like this:

docker run -d -p 9405:9405 -p 1080:1080 -p 3000:3000 -p 9292:9292 \
-v "$DATA_DIR:/shared/postgres_data:delegated" \
-v "$SOURCE_DIR:/src:delegated" \ 
-v "/home/marcus/code/discourse/discourse-reflective-learning-plugin:/src/plugins/discourse-reflective-learning-plugin" \
$ENV_ARGS --hostname=discourse --name=discourse_dev --restart=always \
discourse/discourse_dev:release /sbin/boot

I’ve used absolute paths because I couldn’t be bothered to get into relative paths inside Docker and $SOURCE_DIR environment variables, but I’m sure there’s a cleverer way to do this, so that perhaps all your plugins could be in directories at the same level as discourse and it would automagically include them all. Hope this helps someone.


I am totally open to a PR that automatically follows symlinks in plugins/ dir and then smart mounts volumes.