diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 40983af7aa4a6345c1c68349d4a945127e8ea5e7..ac6324804b70580ef415f1298f0566b72f939d1b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -36,7 +36,7 @@ request. Here they are:
  * There is a .rubocop.yml file provided in the project and rubocop is included in the bundle; please run `bundle exec rubocop` for 
    Ruby style checking.
  
-When writing CSS, keep in mind that our design framework, [Co-Design](https://design.codidact.org/) is available in Core, and
+When writing CSS, keep in mind that our design framework, [Co-Design](https://design.codidact.org/) is available in QPixel, and
 should be used where possible. Avoid writing custom CSS if you can; favour using components and atomic classes from Co-Design.
 
 We also have some [guidelines for commit messages](https://github.com/codidact/core/wiki/Committing-guidelines). Again, please
diff --git a/app/assets/javascripts/categories.js b/app/assets/javascripts/categories.js
new file mode 100644
index 0000000000000000000000000000000000000000..3887efc3977492a4eb7f9bcfa1df018dfa33083d
--- /dev/null
+++ b/app/assets/javascripts/categories.js
@@ -0,0 +1,45 @@
+$(() => {
+  $('.js-category-tag-set-select').on('change', ev => {
+    const $tgt = $(ev.target);
+    const tagSetId = $tgt.val();
+    const formGroups = $('.js-category-tags-group');
+    if (tagSetId) {
+      formGroups.each((i, el) => {
+        const $el = $(el);
+        const $caption = $el.find('.js-tags-group-caption');
+        $caption.find('[data-state="absent"]').hide();
+        $caption.find('[data-state="present"]').show();
+
+        $el.find('.js-tag-select').attr('data-tag-set', tagSetId).attr('disabled', false);
+      });
+    }
+    else {
+      formGroups.each((i, el) => {
+        const $el = $(el);
+        const $caption = $el.find('.js-tags-group-caption');
+        $caption.find('[data-state="absent"]').show();
+        $caption.find('[data-state="present"]').hide();
+
+        $el.find('.js-tag-select').attr('data-tag-set', null).attr('disabled', true);
+      });
+    }
+  });
+
+  $('.js-add-required-topic').on('click', ev => {
+    const $required = $('.js-required-tags');
+    const $topic = $('.js-topic-tags');
+    const union = ($required.val() || []).concat($topic.val() || []);
+
+    const options = $topic.find('option').toArray();
+    const optionIds = options.map(x => $(x).attr('value'));
+    const missing = union.filter(x => !optionIds.includes(x));
+    const missingOptions = $required.find('option').toArray().filter(x => missing.includes($(x).attr('value')));
+
+    missingOptions.forEach(opt => {
+      const $append = $(opt).clone();
+      $append.removeAttr('data-select2-id');
+      $topic.append($append);
+    });
+    $topic.val(union).trigger('change');
+  });
+});
\ No newline at end of file
diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js
index 1439c371fbb44404ba78077aad700a62e59ba74e..be3e3af18223b017acd217b90193bfc1f3d3f95b 100644
--- a/app/assets/javascripts/tags.js
+++ b/app/assets/javascripts/tags.js
@@ -1,14 +1,18 @@
 $(() => {
-  $('.js-tag-select').select2({
-    tags: true,
-    ajax: {
-      url: '/tags',
-      data: function (params) {
-        return Object.assign(params, { tag_set: $(this).data('tag-set') });
-      },
-      headers: { 'Accept': 'application/json' },
-      delay: 100,
-      processResults: data => ({results: data.map(t => ({id: t.name, text: t.name}))}),
-    }
+  $('.js-tag-select').each((i, el) => {
+    const $tgt = $(el);
+    const useIds = $tgt.attr('data-use-ids') === 'true';
+    $tgt.select2({
+      tags: $tgt.attr('data-create') !== 'false',
+      ajax: {
+        url: '/tags',
+        data: function (params) {
+          return Object.assign(params, { tag_set: $(this).data('tag-set') });
+        },
+        headers: { 'Accept': 'application/json' },
+        delay: 100,
+        processResults: data => ({results: data.map(t => ({id: useIds ? t.id : t.name, text: t.name}))}),
+      }
+    });
   });
 });
\ No newline at end of file
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index bf202126f14a5060c76783cea4527df48f7171bf..6f13bce9914553465d9313f92ab266475a996dfe 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -83,4 +83,8 @@ pre.unformatted {
 
 .stat-value {
   font-size: 2.0em;
+}
+
+.badge.is-tag.is-outlined {
+  border: 1px solid #001db1;
 }
\ No newline at end of file
diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb
index e1bd54c0ffea160de3bbed723f6d166219bfc664..62b0a6329f515529a6d3bcfeae4523c016b96516 100644
--- a/app/controllers/categories_controller.rb
+++ b/app/controllers/categories_controller.rb
@@ -75,7 +75,7 @@ class CategoriesController < ApplicationController
   def category_params
     params.require(:category).permit(:name, :short_wiki, :tag_set_id, :is_homepage, :min_trust_level, :button_text,
                                      :color_code, :min_view_trust_level, :license_id, :sequence, display_post_types: [],
-                                     post_type_ids: [])
+                                     post_type_ids: [], required_tag_ids: [], topic_tag_ids: [])
   end
 
   def verify_view_access
diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb
index 916e3f00e52b3ef771423975bc599364334bc384..f68b28e444c53d324905811c023eb8894071dcdd 100644
--- a/app/controllers/questions_controller.rb
+++ b/app/controllers/questions_controller.rb
@@ -49,7 +49,7 @@ class QuestionsController < ApplicationController
       not_found
       return
     end
-    @questions = @tag.posts.undeleted.order('updated_at DESC').paginate(page: params[:page], per_page: 50)
+    @questions = @tag.posts.list_includes.undeleted.order('updated_at DESC').paginate(page: params[:page], per_page: 50)
   end
 
   def lottery
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index e5c54e276baf834b15b95bcf6998364edd9f7441..2aa76e8e6c7c565c063c8dbdf83afe52ce42baef 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -30,6 +30,13 @@ class ApplicationRecord < ActiveRecord::Base
 
     ActiveRecord::Base.send(:sanitize_sql_array, ["MATCH (#{cols}) AGAINST (? IN BOOLEAN MODE)", term])
   end
+
+  def self.sanitize_sql_in(ary)
+    return "(NULL)" unless ary.present? && ary.respond_to?(:map)
+
+    ary = ary.map { |el| ActiveRecord::Base.sanitize_sql_array(['?', el]) }
+    "(#{ary.join(', ')})"
+  end
 end
 
 module UserSortable
diff --git a/app/models/category.rb b/app/models/category.rb
index 486fcb87cdb1eff8302af486e14df3391e433031..cba36d5c375277c0d5167594751f06bb4cb98096 100644
--- a/app/models/category.rb
+++ b/app/models/category.rb
@@ -2,6 +2,8 @@ class Category < ApplicationRecord
   include CommunityRelated
 
   has_and_belongs_to_many :post_types
+  has_and_belongs_to_many :required_tags, class_name: 'Tag', join_table: 'categories_required_tags'
+  has_and_belongs_to_many :topic_tags, class_name: 'Tag', join_table: 'categories_topic_tags'
   has_many :posts
   belongs_to :tag_set
   belongs_to :license
diff --git a/app/models/post.rb b/app/models/post.rb
index bd3ab8060f1bc2630db840e86b250fc8b1d8caac..6d5033ba2f8a74bdb710ff9758db19db5bfb938f 100644
--- a/app/models/post.rb
+++ b/app/models/post.rb
@@ -30,17 +30,18 @@ class Post < ApplicationRecord
   validate :stripped_minimum, if: :question?
   validate :category_allows_post_type
   validate :license_available
+  validate :required_tags?
 
   scope :undeleted, -> { where(deleted: false) }
   scope :deleted, -> { where(deleted: true) }
   scope :qa_only, -> { where(post_type_id: [Question.post_type_id, Answer.post_type_id]) }
-  scope :list_includes, -> { includes(:user, user: :avatar_attachment) }
+  scope :list_includes, -> { includes(:user, :tags, user: :avatar_attachment) }
 
   after_save :check_attribution_notice
   after_save :modify_author_reputation
   after_save :copy_last_activity_to_parent
   after_save :break_description_cache
-  after_save :update_tag_associations, if: :question?
+  before_validation :update_tag_associations, if: :question?
   after_create :create_initial_revision
   after_create :add_license_if_nil
 
@@ -230,4 +231,13 @@ class Post < ApplicationRecord
       update(license: License.site_default)
     end
   end
+
+  def required_tags?
+    required = category&.required_tag_ids
+    return unless required.present? && !required.empty?
+
+    unless tag_ids.any? { |t| required.include? t }
+      errors.add(:tags, "must contain at least one required tag (#{category.required_tags.pluck(:name).join(', ')})")
+    end
+  end
 end
diff --git a/app/models/question.rb b/app/models/question.rb
index 50b2989471c68ca8dc179752b5c1518198e3dc37..b0a347e7e0a0e11e29982e6d6f56a1ecb1cec626 100644
--- a/app/models/question.rb
+++ b/app/models/question.rb
@@ -11,15 +11,6 @@ class Question < Post
     PostType.mapping['Question']
   end
 
-  validates :title, :body, :tags_cache, presence: true
-  validate :tags_in_tag_set
-  validate :maximum_tags
-  validate :maximum_tag_length
-  validate :no_spaces_in_tags
-  validate :stripped_minimum
-
-  after_save :update_tag_associations
-
   def answers
     Answer.where(parent: self)
   end
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 65024e43b4894060fdb8b705445547c91a27a145..cd89a7d0d35a75be50baf01908b569ee4031c212 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -1,6 +1,13 @@
 class Tag < ApplicationRecord
   include CommunityRelated
 
+  scope :category_order, -> (required_ids, topic_ids) do
+    helpers = ActionController::Base.helpers
+    order(Arel.sql("id IN #{sanitize_sql_in(required_ids)} DESC"),
+          Arel.sql("id IN #{sanitize_sql_in(topic_ids)} DESC"),
+          name: :asc)
+  end
+
   has_and_belongs_to_many :posts
   belongs_to :tag_set
 
diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb
index 9613e9d0cbf9cc64c572d419ba5182430c52640f..7a5465b726ad69ebd892af6d0737bb5da38695f6 100644
--- a/app/views/categories/_form.html.erb
+++ b/app/views/categories/_form.html.erb
@@ -31,7 +31,7 @@
     <%= f.label :tag_set_id, 'Tag set', class: 'form-element' %>
     <span class="form-caption">Which tag set may posts in this category draw from?</span>
     <%= f.select :tag_set_id, options_for_select(TagSet.all.map { |ts|  [ts.name, ts.id] }, selected: @category.tag_set_id),
-                 { include_blank: true }, class: 'form-element' %>
+                 { include_blank: true }, class: 'form-element js-category-tag-set-select' %>
   </div>
 
   <div class="form-group">
@@ -90,5 +90,40 @@
     <%= f.number_field :sequence, class: 'form-element' %>
   </div>
 
+  <div class="form-group js-category-tags-group">
+    <%= f.label :required_tag_ids, 'Required tags', class: 'form-element' %>
+    <span class="form-caption js-tags-group-caption">
+      <span data-state="present" style="<%= @category.tag_set.nil? ? 'display: none' : '' %>">
+        Required tags for this category - every post will be required to have one of these tags.
+      </span>
+      <span data-state="absent" style="<%= @category.tag_set.nil? ? '' : 'display: none' %>">
+        Select a tag set first.
+      </span>
+    </span>
+    <% disabled = @category.tag_set.nil? %>
+    <%= f.select :required_tag_ids, options_for_select(@category.required_tags.map { |t| [t.name, t.id] },
+                                                       selected: @category.required_tag_ids),
+                 { include_blank: true }, multiple: true, class: 'form-element js-tag-select js-required-tags',
+                 data: { tag_set: @category.tag_set&.id, create: 'false', use_ids: 'true' }, disabled: disabled %>
+  </div>
+
+  <div class="form-group js-category-tags-group">
+    <%= f.label :required_tag_ids, 'Topic tags', class: 'form-element' %>
+    <span class="form-caption js-tags-group-caption">
+      <span data-state="present" style="<%= @category.tag_set.nil? ? 'display: none' : '' %>">
+        Tags that will be highlighted as the most important tag on a question.
+        <a href="javascript:void(0)" class="js-add-required-topic">Add all required tags</a>
+      </span>
+      <span data-state="absent" style="<%= @category.tag_set.nil? ? '' : 'display: none' %>">
+        Select a tag set first.
+      </span>
+    </span>
+
+    <%= f.select :topic_tag_ids, options_for_select(@category.topic_tags.map { |t| [t.name, t.id] },
+                                                       selected: @category.topic_tag_ids),
+                 { include_blank: true }, multiple: true, class: 'form-element js-tag-select js-topic-tags',
+                 data: { tag_set: @category.tag_set&.id, create: 'false', use_ids: 'true' }, disabled: disabled %>
+  </div>
+
   <%= f.submit 'Save', class: 'button is-filled' %>
 <% end %>
diff --git a/app/views/posts/_expanded.html.erb b/app/views/posts/_expanded.html.erb
index 1a71fceb5637603fd97bf8b0c9841e783531be7f..a2b45b8092f74a88507ba14246cd547d76f00547 100644
--- a/app/views/posts/_expanded.html.erb
+++ b/app/views/posts/_expanded.html.erb
@@ -98,9 +98,14 @@
           <% if is_question %>
             <div class="post--tags has-padding-2">
               <% tag_set = post.tag_set %>
-              <% post.tags_cache.each do |tag| %>
-                <% next if tag.nil? || tag.empty? %>
-                <%= link_to tag, questions_tagged_path(tag_set: tag_set.id, tag: tag), class: 'badge is-tag' %>
+              <% required_ids = post.category&.required_tag_ids %>
+              <% topic_ids = post.category&.topic_tag_ids %>
+              <% post.tags.category_order(required_ids, topic_ids).each do |tag| %>
+                <% next if tag.nil? %>
+                <% required = required_ids&.include? tag.id %>
+                <% topic = topic_ids&.include? tag.id %>
+                <%= link_to tag.name, questions_tagged_path(tag_set: tag_set.id, tag: tag.name),
+                            class: "badge is-tag #{required ? 'is-filled' : ''} #{topic ? 'is-outlined' : ''}" %>
               <% end %>
             </div>
           <% end %>
diff --git a/app/views/posts/_form.html.erb b/app/views/posts/_form.html.erb
index aba24f038783fef453943a3f9ab1c252c5d0f49d..713d328527be91402ea475996f5732ab0c03a2d3 100644
--- a/app/views/posts/_form.html.erb
+++ b/app/views/posts/_form.html.erb
@@ -38,6 +38,15 @@
 
   <div class="form-group">
     <%= f.label :tags_cache, 'Tags (at least one):', class: 'form-element' %>
+    <% required_tags = @category.required_tags.to_a %>
+    <% unless required_tags.empty? %>
+      <span class="form-caption">
+        Requires at least one of
+        <% required_tags.each do |tag| %>
+          <span class="badge is-tag is-filled"><%= tag.name %></span>
+        <% end %>
+      </span>
+    <% end %>
     <%= f.select :tags_cache, options_for_select(@post.tags_cache.map { |t| [t, t] }, selected: @post.tags_cache),
                  { include_blank: true }, multiple: true, class: "form-element js-tag-select",
                  data: { tag_set: @category.tag_set_id } %>
diff --git a/app/views/posts/_list.html.erb b/app/views/posts/_list.html.erb
index 290017e6d5d3165438d4cb1bc925515900cb08c2..5703b4e84e486b74448fecae7667d7701ab71633 100644
--- a/app/views/posts/_list.html.erb
+++ b/app/views/posts/_list.html.erb
@@ -27,8 +27,13 @@
     <div class="has-padding-top-2">
       <% if is_question %>
         <% tag_set = post.tag_set %>
-        <% post.tags_cache.each do |tag| %>
-          <%= link_to tag, questions_tagged_path(tag_set: tag_set.id, tag: tag), class: 'badge is-tag' %>
+        <% required_ids = post.category&.required_tag_ids %>
+        <% topic_ids = post.category&.topic_tag_ids %>
+        <% post.tags.category_order(required_ids, topic_ids).each do |tag| %>
+          <% required = required_ids&.include? tag.id %>
+          <% topic = topic_ids&.include? tag.id %>
+          <%= link_to tag.name, questions_tagged_path(tag_set: tag_set.id, tag: tag.name),
+                      class: "badge is-tag #{required ? 'is-filled' : ''} #{topic ? 'is-outlined' : ''}" %>
         <% end %>
       <% end %>
     </div>
diff --git a/db/migrate/20200508115752_add_category_tag_join_tables.rb b/db/migrate/20200508115752_add_category_tag_join_tables.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f79752d7c334bd55acda2dd6c834f5305deab2e7
--- /dev/null
+++ b/db/migrate/20200508115752_add_category_tag_join_tables.rb
@@ -0,0 +1,13 @@
+class AddCategoryTagJoinTables < ActiveRecord::Migration[5.2]
+  def change
+    create_table :categories_required_tags, id: false, primary_key: [:category_id, :tag_id] do |t|
+      t.bigint :category_id
+      t.bigint :tag_id
+    end
+
+    create_table :categories_topic_tags, id: false, primary_key: [:category_id, :tag_id] do |t|
+      t.bigint :category_id
+      t.bigint :tag_id
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 915d9fbd6bca8789079b1948807197aa515f5eb7..ae7e6d69558fecd931424e957e98c2429ce2cc8e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2020_05_08_112958) do
+ActiveRecord::Schema.define(version: 2020_05_08_115752) do
 
   create_table "active_storage_attachments", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", force: :cascade do |t|
     t.string "name", null: false
@@ -61,6 +61,16 @@ ActiveRecord::Schema.define(version: 2020_05_08_112958) do
     t.bigint "post_type_id", null: false
   end
 
+  create_table "categories_required_tags", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", force: :cascade do |t|
+    t.bigint "category_id"
+    t.bigint "tag_id"
+  end
+
+  create_table "categories_topic_tags", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", force: :cascade do |t|
+    t.bigint "category_id"
+    t.bigint "tag_id"
+  end
+
   create_table "close_reasons", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", force: :cascade do |t|
     t.string "name"
     t.text "description"