diff --git a/app/assets/javascripts/discourse/app/components/table-header-toggle.js b/app/assets/javascripts/discourse/app/components/table-header-toggle.js
index afe7ef32eb3..424879f546b 100644
--- a/app/assets/javascripts/discourse/app/components/table-header-toggle.js
+++ b/app/assets/javascripts/discourse/app/components/table-header-toggle.js
@@ -9,6 +9,7 @@ export default Component.extend({
   chevronIcon: null,
   columnIcon: null,
   translated: false,
+  automatic: false,
   onActiveRender: null,
 
   toggleProperties() {
@@ -31,6 +32,9 @@ export default Component.extend({
   },
   didReceiveAttrs() {
     this._super(...arguments);
+    if (!this.automatic && !this.translated) {
+      this.set("labelKey", this.field);
+    }
     this.set("id", `table-header-toggle-${this.field.replace(/\s/g, "")}`);
     this.toggleChevron();
   },
diff --git a/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js b/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js
index a7fd826af8b..771d5b9b306 100644
--- a/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js
+++ b/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js
@@ -14,7 +14,7 @@ export default Controller.extend(ModalFunctionality, {
   labelKey: null,
 
   onShow() {
-    ajax("directory-columns.json")
+    ajax("edit-directory-columns.json")
       .then((response) => {
         this.setProperties({
           loading: false,
@@ -35,7 +35,7 @@ export default Controller.extend(ModalFunctionality, {
       ),
     };
 
-    ajax("directory-columns.json", { type: "PUT", data })
+    ajax("edit-directory-columns.json", { type: "PUT", data })
       .then(() => {
         reload();
       })
@@ -58,7 +58,7 @@ export default Controller.extend(ModalFunctionality, {
       .forEach((column, index) => {
         column.setProperties({
           position: column.automatic_position || index + 1,
-          enabled: column.automatic,
+          enabled: column.type === "automatic",
         });
       });
     this.set("columns", resetColumns);
diff --git a/app/assets/javascripts/discourse/app/controllers/users.js b/app/assets/javascripts/discourse/app/controllers/users.js
index e2878823ae4..1846b4eea3f 100644
--- a/app/assets/javascripts/discourse/app/controllers/users.js
+++ b/app/assets/javascripts/discourse/app/controllers/users.js
@@ -28,13 +28,22 @@ export default Controller.extend({
     this.set("nameInput", params.name);
     this.set("order", params.order);
 
-    const custom_field_columns = this.columns.filter((c) => !c.automatic);
-    const user_field_ids = custom_field_columns
-      .map((c) => c.user_field_id)
-      .join("|");
+    const userFieldColumns = this.columns.filter(
+      (c) => c.type === "user_field"
+    );
+    const userFieldIds = userFieldColumns.map((c) => c.user_field_id).join("|");
+
+    const pluginColumns = this.columns.filter((c) => c.type === "plugin");
+    const pluginColumnIds = pluginColumns.map((c) => c.id).join("|");
 
     return this.store
-      .find("directoryItem", Object.assign(params, { user_field_ids }))
+      .find(
+        "directoryItem",
+        Object.assign(params, {
+          user_field_ids: userFieldIds,
+          plugin_column_ids: pluginColumnIds,
+        })
+      )
       .then((model) => {
         const lastUpdatedAt = model.get("resultSetMeta.last_updated_at");
         this.setProperties({
diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js b/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js
new file mode 100644
index 00000000000..1007a506b7f
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js
@@ -0,0 +1,37 @@
+import { htmlSafe } from "@ember/template";
+import { number } from "discourse/lib/formatter";
+import { registerUnbound } from "discourse-common/lib/helpers";
+import I18n from "I18n";
+
+registerUnbound("mobile-directory-item-label", function (args) {
+  // Args should include key/values { item, column }
+  const count = args.item.get(args.column.name);
+  return htmlSafe(I18n.t(`directory.${args.column.name}`, { count }));
+});
+
+registerUnbound("directory-item-value", function (args) {
+  // Args should include key/values { item, column }
+  return htmlSafe(
+    `<span class='number'>${number(args.item.get(args.column.name))}</span>`
+  );
+});
+
+registerUnbound("directory-item-user-field-value", function (args) {
+  // Args should include key/values { item, column }
+  const value =
+    args.item.user && args.item.user.user_fields
+      ? args.item.user.user_fields[args.column.user_field_id]
+      : null;
+  const content = value || "-";
+  return htmlSafe(`<span class='user-field-value'>${content}</span>`);
+});
+
+registerUnbound("directory-column-is-automatic", function (args) {
+  // Args should include key/values { column }
+  return args.column.type === "automatic";
+});
+
+registerUnbound("directory-column-is-user-field", function (args) {
+  // Args should include key/values { column }
+  return args.column.type === "user_field";
+});
diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-label.js b/app/assets/javascripts/discourse/app/helpers/directory-item-label.js
deleted file mode 100644
index 56723ee716e..00000000000
--- a/app/assets/javascripts/discourse/app/helpers/directory-item-label.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import { htmlSafe } from "@ember/template";
-import { registerUnbound } from "discourse-common/lib/helpers";
-import I18n from "I18n";
-
-export default registerUnbound("mobile-directory-item-label", function (args) {
-  // Args should include key/values { item, column }
-
-  const count = args.item.get(args.column.name);
-  return htmlSafe(I18n.t(`directory.${args.column.name}`, { count }));
-});
diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js b/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js
deleted file mode 100644
index aeab4bcbe12..00000000000
--- a/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { htmlSafe } from "@ember/template";
-import { registerUnbound } from "discourse-common/lib/helpers";
-
-export default registerUnbound(
-  "directory-item-user-field-value",
-  function (args) {
-    // Args should include key/values { item, column }
-
-    const value =
-      args.item.user && args.item.user.user_fields
-        ? args.item.user.user_fields[args.column.user_field_id]
-        : null;
-    const content = value || "-";
-    return htmlSafe(`<span class='user-field-value'>${content}</span>`);
-  }
-);
diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-value.js b/app/assets/javascripts/discourse/app/helpers/directory-item-value.js
deleted file mode 100644
index a3c6e3d6d38..00000000000
--- a/app/assets/javascripts/discourse/app/helpers/directory-item-value.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { htmlSafe } from "@ember/template";
-import { registerUnbound } from "discourse-common/lib/helpers";
-import { number } from "discourse/lib/formatter";
-
-export default registerUnbound("directory-item-value", function (args) {
-  // Args should include key/values { item, column }
-
-  return htmlSafe(
-    `<span class='number'>${number(args.item.get(args.column.name))}</span>`
-  );
-});
diff --git a/app/assets/javascripts/discourse/app/routes/users.js b/app/assets/javascripts/discourse/app/routes/users.js
index 181204d7864..ed1ac2cf84b 100644
--- a/app/assets/javascripts/discourse/app/routes/users.js
+++ b/app/assets/javascripts/discourse/app/routes/users.js
@@ -1,6 +1,7 @@
 import DiscourseRoute from "discourse/routes/discourse";
 import I18n from "I18n";
-import PreloadStore from "discourse/lib/preload-store";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
 import { Promise } from "rsvp";
 
 export default DiscourseRoute.extend({
@@ -38,9 +39,12 @@ export default DiscourseRoute.extend({
   },
 
   model(params) {
-    const columns = PreloadStore.get("directoryColumns");
-    params.order = params.order || columns[0].name;
-    return { params, columns };
+    return ajax("directory-columns.json")
+      .then((response) => {
+        params.order = params.order || response.directory_columns[0].name;
+        return { params, columns: response.directory_columns };
+      })
+      .catch(popupAjaxError);
   },
 
   setupController(controller, model) {
diff --git a/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs b/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs
index 0d4fece4112..b1b083beda4 100644
--- a/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs
+++ b/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs
@@ -1,10 +1,10 @@
 <td>{{user-info user=item.user}}</td>
 {{#each columns as |column|}}
   <td>
-    {{#if column.automatic}}
-      {{directory-item-value item=item column=column}}
-    {{else}}
+    {{#if (directory-column-is-user-field column=column)}}
       {{directory-item-user-field-value item=item column=column}}
+    {{else}}
+      {{directory-item-value item=item column=column}}
     {{/if}}
   </td>
 {{/each}}
diff --git a/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs b/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs
index 1aafa640cc0..a646d794db6 100644
--- a/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs
+++ b/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs
@@ -7,6 +7,7 @@
         icon=column.icon
         order=order
         asc=asc
+        automatic=(directory-column-is-automatic column=column)
         translated=column.user_field_id
         onActiveRender=setActiveHeader
       }}
diff --git a/app/assets/javascripts/discourse/app/templates/group-index.hbs b/app/assets/javascripts/discourse/app/templates/group-index.hbs
index d5a7770478b..abe17a0f2c4 100644
--- a/app/assets/javascripts/discourse/app/templates/group-index.hbs
+++ b/app/assets/javascripts/discourse/app/templates/group-index.hbs
@@ -35,11 +35,11 @@
                 {{d-button action=(action "bulkClearAll") label="topics.bulk.clear_all"}}
               </th>
             {{/if}}
-            {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username" class="username"}}
+            {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username" class="username" automatic=true}}
             <th class="group-owner"></th>
-            {{table-header-toggle order=order asc=asc field="added_at" labelKey="groups.member_added"}}
-            {{table-header-toggle order=order asc=asc field="last_posted_at" labelKey="last_post"}}
-            {{table-header-toggle order=order asc=asc field="last_seen_at" labelKey="last_seen"}}
+            {{table-header-toggle order=order asc=asc field="added_at" labelKey="groups.member_added" automatic=true}}
+            {{table-header-toggle order=order asc=asc field="last_posted_at" labelKey="last_post" automatic=true}}
+            {{table-header-toggle order=order asc=asc field="last_seen_at" labelKey="last_seen" automatic=true}}
             <th>
               {{#if isBulk}}
                 {{group-member-dropdown
diff --git a/app/assets/javascripts/discourse/app/templates/group-requests.hbs b/app/assets/javascripts/discourse/app/templates/group-requests.hbs
index 017aaaef083..b2f8d03ddfb 100644
--- a/app/assets/javascripts/discourse/app/templates/group-requests.hbs
+++ b/app/assets/javascripts/discourse/app/templates/group-requests.hbs
@@ -12,8 +12,8 @@
     {{#load-more selector=".group-members tr" action=(action "loadMore")}}
       <table class="group-members">
         <thead>
-          {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username"}}
-          {{table-header-toggle order=order asc=asc field="requested_at" labelKey="groups.member_requested"}}
+          {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username" automatic=true}}
+          {{table-header-toggle order=order asc=asc field="requested_at" labelKey="groups.member_requested" automatic=true}}
           <th>{{i18n "groups.requests.reason"}}</th>
           <th></th>
           <th></th>
diff --git a/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs b/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs
index e43ad11e263..3f75ed72788 100644
--- a/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs
+++ b/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs
@@ -1,7 +1,7 @@
 {{user-info user=item.user}}
 
 {{#each columns as |column|}}
-  {{#if column.automatic}}
+  {{#if (directory-column-is-automatic column=column)}}
     <div class="user-stat">
       <span class="value">
         {{directory-item-value item=item column=column}}
diff --git a/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs b/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs
index fb3e86465eb..7fe81183a0b 100644
--- a/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs
+++ b/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs
@@ -8,10 +8,12 @@
           <div class="left-content">
             <label class="column-name">
               {{input type="checkbox" checked=column.enabled}}
-              {{#if column.automatic}}
+              {{#if (directory-column-is-automatic column=column)}}
                 {{directory-table-header-title field=column.name labelKey=labelKey icon=column.icon}}
-              {{else}}
+              {{else if (directory-column-is-user-field column=column)}}
                 {{directory-table-header-title field=column.user_field.name translated=true}}
+              {{else}}
+                {{directory-table-header-title field=(i18n column.name) translated=true}}
               {{/if}}
             </label>
           </div>
diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js
index b312f02e25a..78f3c8cd90b 100644
--- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js
+++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js
@@ -938,13 +938,13 @@ export function applyDefaultHandlers(pretender) {
     return [404, { "Content-Type": "application/html" }, ""];
   });
 
-  pretender.get("directory-columns.json", () => {
+  pretender.get("edit-directory-columns.json", () => {
     return response(200, {
       directory_columns: [
         {
           id: 1,
           name: "likes_received",
-          automatic: true,
+          type: "automatic",
           enabled: true,
           automatic_position: 1,
           position: 1,
@@ -954,7 +954,7 @@ export function applyDefaultHandlers(pretender) {
         {
           id: 2,
           name: "likes_given",
-          automatic: true,
+          type: "automatic",
           enabled: true,
           automatic_position: 2,
           position: 2,
@@ -964,7 +964,7 @@ export function applyDefaultHandlers(pretender) {
         {
           id: 3,
           name: "topic_count",
-          automatic: true,
+          type: "automatic",
           enabled: true,
           automatic_position: 3,
           position: 3,
@@ -974,7 +974,7 @@ export function applyDefaultHandlers(pretender) {
         {
           id: 4,
           name: "post_count",
-          automatic: true,
+          type: "automatic",
           enabled: true,
           automatic_position: 4,
           position: 4,
@@ -984,7 +984,7 @@ export function applyDefaultHandlers(pretender) {
         {
           id: 5,
           name: "topics_entered",
-          automatic: true,
+          type: "automatic",
           enabled: true,
           automatic_position: 5,
           position: 5,
@@ -994,7 +994,7 @@ export function applyDefaultHandlers(pretender) {
         {
           id: 6,
           name: "posts_read",
-          automatic: true,
+          type: "automatic",
           enabled: true,
           automatic_position: 6,
           position: 6,
@@ -1004,7 +1004,7 @@ export function applyDefaultHandlers(pretender) {
         {
           id: 7,
           name: "days_visited",
-          automatic: true,
+          type: "automatic",
           enabled: true,
           automatic_position: 7,
           position: 7,
@@ -1014,7 +1014,7 @@ export function applyDefaultHandlers(pretender) {
         {
           id: 9,
           name: null,
-          automatic: false,
+          type: "user_field",
           enabled: false,
           automatic_position: null,
           position: 8,
@@ -1035,4 +1035,75 @@ export function applyDefaultHandlers(pretender) {
       ],
     });
   });
+
+  pretender.get("directory-columns.json", () => {
+    return response(200, {
+      directory_columns: [
+        {
+          id: 1,
+          name: "likes_received",
+          type: "automatic",
+          position: 1,
+          icon: "heart",
+          user_field: null,
+        },
+        {
+          id: 2,
+          name: "likes_given",
+          type: "automatic",
+          position: 2,
+          icon: "heart",
+          user_field: null,
+        },
+        {
+          id: 3,
+          name: "topic_count",
+          type: "automatic",
+          position: 3,
+          icon: null,
+          user_field: null,
+        },
+        {
+          id: 4,
+          name: "post_count",
+          type: "automatic",
+          position: 4,
+          icon: null,
+          user_field: null,
+        },
+        {
+          id: 5,
+          name: "topics_entered",
+          type: "automatic",
+          position: 5,
+          icon: null,
+          user_field: null,
+        },
+        {
+          id: 6,
+          name: "posts_read",
+          type: "automatic",
+          position: 6,
+          icon: null,
+          user_field: null,
+        },
+        {
+          id: 7,
+          name: "days_visited",
+          type: "automatic",
+          position: 7,
+          icon: null,
+          user_field: null,
+        },
+        {
+          id: 9,
+          name: "Favorite Color",
+          type: "user_field",
+          position: 8,
+          icon: null,
+          user_field_id: 3,
+        },
+      ],
+    });
+  });
 }
diff --git a/app/assets/javascripts/discourse/tests/setup-tests.js b/app/assets/javascripts/discourse/tests/setup-tests.js
index f51ff1cfdae..12f93098ab1 100644
--- a/app/assets/javascripts/discourse/tests/setup-tests.js
+++ b/app/assets/javascripts/discourse/tests/setup-tests.js
@@ -229,12 +229,6 @@ function setupTestsCommon(application, container, config) {
     });
 
     PreloadStore.reset();
-    PreloadStore.store(
-      "directoryColumns",
-      JSON.parse(
-        '[{"name":"likes_given","automatic":true,"icon":"heart","user_field_id":null},{"name":"posts_read","automatic":true,"icon":null,"user_field_id":null},{"name":"likes_received","automatic":true,"icon":"heart","user_field_id":null},{"name":"topic_count","automatic":true,"icon":null,"user_field_id":null},{"name":"post_count","automatic":true,"icon":null,"user_field_id":null},{"name":"topics_entered","automatic":true,"icon":null,"user_field_id":null},{"name":"days_visited","automatic":true,"icon":null,"user_field_id":null},{"name":"Favorite Color","automatic":false,"icon":null,"user_field_id":3}]'
-      )
-    );
 
     sinon.stub(ScrollingDOMMethods, "screenNotFull");
     sinon.stub(ScrollingDOMMethods, "bindOnScroll");
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index ca9796b9621..acfa7e10802 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -604,7 +604,6 @@ class ApplicationController < ActionController::Base
     store_preloaded("customEmoji", custom_emoji)
     store_preloaded("isReadOnly", @readonly_mode.to_s)
     store_preloaded("activatedThemes", activated_themes_json)
-    store_preloaded("directoryColumns", directory_columns_json)
   end
 
   def preload_current_user_data
@@ -616,20 +615,6 @@ class ApplicationController < ActionController::Base
     store_preloaded("topicTrackingStates", MultiJson.dump(serializer))
   end
 
-  def directory_columns_json
-    DirectoryColumn
-      .left_joins(:user_field)
-      .where(enabled: true)
-      .order(:position)
-      .pluck('directory_columns.name',
-             'directory_columns.automatic',
-             'directory_columns.icon',
-             'user_fields.id',
-             'user_fields.name')
-      .map { |column| { name: column[0] || column[4], automatic: column[1], icon: column[2], user_field_id: column[3] } }
-      .to_json
-  end
-
   def custom_html_json
     target = view_context.mobile_view? ? :mobile : :desktop
 
diff --git a/app/controllers/directory_columns_controller.rb b/app/controllers/directory_columns_controller.rb
index 2efdcd6dd4f..d11f5e30ff5 100644
--- a/app/controllers/directory_columns_controller.rb
+++ b/app/controllers/directory_columns_controller.rb
@@ -1,62 +1,8 @@
 # frozen_string_literal: true
 
 class DirectoryColumnsController < ApplicationController
-  requires_login
-
   def index
-    raise Discourse::NotFound unless guardian.is_staff?
-
-    ensure_user_fields_have_columns
-
-    columns = DirectoryColumn.includes(:user_field).all
-    render_json_dump(directory_columns: serialize_data(columns, DirectoryColumnSerializer))
-  end
-
-  def update
-    raise Discourse::NotFound unless guardian.is_staff?
-    params.require(:directory_columns)
-    directory_column_params = params.permit(directory_columns: {})
-    directory_columns = DirectoryColumn.all
-
-    has_enabled_column = directory_column_params[:directory_columns].values.any? do |column_data|
-      column_data[:enabled].to_s == "true"
-    end
-    raise Discourse::InvalidParameters, "Must have at least one column enabled" unless has_enabled_column
-
-    directory_column_params[:directory_columns].values.each do |column_data|
-      existing_column = directory_columns.detect { |c| c.id == column_data[:id].to_i }
-      if (existing_column.enabled != column_data[:enabled] || existing_column.position != column_data[:position].to_i)
-        existing_column.update(enabled: column_data[:enabled], position: column_data[:position])
-      end
-    end
-
-    render json: success_json
-  end
-
-  private
-
-  def ensure_user_fields_have_columns
-    user_fields_without_column =
-      UserField.left_outer_joins(:directory_column)
-        .where(directory_column: { user_field_id: nil })
-        .where("show_on_profile=? OR show_on_user_card=?", true, true)
-
-    return unless user_fields_without_column.count > 0
-
-    next_position = DirectoryColumn.maximum("position") + 1
-
-    new_directory_column_attrs = []
-    user_fields_without_column.each do |user_field|
-      new_directory_column_attrs.push({
-        user_field_id: user_field.id,
-        enabled: false,
-        automatic: false,
-        position: next_position
-      })
-
-      next_position += 1
-    end
-
-    DirectoryColumn.insert_all(new_directory_column_attrs)
+    directory_columns = DirectoryColumn.includes(:user_field).where(enabled: true).order(:position)
+    render_json_dump(directory_columns: serialize_data(directory_columns, DirectoryColumnSerializer))
   end
 end
diff --git a/app/controllers/directory_items_controller.rb b/app/controllers/directory_items_controller.rb
index 8f7314f75e8..b8ec391b9b0 100644
--- a/app/controllers/directory_items_controller.rb
+++ b/app/controllers/directory_items_controller.rb
@@ -26,13 +26,14 @@ class DirectoryItemsController < ApplicationController
       result = result.references(:user).where.not(users: { username: params[:exclude_usernames].split(",") })
     end
 
-    order = params[:order] || DirectoryItem.headings.first
+    order = params[:order] || DirectoryColumn.automatic_column_names.first
     dir = params[:asc] ? 'ASC' : 'DESC'
-    if DirectoryItem.headings.include?(order.to_sym)
+    if DirectoryColumn.active_column_names.include?(order.to_sym)
       result = result.order("directory_items.#{order} #{dir}, directory_items.id")
     elsif params[:order] === 'username'
       result = result.order("users.#{order} #{dir}, directory_items.id")
     else
+      # Ordering by user field value
       user_field = UserField.find_by(name: params[:order])
       if user_field
         result = result
@@ -98,6 +99,10 @@ class DirectoryItemsController < ApplicationController
       serializer_opts[:user_field_ids] = params[:user_field_ids]&.split("|")&.map(&:to_i)
     end
 
+    if params[:plugin_column_ids]
+      serializer_opts[:plugin_column_ids] = params[:plugin_column_ids]&.split("|")&.map(&:to_i)
+    end
+
     serialized = serialize_data(result, DirectoryItemSerializer, serializer_opts)
     render_json_dump(directory_items: serialized,
                      meta: {
diff --git a/app/controllers/edit_directory_columns_controller.rb b/app/controllers/edit_directory_columns_controller.rb
new file mode 100644
index 00000000000..b40d13ce66e
--- /dev/null
+++ b/app/controllers/edit_directory_columns_controller.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+class EditDirectoryColumnsController < ApplicationController
+  requires_login
+
+  def index
+    raise Discourse::NotFound unless guardian.is_staff?
+
+    ensure_user_fields_have_columns
+
+    columns = DirectoryColumn.includes(:user_field).all
+    render_json_dump(directory_columns: serialize_data(columns, EditDirectoryColumnSerializer))
+  end
+
+  def update
+    raise Discourse::NotFound unless guardian.is_staff?
+    params.require(:directory_columns)
+    directory_column_params = params.permit(directory_columns: {})
+    directory_columns = DirectoryColumn.all
+
+    has_enabled_column = directory_column_params[:directory_columns].values.any? do |column_data|
+      column_data[:enabled].to_s == "true"
+    end
+    raise Discourse::InvalidParameters, "Must have at least one column enabled" unless has_enabled_column
+
+    directory_column_params[:directory_columns].values.each do |column_data|
+      existing_column = directory_columns.detect { |c| c.id == column_data[:id].to_i }
+      if (existing_column.enabled != column_data[:enabled] || existing_column.position != column_data[:position].to_i)
+        existing_column.update(enabled: column_data[:enabled], position: column_data[:position])
+      end
+    end
+
+    render json: success_json
+  end
+
+  private
+
+  def ensure_user_fields_have_columns
+    user_fields_without_column =
+      UserField.left_outer_joins(:directory_column)
+        .where(directory_column: { user_field_id: nil })
+        .where("show_on_profile=? OR show_on_user_card=?", true, true)
+
+    return unless user_fields_without_column.count > 0
+
+    next_position = DirectoryColumn.maximum("position") + 1
+
+    new_directory_column_attrs = []
+    user_fields_without_column.each do |user_field|
+      new_directory_column_attrs.push({
+        user_field_id: user_field.id,
+        enabled: false,
+        type: DirectoryColumn.types[:user_field],
+        position: next_position
+      })
+
+      next_position += 1
+    end
+
+    DirectoryColumn.insert_all(new_directory_column_attrs)
+  end
+end
diff --git a/app/models/directory_column.rb b/app/models/directory_column.rb
index 4a3bc3546e0..8b73960a8d2 100644
--- a/app/models/directory_column.rb
+++ b/app/models/directory_column.rb
@@ -1,5 +1,52 @@
 # frozen_string_literal: true
 
 class DirectoryColumn < ActiveRecord::Base
+
+  # TODO(2021-06-18): Remove automatic column
+  self.ignored_columns = ["automatic"]
+  self.inheritance_column = nil
+
+  enum type: { automatic: 0, user_field: 1, plugin: 2 }
+
+  def self.automatic_column_names
+    @automatic_column_names ||= [:likes_received,
+                   :likes_given,
+                   :topics_entered,
+                   :topic_count,
+                   :post_count,
+                   :posts_read,
+                   :days_visited]
+  end
+
+  def self.active_column_names
+    DirectoryColumn.where(type: [:automatic, :plugin]).where(enabled: true).pluck(:name).map(&:to_sym)
+  end
+
+  @@plugin_directory_columns = []
+
+  def self.plugin_directory_columns
+    @@plugin_directory_columns
+  end
+
   belongs_to :user_field
+
+  def self.clear_plugin_directory_columns
+    @@plugin_directory_columns = []
+  end
+
+  def self.find_or_create_plugin_directory_column(attrs)
+    directory_column = find_or_create_by(
+      name: attrs[:column_name],
+      icon: attrs[:icon],
+      type: DirectoryColumn.types[:plugin]
+    ) do |column|
+      column.position = DirectoryColumn.maximum("position") + 1
+      column.enabled = false
+    end
+
+    unless @@plugin_directory_columns.include?(directory_column.name)
+      @@plugin_directory_columns << directory_column.name
+      DirectoryItem.add_plugin_query(attrs[:query])
+    end
+  end
 end
diff --git a/app/models/directory_item.rb b/app/models/directory_item.rb
index 930c7829297..817697a434a 100644
--- a/app/models/directory_item.rb
+++ b/app/models/directory_item.rb
@@ -4,15 +4,7 @@ class DirectoryItem < ActiveRecord::Base
   belongs_to :user
   has_one :user_stat, foreign_key: :user_id, primary_key: :user_id
 
-  def self.headings
-    @headings ||= [:likes_received,
-                   :likes_given,
-                   :topics_entered,
-                   :topic_count,
-                   :post_count,
-                   :posts_read,
-                   :days_visited]
-  end
+  @@plugin_queries = []
 
   def self.period_types
     @types ||= Enum.new(all: 1,
@@ -34,8 +26,16 @@ class DirectoryItem < ActiveRecord::Base
     Time.zone.at(val.to_i)
   end
 
-  def self.refresh_period!(period_type, force: false)
+  def self.add_plugin_query(details)
+    @@plugin_queries << details
+  end
 
+  def self.clear_plugin_queries
+    @@plugin_queries = []
+  end
+
+  def self.refresh_period!(period_type, force: false)
+    DiscourseEvent.trigger("before_directory_refresh")
     Discourse.redis.set("directory_#{period_types[period_type]}", Time.zone.now.to_i)
 
     # Don't calculate it if the user directory is disabled
@@ -53,30 +53,26 @@ class DirectoryItem < ActiveRecord::Base
 
     ActiveRecord::Base.transaction do
       # Delete records that belonged to users who have been deleted
-      DB.exec "DELETE FROM directory_items
+      DB.exec("DELETE FROM directory_items
                 USING directory_items di
                 LEFT JOIN users u ON (u.id = user_id AND u.active AND u.silenced_till IS NULL AND u.id > 0)
                 WHERE di.id = directory_items.id AND
                       u.id IS NULL AND
-                      di.period_type = :period_type", period_type: period_types[period_type]
+                      di.period_type = :period_type", period_type: period_types[period_type])
 
       # Create new records for users who don't have one yet
-      DB.exec "INSERT INTO directory_items(period_type, user_id, likes_received, likes_given, topics_entered, days_visited, posts_read, topic_count, post_count)
+
+      column_names = DirectoryColumn.automatic_column_names + DirectoryColumn.plugin_directory_columns
+      DB.exec("INSERT INTO directory_items(period_type, user_id, #{column_names.map(&:to_s).join(", ")})
                 SELECT
                     :period_type,
                     u.id,
-                    0,
-                    0,
-                    0,
-                    0,
-                    0,
-                    0,
-                    0
+                    #{Array.new(column_names.count) { |_| 0 }.join(", ") }
                 FROM users u
                 LEFT JOIN directory_items di ON di.user_id = u.id AND di.period_type = :period_type
                 WHERE di.id IS NULL AND u.id > 0 AND u.silenced_till IS NULL AND u.active
                 #{SiteSetting.must_approve_users ? 'AND u.approved' : ''}
-      ", period_type: period_types[period_type]
+              ", period_type: period_types[period_type])
 
       # Calculate new values and update records
       #
@@ -84,7 +80,18 @@ class DirectoryItem < ActiveRecord::Base
       # TODO
       # WARNING: post_count is a wrong name, it should be reply_count (excluding topic post)
       #
-      DB.exec "WITH x AS (SELECT
+      #
+      query_args = {
+        period_type: period_types[period_type],
+        since: since,
+        like_type: UserAction::LIKE,
+        was_liked_type: UserAction::WAS_LIKED,
+        new_topic_type: UserAction::NEW_TOPIC,
+        reply_type: UserAction::REPLY,
+        regular_post_type: Post.types[:regular]
+      }
+
+      DB.exec("WITH x AS (SELECT
                     u.id user_id,
                     SUM(CASE WHEN p.id IS NOT NULL AND t.id IS NOT NULL AND ua.action_type = :was_liked_type THEN 1 ELSE 0 END) likes_received,
                     SUM(CASE WHEN p.id IS NOT NULL AND t.id IS NOT NULL AND ua.action_type = :like_type THEN 1 ELSE 0 END) likes_given,
@@ -123,14 +130,13 @@ class DirectoryItem < ActiveRecord::Base
         di.topic_count <> x.topic_count OR
         di.post_count <> x.post_count )
 
-      ",
-                  period_type: period_types[period_type],
-                  since: since,
-                  like_type: UserAction::LIKE,
-                  was_liked_type: UserAction::WAS_LIKED,
-                  new_topic_type: UserAction::NEW_TOPIC,
-                  reply_type: UserAction::REPLY,
-                  regular_post_type: Post.types[:regular]
+              ",
+              query_args
+             )
+
+      @@plugin_queries.each do |plugin_query|
+        DB.exec(plugin_query, query_args)
+      end
 
       if period_type == :all
         DB.exec <<~SQL
diff --git a/app/serializers/directory_column_serializer.rb b/app/serializers/directory_column_serializer.rb
index 18e18ba67b8..4e172d3e9e6 100644
--- a/app/serializers/directory_column_serializer.rb
+++ b/app/serializers/directory_column_serializer.rb
@@ -3,11 +3,12 @@
 class DirectoryColumnSerializer < ApplicationSerializer
   attributes :id,
              :name,
-             :automatic,
-             :enabled,
-             :automatic_position,
+             :type,
              :position,
-             :icon
+             :icon,
+             :user_field_id
 
-  has_one :user_field, serializer: UserFieldSerializer, embed: :objects
+  def name
+    object.name || object.user_field.name
+  end
 end
diff --git a/app/serializers/directory_item_serializer.rb b/app/serializers/directory_item_serializer.rb
index 02a15ae3f47..1e18f84c802 100644
--- a/app/serializers/directory_item_serializer.rb
+++ b/app/serializers/directory_item_serializer.rb
@@ -20,7 +20,7 @@ class DirectoryItemSerializer < ApplicationSerializer
              :time_read
 
   has_one :user, embed: :objects, serializer: UserSerializer
-  attributes *DirectoryItem.headings
+  attributes *DirectoryColumn.active_column_names
 
   def id
     object.user_id
diff --git a/app/serializers/edit_directory_column_serializer.rb b/app/serializers/edit_directory_column_serializer.rb
new file mode 100644
index 00000000000..7c703d59659
--- /dev/null
+++ b/app/serializers/edit_directory_column_serializer.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class EditDirectoryColumnSerializer < DirectoryColumnSerializer
+  attributes :enabled,
+             :automatic_position
+
+  has_one :user_field, serializer: UserFieldSerializer, embed: :objects
+end
diff --git a/config/routes.rb b/config/routes.rb
index 052480ef8ab..cd395847561 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -388,7 +388,8 @@ Discourse::Application.routes.draw do
 
     get "user-cards" => "users#cards", format: :json
     get "directory-columns" => "directory_columns#index", format: :json
-    put "directory-columns" => "directory_columns#update", format: :json
+    get "edit-directory-columns" => "edit_directory_columns#index", format: :json
+    put "edit-directory-columns" => "edit_directory_columns#update", format: :json
 
     %w{users u}.each_with_index do |root_path, index|
       get "#{root_path}" => "users#index", constraints: { format: 'html' }
diff --git a/db/migrate/20210618135229_reintroduce_type_to_directory_columns.rb b/db/migrate/20210618135229_reintroduce_type_to_directory_columns.rb
new file mode 100644
index 00000000000..bb149e7eebb
--- /dev/null
+++ b/db/migrate/20210618135229_reintroduce_type_to_directory_columns.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class ReintroduceTypeToDirectoryColumns < ActiveRecord::Migration[6.1]
+  def up
+    if !ActiveRecord::Base.connection.column_exists?(:directory_columns, :type)
+      # A migration that added this column was previously merged and reverted.
+      # Some sites have this column and some do not, so only add if missing.
+      add_column :directory_columns, :type, :integer, default: 0, null: false
+    end
+
+    DB.exec(
+      <<~SQL
+        UPDATE directory_columns
+        SET type = CASE WHEN automatic THEN 0 ELSE 1 END;
+      SQL
+    )
+  end
+
+  def down
+    remove_column :directory_columns, :type
+  end
+end
diff --git a/lib/discourse_event.rb b/lib/discourse_event.rb
index bd7a21a950d..b2e03b8b1d1 100644
--- a/lib/discourse_event.rb
+++ b/lib/discourse_event.rb
@@ -27,4 +27,7 @@ class DiscourseEvent
     events[event_name].delete(block)
   end
 
+  def self.all_off(event_name)
+    events.delete(event_name)
+  end
 end
diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb
index 2a92c6d01d6..646603effca 100644
--- a/lib/plugin/instance.rb
+++ b/lib/plugin/instance.rb
@@ -373,6 +373,14 @@ class Plugin::Instance
     assets
   end
 
+  def add_directory_column(column_name, query:, icon: nil)
+    validate_directory_column_name(column_name)
+
+    DiscourseEvent.on("before_directory_refresh") do
+      DirectoryColumn.find_or_create_plugin_directory_column(column_name: column_name, icon: icon, query: query)
+    end
+  end
+
   def delete_extra_automatic_assets(good_paths)
     return unless Dir.exists? auto_generated_path
 
@@ -593,7 +601,6 @@ class Plugin::Instance
   # this allows us to present information about a plugin in the UI
   # prior to activations
   def activate!
-
     if @path
       root_dir_name = File.dirname(@path)
 
@@ -964,6 +971,11 @@ class Plugin::Instance
 
   private
 
+  def validate_directory_column_name(column_name)
+    match = /^[_a-z]+$/.match(column_name)
+    raise "Invalid directory column name '#{column_name}'. Can only contain a-z and underscores" unless match
+  end
+
   def write_asset(path, contents)
     unless File.exists?(path)
       ensure_directory(path)
diff --git a/spec/components/discourse_event_spec.rb b/spec/components/discourse_event_spec.rb
index 4e87a079155..fe4ad080db8 100644
--- a/spec/components/discourse_event_spec.rb
+++ b/spec/components/discourse_event_spec.rb
@@ -83,9 +83,25 @@ describe DiscourseEvent do
         expect(harvey.job).to eq('Supervillain')
         expect(harvey.name).to eq('Two Face')
       end
-
     end
 
-  end
+    context '#all_off' do
 
+      let(:event_handler_2) do
+        Proc.new { |user| user.job = 'Supervillain' }
+      end
+
+      before do
+        DiscourseEvent.on(:acid_face, &event_handler_2)
+      end
+
+      it 'removes all handlers with a key' do
+        harvey.job = 'gardening'
+        DiscourseEvent.all_off(:acid_face)
+        DiscourseEvent.trigger(:acid_face, harvey) # Doesn't change anything
+        expect(harvey.job).to eq('gardening')
+      end
+
+    end
+  end
 end
diff --git a/spec/components/plugin/instance_spec.rb b/spec/components/plugin/instance_spec.rb
index e9de28e83d1..171e8c94b51 100644
--- a/spec/components/plugin/instance_spec.rb
+++ b/spec/components/plugin/instance_spec.rb
@@ -600,4 +600,51 @@ describe Plugin::Instance do
       expect(ApiKeyScope.scope_mappings.dig(:groups, :create, :actions)).to contain_exactly(*actions)
     end
   end
+
+  describe '#add_directory_column' do
+    let!(:plugin) { Plugin::Instance.new }
+
+    before do
+      DirectoryItem.clear_plugin_queries
+    end
+
+    after do
+      DirectoryColumn.clear_plugin_directory_columns
+    end
+
+    describe "with valid column name" do
+      let(:column_name) { "random_c" }
+
+      before do
+        DB.exec("ALTER TABLE directory_items ADD COLUMN IF NOT EXISTS #{column_name} integer")
+      end
+
+      after do
+        DB.exec("ALTER TABLE directory_items DROP COLUMN IF EXISTS #{column_name}")
+        DiscourseEvent.all_off("before_directory_refresh")
+      end
+
+      it 'creates a directory column record when directory items are refreshed' do
+        plugin.add_directory_column(column_name, query: "SELECT COUNT(*) FROM users", icon: 'recycle')
+        expect(DirectoryColumn.find_by(name: column_name, icon: 'recycle', enabled: false)).not_to be_present
+
+        DirectoryItem.refresh!
+        expect(DirectoryColumn.find_by(name: column_name, icon: 'recycle', enabled: false)).to be_present
+      end
+    end
+
+    it 'errors when the column_name contains invalid characters' do
+      expect {
+        plugin.add_directory_column('Capital', query: "SELECT COUNT(*) FROM users", icon: 'recycle')
+      }.to raise_error(RuntimeError)
+
+      expect {
+        plugin.add_directory_column('has space', query: "SELECT COUNT(*) FROM users", icon: 'recycle')
+      }.to raise_error(RuntimeError)
+
+      expect {
+        plugin.add_directory_column('has_number_1', query: "SELECT COUNT(*) FROM users", icon: 'recycle')
+      }.to raise_error(RuntimeError)
+    end
+  end
 end
diff --git a/spec/requests/admin/user_fields_controller_spec.rb b/spec/requests/admin/user_fields_controller_spec.rb
index 828795859c6..9084ebf472a 100644
--- a/spec/requests/admin/user_fields_controller_spec.rb
+++ b/spec/requests/admin/user_fields_controller_spec.rb
@@ -130,7 +130,7 @@ describe Admin::UserFieldsController do
         DirectoryColumn.create(
           user_field_id: user_field.id,
           enabled: false,
-          automatic: false,
+          type: DirectoryColumn.types[:user_field],
           position: next_position
         )
         expect {
diff --git a/spec/requests/directory_columns_controller_spec.rb b/spec/requests/directory_columns_controller_spec.rb
index 6f01eb01ad6..5fb8074b978 100644
--- a/spec/requests/directory_columns_controller_spec.rb
+++ b/spec/requests/directory_columns_controller_spec.rb
@@ -7,6 +7,17 @@ describe DirectoryColumnsController do
   fab!(:admin) { Fabricate(:admin) }
 
   describe "#index" do
+    it "returns all active directory columns" do
+      likes_given = DirectoryColumn.find_by(name: "likes_given")
+      likes_given.update(enabled: false)
+
+      get "/directory-columns.json"
+
+      expect(response.parsed_body["directory_columns"].map { |dc| dc["name"] }).not_to include("likes_given")
+    end
+  end
+
+  describe "#edit-index" do
     fab!(:public_user_field) { Fabricate(:user_field, show_on_profile: true) }
     fab!(:private_user_field) { Fabricate(:user_field, show_on_profile: false, show_on_user_card: false) }
 
@@ -14,13 +25,13 @@ describe DirectoryColumnsController do
       sign_in(admin)
 
       expect {
-        get "/directory-columns.json"
+        get "/edit-directory-columns.json"
       }.to change { DirectoryColumn.count }.by(1)
     end
 
     it "returns a 403 when not logged in as staff member" do
       sign_in(user)
-      get "/directory-columns.json"
+      get "/edit-directory-columns.json"
 
       expect(response.status).to eq(404)
     end
@@ -50,7 +61,7 @@ describe DirectoryColumnsController do
       sign_in(admin)
 
       expect {
-        put "/directory-columns.json", params: params
+        put "/edit-directory-columns.json", params: params
       }.to change { DirectoryColumn.find(first_directory_column_id).enabled }.from(true).to(false)
     end
 
@@ -59,14 +70,14 @@ describe DirectoryColumnsController do
       bad_params = params
       bad_params[:directory_columns][:"1"][:enabled] = false
 
-      put "/directory-columns.json", params: bad_params
+      put "/edit-directory-columns.json", params: bad_params
 
       expect(response.status).to eq(400)
     end
 
     it "returns a 404 when not logged in as a staff member" do
       sign_in(user)
-      put "/directory-columns.json", params: params
+      put "/edit-directory-columns.json", params: params
 
       expect(response.status).to eq(404)
     end