diff --git a/app/assets/javascripts/articles.coffee b/app/assets/javascripts/articles.coffee
new file mode 100644
index 0000000000000000000000000000000000000000..24f83d18bbd38c24c4f7c3c2fc360cd68e857a2a
--- /dev/null
+++ b/app/assets/javascripts/articles.coffee
@@ -0,0 +1,3 @@
+# Place all the behaviors and hooks related to the matching controller here.
+# All this logic will automatically be available in application.js.
+# You can use CoffeeScript in this file: http://coffeescript.org/
diff --git a/app/assets/stylesheets/articles.scss b/app/assets/stylesheets/articles.scss
new file mode 100644
index 0000000000000000000000000000000000000000..e77f17a9e74808b1ea6ac00528eba5153a1e1bb5
--- /dev/null
+++ b/app/assets/stylesheets/articles.scss
@@ -0,0 +1,3 @@
+// Place all the styles related to the Articles controller here.
+// They will automatically be included in application.css.
+// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/app/assets/stylesheets/posts.scss b/app/assets/stylesheets/posts.scss
index 53e70c89896fbc09b611c1a4655549970e5bf677..ed8df6017776e1d827dd7831088bf65cd936f965 100644
--- a/app/assets/stylesheets/posts.scss
+++ b/app/assets/stylesheets/posts.scss
@@ -75,3 +75,12 @@ h1 .badge.is-tag.is-master-tag {
     margin-bottom: 0;
   }
 }
+
+.post--title {
+  display: flex;
+  align-items: center;
+
+  > .badge {
+    margin-left: 0.5em;
+  }
+}
diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b4253e185eb1beadc400200cd19b652cc29e2b0c
--- /dev/null
+++ b/app/controllers/articles_controller.rb
@@ -0,0 +1,92 @@
+class ArticlesController < ApplicationController
+  before_action :set_article
+  before_action :check_article
+
+  def show
+    if @article.deleted?
+      check_your_privilege('ViewDeleted', @article) # || return
+    end
+  end
+
+  def share
+    redirect_to article_path(params[:id])
+  end
+
+  def edit
+    check_your_privilege('Edit', @article)
+  end
+
+  def update
+    return unless check_your_privilege('Edit', @article)
+
+    PostHistory.post_edited(@article, current_user, before: @article.body_markdown,
+                            after: params[:article][:body_markdown], comment: params[:edit_comment])
+    body_rendered = helpers.render_markdown(params[:article][:body_markdown])
+    if @article.update(article_params.merge(tags_cache: params[:article][:tags_cache]&.reject { |e| e.to_s.empty? },
+                                            body: body_rendered, last_activity: DateTime.now,
+                                            last_activity_by: current_user))
+      redirect_to article_path(@article)
+    else
+      render :edit
+    end
+  end
+
+  def destroy
+    unless check_your_privilege('Delete', @article, false)
+      flash[:danger] = 'You must have the Delete privilege to delete posts.'
+      redirect_to article_path(@article) && return
+    end
+
+    if @article.deleted
+      flash[:danger] = "Can't delete a deleted post."
+      redirect_to article_path(@article) && return
+    end
+
+    if @article.update(deleted: true, deleted_at: DateTime.now, deleted_by: current_user,
+                       last_activity: DateTime.now, last_activity_by: current_user)
+      PostHistory.post_deleted(@article, current_user)
+    else
+      flash[:danger] = "Can't delete this post right now. Try again later."
+    end
+    redirect_to article_path(@article)
+  end
+
+  def undelete
+    unless check_your_privilege('Delete', @article, false)
+      flash[:danger] = 'You must have the Delete privilege to undelete posts.'
+      redirect_to article_path(@article) && return
+    end
+
+    unless @article.deleted
+      flash[:danger] = "Can't undelete an undeleted post."
+      redirect_to article_path(@article) && return
+    end
+
+    if @article.update(deleted: false, deleted_at: nil, deleted_by: nil,
+                       last_activity: DateTime.now, last_activity_by: current_user)
+      PostHistory.post_undeleted(@article, current_user)
+    else
+      flash[:danger] = "Can't undelete this article right now. Try again later."
+    end
+    redirect_to article_path(@article)
+  end
+
+  private
+
+  def set_article
+    @article = Article.find params[:id]
+    if @article.deleted && !current_user&.has_post_privilege?('ViewDeleted', @article)
+      not_found
+    end
+  end
+
+  def check_article
+    unless @article.post_type_id == Article.post_type_id
+      not_found
+    end
+  end
+
+  def article_params
+    params.require(:article).permit(:body_markdown, :title, :tags_cache)
+  end
+end
diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb
index 1ff980842cf02ce20f341d88320eb1a5d66140e6..915a2c916d9415c759188e49471294fe26f90480 100644
--- a/app/controllers/comments_controller.rb
+++ b/app/controllers/comments_controller.rb
@@ -88,21 +88,10 @@ class CommentsController < ApplicationController
   def comment_link(comment)
     if comment.post.question?
       question_path(comment.post, anchor: "comment-#{comment.id}")
+    elsif comment.post.article?
+      article_path(comment.post, anchor: "comment-#{comment.id}")
     else
       question_path(comment.post.parent, anchor: "comment-#{comment.id}")
     end
   end
 end
-
-# Provides a custom HTML sanitization interface to use for cleaning up the HTML in questions.
-class CommentScrubber < Rails::Html::PermitScrubber
-  def initialize
-    super
-    self.tags = %w[a b i em strong strike del code]
-    self.attributes = %w[href title]
-  end
-
-  def skip_node?(node)
-    node.text?
-  end
-end
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index ed4091c9e9764c1d3972df3589f7b78442247aa2..0970ba6e38e5a50178b1e1fef08d40d2f31d6173 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -15,7 +15,8 @@ class PostsController < ApplicationController
 
   def create
     @category = Category.find(params[:category_id])
-    @post = Post.new(post_params.merge(category: @category, user: current_user, post_type_id: params[:post_type_id],
+    @post = Post.new(post_params.merge(category: @category, user: current_user,
+                                       post_type_id: params[:post][:post_type_id] || params[:post_type_id],
                                        body: helpers.render_markdown(params[:post][:body_markdown])))
 
     if @category.min_trust_level.present? && @category.min_trust_level > current_user.trust_level
@@ -25,7 +26,7 @@ class PostsController < ApplicationController
     end
 
     if @post.save
-      redirect_to question_path(@post)
+      redirect_to helpers.generic_show_link(@post)
     else
       render :new, status: 400
     end
diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb
index cf9ce5ba2a4a78291031710abbdcba12429ff8dc..8d478f8be87405909b732f52a395610939e9860e 100644
--- a/app/controllers/questions_controller.rb
+++ b/app/controllers/questions_controller.rb
@@ -65,7 +65,7 @@ class QuestionsController < ApplicationController
     PostHistory.post_edited(@question, current_user, before: @question.body_markdown,
                             after: params[:question][:body_markdown], comment: params[:edit_comment])
     body_rendered = helpers.render_markdown(params[:question][:body_markdown])
-    if @question.update(question_params.merge(tags_cache: params[:question][:tags_cache]&.reject(&:empty?),
+    if @question.update(question_params.merge(tags_cache: params[:question][:tags_cache]&.reject { |e| e.to_s.empty? },
                                               body: body_rendered, last_activity: DateTime.now,
                                               last_activity_by: current_user))
       redirect_to url_for(controller: :questions, action: :show, id: @question.id)
@@ -191,11 +191,15 @@ class QuestionsController < ApplicationController
   def set_question
     @question = Question.find params[:id]
   rescue
-    if current_user.has_privilege?('ViewDeleted')
+    if current_user&.has_privilege?('ViewDeleted')
       @question ||= Question.unscoped.find params[:id]
     end
     if @question.nil?
-      render template: 'errors/not_found', status: 404
+      not_found
+      return
+    end
+    unless @question.post_type_id == Question.post_type_id
+      not_found
     end
   end
 end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 78fdcce45132c7df952c632da59aba0371812e79..ef7d04e6b1d9e95dcca2a06fffab14eee7bb0402 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -75,4 +75,41 @@ module ApplicationHelper
 
     markdown
   end
+
+  def generic_share_link(post)
+    case post.post_type_id
+    when Question.post_type_id
+      share_question_url(post)
+    when Answer.post_type_id
+      share_answer_url(qid: post.parent_id, id: post.id)
+    when Article.post_type_id
+      share_article_url(post)
+    else
+      '#'
+    end
+  end
+
+  def generic_edit_link(post)
+    case post.post_type_id
+    when Question.post_type_id
+      edit_question_url(post)
+    when Answer.post_type_id
+      edit_answer_url(post)
+    when Article.post_type_id
+      edit_article_url(post)
+    else
+      '#'
+    end
+  end
+
+  def generic_show_link(post)
+    case post.post_type_id
+    when Question.post_type_id
+      question_url(post)
+    when Article.post_type_id
+      article_url(post)
+    else
+      '#'
+    end
+  end
 end
diff --git a/app/helpers/articles_helper.rb b/app/helpers/articles_helper.rb
new file mode 100644
index 0000000000000000000000000000000000000000..296827759598a2dd2074b82c6c9e5f6e40f3f0fb
--- /dev/null
+++ b/app/helpers/articles_helper.rb
@@ -0,0 +1,2 @@
+module ArticlesHelper
+end
diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb
index bb87154d11feab1ad4da2fb89892b1fe8403085a..cfb31edf2eab4ec8428d8149fc71958cc64e9572 100644
--- a/app/helpers/categories_helper.rb
+++ b/app/helpers/categories_helper.rb
@@ -8,7 +8,8 @@ module CategoriesHelper
   def expandable?
     (defined?(@category) && !current_page?(new_category_path)) ||
       (defined?(@post) && !@post.category.nil?) ||
-      (defined?(@question) && !@question.category.nil?)
+      (defined?(@question) && !@question.category.nil?) ||
+      (defined?(@article) && !@article.category.nil?)
   end
 
   def current_category
@@ -18,6 +19,8 @@ module CategoriesHelper
                             @post.category
                           elsif defined?(@question) && !@question.category.nil?
                             @question.category
+                          elsif defined?(@article) && !@article.category.nil?
+                            @article.category
                           end
   end
 end
diff --git a/app/helpers/comments_helper.rb b/app/helpers/comments_helper.rb
index 0ec9ca5f2dc766c09e48fb187a9c1b0252e8e7c1..1feb966f4393b5cb8a8d034a8aadea799325c88e 100644
--- a/app/helpers/comments_helper.rb
+++ b/app/helpers/comments_helper.rb
@@ -1,2 +1,14 @@
 module CommentsHelper
 end
+
+class CommentScrubber < Rails::Html::PermitScrubber
+  def initialize
+    super
+    self.tags = %w[a b i em strong strike del code]
+    self.attributes = %w[href title]
+  end
+
+  def skip_node?(node)
+    node.text?
+  end
+end
diff --git a/app/helpers/post_types_helper.rb b/app/helpers/post_types_helper.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ddd6bc36541b12bdb19f9257161b54300e66f1a4
--- /dev/null
+++ b/app/helpers/post_types_helper.rb
@@ -0,0 +1,12 @@
+module PostTypesHelper
+  def post_type_badge(type)
+    icon_class = {
+      'Question' => 'fas fa-question',
+      'Article' => 'fas fa-newspaper'
+    }[type]
+    tag.span class: 'badge is-tag is-filled is-muted' do
+      tag.i(class: icon_class) + ' ' +
+        tag.span(type)
+    end
+  end
+end
diff --git a/app/models/article.rb b/app/models/article.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f5cb2f4d5fadfb897caa20437a79fb57b7105654
--- /dev/null
+++ b/app/models/article.rb
@@ -0,0 +1,7 @@
+class Article < Post
+  default_scope { where(post_type_id: Article.post_type_id) }
+
+  def self.post_type_id
+    PostType.mapping['Article']
+  end
+end
diff --git a/app/models/post.rb b/app/models/post.rb
index 036a493110526653c323d90d55d870d74c1d33ae..601c7189efd6148547d5c1dd20f8d25572c1e3ed 100644
--- a/app/models/post.rb
+++ b/app/models/post.rb
@@ -23,15 +23,15 @@ class Post < ApplicationRecord
 
   validates :body, presence: true, length: { minimum: 30, maximum: 30_000 }
   validates :doc_slug, uniqueness: { scope: [:community_id] }, if: -> { doc_slug.present? }
-  validates :title, :body, :tags_cache, presence: true, if: :question?
-  validate :tags_in_tag_set, if: :question?
-  validate :maximum_tags, if: :question?
-  validate :maximum_tag_length, if: :question?
-  validate :no_spaces_in_tags, if: :question?
-  validate :stripped_minimum, if: :question?
+  validates :title, :body, :tags_cache, presence: true, if: -> { question? || article? }
+  validate :tags_in_tag_set, if: -> { question? || article? }
+  validate :maximum_tags, if: -> { question? || article? }
+  validate :maximum_tag_length, if: -> { question? || article? }
+  validate :no_spaces_in_tags, if: -> { question? || article? }
+  validate :stripped_minimum, if: -> { question? || article? }
   validate :category_allows_post_type
   validate :license_available
-  validate :required_tags?, if: -> { post_type_id == Question.post_type_id }
+  validate :required_tags?, if: -> { question? || article? }
 
   scope :undeleted, -> { where(deleted: false) }
   scope :deleted, -> { where(deleted: true) }
@@ -42,8 +42,8 @@ class Post < ApplicationRecord
   after_save :modify_author_reputation
   after_save :copy_last_activity_to_parent
   after_save :break_description_cache
-  after_save :update_category_activity, if: :question?
-  before_validation :update_tag_associations, if: :question?
+  after_save :update_category_activity, if: -> { question? || article? }
+  before_validation :update_tag_associations, if: -> { question? || article? }
   after_create :create_initial_revision
   after_create :add_license_if_nil
 
@@ -53,7 +53,7 @@ class Post < ApplicationRecord
 
   # Double-define: initial definitions are less efficient, so if we have a record of the post type we'll
   # override them later with more efficient methods.
-  ['Question', 'Answer', 'PolicyDoc', 'HelpDoc'].each do |pt|
+  ['Question', 'Answer', 'PolicyDoc', 'HelpDoc', 'Article'].each do |pt|
     define_method "#{pt.underscore}?" do
       post_type_id == pt.constantize.post_type_id
     end
diff --git a/app/models/vote.rb b/app/models/vote.rb
index fe5eb01fa6d9aca236c64f9da92f6eb93067eea9..a94767d0b7675dd277e385691b7fcba330145cd5 100644
--- a/app/models/vote.rb
+++ b/app/models/vote.rb
@@ -17,7 +17,8 @@ class Vote < ApplicationRecord
   def self.total_rep_change(col)
     col = col.includes(:post)
     settings = SiteSetting.where(name: ['QuestionUpVoteRep', 'QuestionDownVoteRep',
-                                        'AnswerUpVoteRep', 'AnswerDownVoteRep'])
+                                        'AnswerUpVoteRep', 'AnswerDownVoteRep',
+                                        'ArticleUpVoteRep', 'ArticleDownVoteRep'])
                           .map { |ss| [ss.name, ss.value] }.to_h
     rep_changes = PostType.mapping.map do |k, v|
       vote_types = { 1 => 'Up', -1 => 'Down' }
@@ -45,7 +46,9 @@ class Vote < ApplicationRecord
       [post_type_ids['Question'], 1] => 'QuestionUpVoteRep',
       [post_type_ids['Question'], -1] => 'QuestionDownVoteRep',
       [post_type_ids['Answer'], 1] => 'AnswerUpVoteRep',
-      [post_type_ids['Answer'], -1] => 'AnswerDownVoteRep'
+      [post_type_ids['Answer'], -1] => 'AnswerDownVoteRep',
+      [post_type_ids['Article'], 1] => 'ArticleUpVoteRep',
+      [post_type_ids['Article'], -1] => 'ArticleDownVoteRep'
     }
     rep_change = SiteSetting[setting_names[[post.post_type_id, vote_type]]] || 0
     recv_user.update!(reputation: recv_user.reputation + direction * rep_change)
diff --git a/app/views/articles/_form.html.erb b/app/views/articles/_form.html.erb
new file mode 100644
index 0000000000000000000000000000000000000000..bb9878bdeecde21fec65feb7a6381cf49f01a893
--- /dev/null
+++ b/app/views/articles/_form.html.erb
@@ -0,0 +1,47 @@
+<%= render 'posts/markdown_script' %>
+
+<% if @article.errors.any? %>
+  <div class="notice is-danger is-filled">
+    The following errors prevented this post from being saved:
+    <ul>
+      <% @article.errors.full_messages.each do |msg| %>
+        <li><%= msg %></li>
+      <% end %>
+    </ul>
+  </div>
+<% end %>
+
+<%= render 'posts/image_upload' %>
+
+<%= form_for @article, url: edit_article_path(@article) do |f| %>
+  <div class="form-group">
+    <%= f.label :title, "Title your post:", class: "form-element" %>
+    <%= f.text_field :title, class: "form-element" %>
+  </div>
+
+  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body' %>
+
+  <div class="post-preview"></div>
+
+  <div class="form-group">
+    <%= f.label :tags_cache, "Tags", class: "form-element" %>
+    <div class="form-caption">
+      Tags help to categorize posts. Separate them by space. Use hyphens for multiple-word tags.
+    </div>
+    <%= f.select :tags_cache, options_for_select(@article.tags_cache.map { |t| [t, t] }, selected: @article.tags_cache),
+                 { include_blank: true }, multiple: true, class: "form-element js-tag-select",
+                 data: { tag_set: @article.category.tag_set.id } %>
+  </div>
+
+  <div class="form-group">
+    <%= label_tag :edit_comment, 'Edit comment', class: "form-element" %>
+    <div class="form-caption">
+      Describe&mdash;if necessary&mdash;what you are changing and why you are making this edit.
+    </div>
+    <%= text_field_tag :edit_comment, params[:edit_comment], class: 'form-element' %>
+  </div>
+
+  <div class="form-group">
+    <%= f.submit 'Save', class: "button is-filled" %><br/>
+  </div>
+<% end %>
diff --git a/app/views/articles/edit.html.erb b/app/views/articles/edit.html.erb
new file mode 100644
index 0000000000000000000000000000000000000000..6727a85a707a9f1bfb920d6498c26629bb9bfc8f
--- /dev/null
+++ b/app/views/articles/edit.html.erb
@@ -0,0 +1 @@
+<%= render 'form', is_edit: true %>
\ No newline at end of file
diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb
new file mode 100644
index 0000000000000000000000000000000000000000..0e822e29813c0880ad55d59597498b428df627cc
--- /dev/null
+++ b/app/views/articles/show.html.erb
@@ -0,0 +1,8 @@
+<% content_for :title, @article.title.truncate(50) %>
+<% content_for :description do %>
+  <% Rails.cache.fetch "posts/#{@article.id}/description" do %>
+    <%= @article.body_plain[0..74].strip %>...
+  <% end %>
+<% end %>
+
+<%= render 'posts/expanded', post: @article %>
\ No newline at end of file
diff --git a/app/views/posts/_article_list.html.erb b/app/views/posts/_article_list.html.erb
new file mode 100644
index 0000000000000000000000000000000000000000..237edc9cf67c596e45d4839992557eb26c84513f
--- /dev/null
+++ b/app/views/posts/_article_list.html.erb
@@ -0,0 +1,30 @@
+<% active_user = post.last_activity_by || post.user %>
+<div class="item-list--item <%= post.deleted ? 'deleted-content' : '' %>">
+  <div class="item-list--number-value">
+    <span class="item-list--number"><%= post.score %></span>
+    <span class="item-list--number-label">score</span>
+  </div>
+  <div class="item-list--text-value is-primary">
+    <div class="post-list--title">
+      <%= link_to post.title, share_article_path(post) %>
+    </div>
+    <p class="has-color-tertiary-600 has-float-right post-list--meta">
+      last activity <%= time_ago_in_words(post.last_activity) %> ago by <%= link_to active_user.username, user_path(active_user) %>
+    </p>
+    <div class="has-padding-top-2">
+      <% category = defined?(@category) ? @category : post.category %>
+      <% if category.display_post_types.reject { |e| e.to_s.empty? }.size > 1 %>
+        <%= post_type_badge(post.post_type.name) %>
+      <% end %>
+      <% tag_set = post.tag_set %>
+      <% required_ids = category&.required_tag_ids %>
+      <% topic_ids = category&.topic_tag_ids %>
+      <% category_sort_tags(post.tags, 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 %>
+    </div>
+  </div>
+</div>
diff --git a/app/views/posts/_expanded.html.erb b/app/views/posts/_expanded.html.erb
index 7c40682cdf1e3f29fdb411a79fe4b1baea35e53b..7e6c076f66fbd1cc889f0310e20ac54a9ad4e8e5 100644
--- a/app/views/posts/_expanded.html.erb
+++ b/app/views/posts/_expanded.html.erb
@@ -1,13 +1,16 @@
 <% is_question = post.post_type_id == Question.post_type_id %>
+<% is_top_level = post.parent_id.nil? %>
+<% has_tags = is_top_level && !post.tag_ids.empty? %>
 
-<div class="post <%= post.meta? ? 'is-meta' : '' %> <%= is_question ? '' : 'has-border-bottom-style-solid has-border-bottom-width-1 has-border-color-tertiary-100' %>" data-post-id="<%= post.id %>" id="<%= (is_question ? 'question-' : 'answer-') + post.id.to_s %>">
-  <% if is_question %>
+<div class="post <%= post.meta? ? 'is-meta' : '' %> <%= is_top_level ? '' : 'has-border-bottom-style-solid has-border-bottom-width-1 has-border-color-tertiary-100' %>" data-post-id="<%= post.id %>" id="<%= (is_question ? 'question-' : 'answer-') + post.id.to_s %>">
+  <% if is_top_level %>
     <h1 class="post--title has-border-top-width-4 has-border-top-style-solid has-border-color-<%= post.meta? ? 'tertiary' : 'primary' %>-400 has-padding-2">
-      <% if post.meta? %>
-        <span class="badge is-tag is-master-tag">meta</span>
-      <% end %>
       <%= post.title %>
-      <%= post.closed ? "[closed]" : "" %>
+      <%= is_question && post.closed ? "[closed]" : "" %>
+      <% category = defined?(@category) ? @category : post.category %>
+      <% if category.display_post_types.reject { |e| e.to_s.empty? }.size > 1 %>
+        <%= post_type_badge(post.post_type.name) %>
+      <% end %>
     </h1>
   <% end %>
 
@@ -95,7 +98,7 @@
               <div class="has-clear-clear"></div>
             </div>
           </div>
-          <% if is_question %>
+          <% if has_tags %>
             <div class="post--tags has-padding-2">
               <% tag_set = post.tag_set %>
               <% required_ids = post.category&.required_tag_ids %>
@@ -121,18 +124,18 @@
 
         <div class="post--actions">
           <%= link_to 'history', post_history_path(post) %> &middot;
-          <%= link_to 'edit', is_question ? edit_question_path(post) : edit_answer_path(post) %> &middot;
-          <%= link_to 'permalink', is_question ? share_question_path(post) : share_answer_path(qid: post.parent_id, id: post.id) %> &middot;
+          <%= link_to 'edit', generic_edit_link(post) %> &middot;
+          <%= link_to 'permalink', generic_share_link(post) %> &middot;
           <% if is_question && !post.closed %>
             <a href="#" class="close-dialog-link">close</a> &middot;
           <% elsif is_question && post.closed %>
             <%= link_to 'reopen', reopen_question_path(post), method: :post, class: 'reopen-question' %> &middot;
           <% end %>
           <% if !post.deleted %>
-            <%= link_to 'delete', url_for(controller: is_question ? :questions : :answers, action: :destroy, id: post.id),
+            <%= link_to 'delete', url_for(controller: post.post_type.name.pluralize.downcase.to_sym, action: :destroy, id: post.id),
                         method: :delete, data: { confirm: 'Are you sure you want to delete this post?' }, class: "is-red" %>
           <% else %>
-            <%= link_to 'undelete', url_for(controller: is_question ? :questions : :answers, action: :undelete, id: post.id),
+            <%= link_to 'undelete', url_for(controller: post.post_type.name.pluralize.downcase.to_sym, action: :undelete, id: post.id),
                         method: :post, data: { confirm: 'Undelete this question, making it visible to regular users?' }, class: "is-red" %>
           <% end %> &middot;
           <a href="#" class="flag-dialog-link">flag</a>
diff --git a/app/views/posts/_form.html.erb b/app/views/posts/_form.html.erb
index 7bc27bc8712a123c252a98e974fc3339c1eaf5c2..6e71b2d99b300b1136b8946fe95abf6a4ee98339 100644
--- a/app/views/posts/_form.html.erb
+++ b/app/views/posts/_form.html.erb
@@ -1,3 +1,5 @@
+<% with_post_type ||= false %>
+
 <% content_for :head do %>
   <%= render 'posts/markdown_script' %>
 <% end %>
@@ -25,7 +27,20 @@
 
 <%= form_for @post, url: submit_path, html: { class: 'has-margin-top-4' } do |f| %>
   <%= f.hidden_field :category_id %>
-  <%= f.hidden_field :post_type_id %>
+
+  <% if with_post_type %>
+    <div class="form-group">
+      <%= f.label :post_type_id, 'Post type', class: 'form-element' %>
+      <span class="form-caption">What kind of post is this? Questions can have answers; articles only have comments.</span>
+      <% ids = @category.display_post_types.reject { |e| e.to_s.empty? } %>
+      <% post_types = PostType.where(id: ids) %>
+      <% opts = post_types.map { |pt| [pt.name, pt.id] } %>
+      <%= f.select :post_type_id, options_for_select(opts, selected: @post.post_type_id),
+                   { include_blank: true }, class: 'form-element' %>
+    </div>
+  <% else %>
+    <%= f.hidden_field :post_type_id %>
+  <% end %>
 
   <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body' %>
 
diff --git a/app/views/posts/_list.html.erb b/app/views/posts/_list.html.erb
index 57312cf0c21a176582f05a21207403a3441e5d0b..e72067a300ca5fd5f66c3bd5541141f336a4ec17 100644
--- a/app/views/posts/_list.html.erb
+++ b/app/views/posts/_list.html.erb
@@ -1,5 +1,5 @@
 <% is_question = post.post_type_id == Question.post_type_id %>
-<% is_meta = (is_question && post.meta?) || (!is_question && post.parent.meta?) %>
+<% is_meta = (is_question && post.meta?) || (!is_question && post.parent&.meta?) %>
 <% active_user = post.last_activity_by || post.user %>
 <div class="item-list--item <%= is_meta ? 'post__meta' : '' %> <%= post.deleted ? 'deleted-content' : '' %>">
   <div class="item-list--number-value">
@@ -25,10 +25,14 @@
       last activity <%= time_ago_in_words(post.last_activity) %> ago by <%= link_to active_user.username, user_path(active_user) %>	
     </p>
     <div class="has-padding-top-2">
+      <% category = defined?(@category) ? @category : post.category %>
+      <% if category.display_post_types.reject { |e| e.to_s.empty? }.size > 1 %>
+        <%= post_type_badge(post.post_type.name) %>
+      <% end %>
       <% if is_question %>
         <% tag_set = post.tag_set %>
-        <% required_ids = defined?(@category) ? @category&.required_tag_ids : post.category&.required_tag_ids %>
-        <% topic_ids = defined?(@category) ? @category&.topic_tag_ids : post.category&.topic_tag_ids %>
+        <% required_ids = category&.required_tag_ids %>
+        <% topic_ids = category&.topic_tag_ids %>
         <% category_sort_tags(post.tags, required_ids, topic_ids).each do |tag| %>
           <% required = required_ids&.include? tag.id %>
           <% topic = topic_ids&.include? tag.id %>
diff --git a/app/views/posts/_type_agnostic.html.erb b/app/views/posts/_type_agnostic.html.erb
index 9fb034ba784f0b6b2a7db05c9be7de22a2940dd4..2ac0c70f6f6e4efeee4fb75ebf373c03a0a9926b 100644
--- a/app/views/posts/_type_agnostic.html.erb
+++ b/app/views/posts/_type_agnostic.html.erb
@@ -3,7 +3,8 @@
     'Question' => 'posts/list',
     'Answer' => 'posts/list',
     'PolicyDoc' => 'posts/document',
-    'HelpDoc' => 'posts/document'
+    'HelpDoc' => 'posts/document',
+    'Article' => 'posts/article_list'
   }
 %>
 <%= render post_types_views[post.post_type.name], post: post %>
\ No newline at end of file
diff --git a/app/views/posts/new.html.erb b/app/views/posts/new.html.erb
index 09a9d727b3074245fefac79f94073a6d083f11c3..1db9ccf3fdc9292ecaef382020eb94e25d15532c 100644
--- a/app/views/posts/new.html.erb
+++ b/app/views/posts/new.html.erb
@@ -1,4 +1,5 @@
 <h1 class="has-margin-bottom-2">New Post in <%= @category.name %></h1>
 <p class="has-color-tertiary-500">Not where you meant to post? See <%= link_to 'Categories', categories_path %></p>
 
-<%= render 'form', submit_path: create_post_path(@category.id) %>
\ No newline at end of file
+<%= render 'form', with_post_type: @category.display_post_types.reject { |e| e.to_s.empty? }.size > 1,
+           submit_path: create_post_path(@category.id) %>
\ No newline at end of file
diff --git a/app/views/questions/tagged.html.erb b/app/views/questions/tagged.html.erb
index 63bfaafc4ba5e91d0b85c2a1557c648fd3844cf3..2aa7fe81eff2e4dc180ee7291349292ada569157 100644
--- a/app/views/questions/tagged.html.erb
+++ b/app/views/questions/tagged.html.erb
@@ -4,7 +4,7 @@
 
 <div class="item-list">
   <% @questions.each do |question| %>
-    <%= render 'posts/list', post: question %>
+    <%= render 'posts/type_agnostic', post: question %>
   <% end %>
 </div><br/>
 
diff --git a/config/routes.rb b/config/routes.rb
index b7efba50617827b1d4e62f26657282898ea3d45d..af9ed46242e21bdaffb45d8ff520427d48fc4666 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -68,6 +68,14 @@ Rails.application.routes.draw do
   post   'questions/:id/close',            to: 'questions#close', as: :close_question
   post   'questions/:id/reopen',           to: 'questions#reopen', as: :reopen_question
 
+  scope 'articles' do
+    get    ':id',                          to: 'articles#show', as: :article
+    get    ':id/edit',                     to: 'articles#edit', as: :edit_article
+    patch  ':id/edit',                     to: 'articles#update', as: :update_article
+    delete ':id/delete',                   to: 'articles#destroy', as: :destroy_article
+    delete ':id/undelete',                 to: 'articles#undelete', as: :undelete_article
+  end
+
   get    'posts/:id/history',              to: 'post_history#post', as: :post_history
   get    'posts/search',                   to: 'search#search', as: :search
   post   'posts/upload',                   to: 'posts#upload', as: :upload
@@ -135,6 +143,7 @@ Rails.application.routes.draw do
 
   get    'q/:id',                          to: 'posts#share_q', as: :share_question
   get    'a/:qid/:id',                     to: 'posts#share_a', as: :share_answer
+  get    'ar/:id',                         to: 'articles#share', as: :share_article
 
   get    'subscriptions/new/:type',        to: 'subscriptions#new', as: :new_subscription
   post   'subscriptions/new',              to: 'subscriptions#create', as: :create_subscription
diff --git a/db/seeds/post_types.yml b/db/seeds/post_types.yml
index c957d9cfdbc7c58bbc94c096da64450bc176f303..ae17f34eef894a188f109dc6cd9a68a9cd6b486d 100644
--- a/db/seeds/post_types.yml
+++ b/db/seeds/post_types.yml
@@ -1,4 +1,5 @@
 - name: Question
 - name: Answer
 - name: PolicyDoc
-- name: HelpDoc
\ No newline at end of file
+- name: HelpDoc
+- name: Article
\ No newline at end of file
diff --git a/db/seeds/site_settings.yml b/db/seeds/site_settings.yml
index 145846f65a443421073968c73d868f1e32154773..bcf88b9f433737b105cea09b1c2d3e02fe181b14 100644
--- a/db/seeds/site_settings.yml
+++ b/db/seeds/site_settings.yml
@@ -54,6 +54,20 @@
   description: >
     The reputation change to the post's author when an answer is downvoted.
 
+- name: ArticleUpVoteRep
+  value: 10
+  value_type: integer
+  category: ReputationAndVoting
+  description: >
+    The reputation change to the post's author when an article is upvoted.
+
+- name: ArticleDownVoteRep
+  value: -2
+  value_type: integer
+  category: ReputationAndVoting
+  description: >
+    The reputation change to the post's author when an article is downvoted.
+
 - name: AllowSelfVotes
   value: false
   value_type: boolean
diff --git a/test/controllers/articles_controller_test.rb b/test/controllers/articles_controller_test.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f431f0687565af6c18be9b36eaf8053e34b97c2e
--- /dev/null
+++ b/test/controllers/articles_controller_test.rb
@@ -0,0 +1,57 @@
+require 'test_helper'
+
+class ArticlesControllerTest < ActionController::TestCase
+  include Devise::Test::ControllerHelpers
+
+  test 'should get show article page' do
+    get :show, params: { id: posts(:article_one).id }
+    assert_not_nil assigns(:article)
+    assert_response(200)
+  end
+
+  test 'should get show article page with deleted article' do
+    sign_in users(:deleter)
+    get :show, params: { id: posts(:deleted_article).id }
+    assert_not_nil assigns(:article)
+    assert_response(200)
+  end
+
+  test 'should prevent unprivileged user seeing deleted post' do
+    get :show, params: { id: posts(:deleted_article).id }
+    assert_response 404
+  end
+
+  test 'should get edit article page' do
+    sign_in users(:editor)
+    get :edit, params: { id: posts(:article_one).id }
+    assert_not_nil assigns(:article)
+    assert_response(200)
+  end
+
+  test 'should update existing article' do
+    sign_in users(:editor)
+    patch :update, params: { id: posts(:article_one).id, article: { title: 'ABCDEF GHIJKL MNOPQR',
+                                                                    body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ',
+                                                                    tags_cache: ['discussion', 'support'] } }
+    assert_not_nil assigns(:article)
+    assert_equal ['discussion', 'support'], assigns(:article).tags_cache
+    assert_equal ['discussion', 'support'], assigns(:article).tags.map(&:name)
+    assert_response(302)
+  end
+
+  test 'should mark article deleted' do
+    sign_in users(:deleter)
+    delete :destroy, params: { id: posts(:article_one).id }
+    assert_not_nil assigns(:article)
+    assert_equal true, assigns(:article).deleted
+    assert_response(302)
+  end
+
+  test 'should mark article undeleted' do
+    sign_in users(:deleter)
+    delete :undelete, params: { id: posts(:deleted_article).id }
+    assert_not_nil assigns(:article)
+    assert_equal false, assigns(:article).deleted
+    assert_response(302)
+  end
+end
diff --git a/test/controllers/moderator_controller_test.rb b/test/controllers/moderator_controller_test.rb
index 9350bc885e3a34f0ea2bf6a082dae895f550cd9b..8528ecc9605b2dcb4e8145ca02f546bab84e9c5e 100644
--- a/test/controllers/moderator_controller_test.rb
+++ b/test/controllers/moderator_controller_test.rb
@@ -9,46 +9,6 @@ class ModeratorControllerTest < ActionController::TestCase
     assert_response(200)
   end
 
-  test 'should get recently deleted questions' do
-    sign_in users(:moderator)
-    get :recently_deleted_questions
-    assert_not_nil assigns(:questions)
-    assigns(:questions).each do |question|
-      assert_equal true, question.deleted
-    end
-    assert_response(200)
-  end
-
-  test 'should get recently deleted answers' do
-    sign_in users(:moderator)
-    get :recently_deleted_answers
-    assert_not_nil assigns(:answers)
-    assigns(:answers).each do |answer|
-      assert_equal true, answer.deleted
-    end
-    assert_response(200)
-  end
-
-  test 'should get recently undeleted questions' do
-    sign_in users(:moderator)
-    get :recently_undeleted_questions
-    assert_not_nil assigns(:questions)
-    assigns(:questions).each do |question|
-      assert_equal false, question.deleted
-    end
-    assert_response(200)
-  end
-
-  test 'should get recently undeleted answers' do
-    sign_in users(:moderator)
-    get :recently_undeleted_answers
-    assert_not_nil assigns(:answers)
-    assigns(:answers).each do |answer|
-      assert_equal false, answer.deleted
-    end
-    assert_response(200)
-  end
-
   test 'should require authentication to access pages' do
     sign_out :user
     [:index, :recently_deleted_answers, :recently_deleted_questions, :recently_undeleted_answers,
diff --git a/test/controllers/questions_controller_test.rb b/test/controllers/questions_controller_test.rb
index 32cf644c5eff6a612784503da02b765b3cc6eec7..9ca6af0f0ab5a429b7b5c8bbc0f591e4e29dffc0 100644
--- a/test/controllers/questions_controller_test.rb
+++ b/test/controllers/questions_controller_test.rb
@@ -189,4 +189,10 @@ class QuestionsControllerTest < ActionController::TestCase
     assert_not_nil flash[:danger]
     assert_response(302)
   end
+
+  test 'should prevent using questions routes for articles' do
+    sign_in users(:deleter) # deliberate; catch ViewDeleted using unscoped
+    get :show, params: { id: posts(:article_one).id }
+    assert_response 404
+  end
 end
diff --git a/test/fixtures/categories.yml b/test/fixtures/categories.yml
index 53bae5c6fa99f7cc6f3250afea5b639d9c5ad030..df944e28aa218c38d286359f66d756564a21fe96 100644
--- a/test/fixtures/categories.yml
+++ b/test/fixtures/categories.yml
@@ -6,9 +6,11 @@ main:
   short_wiki: Main Q&A
   display_post_types:
     - <%= Question.post_type_id %>
+    - <%= Article.post_type_id %>
   post_types:
     - question
     - answer
+    - article
   tag_set: main
   license: cc_by_sa
 
diff --git a/test/fixtures/post_types.yml b/test/fixtures/post_types.yml
index 4736a9e20f7916d48956eaff2a7958184c15192d..71c578bf15cd658dd8bb260ec97eaa0f725e874c 100644
--- a/test/fixtures/post_types.yml
+++ b/test/fixtures/post_types.yml
@@ -4,6 +4,9 @@ question:
 answer:
   name: Answer
 
+article:
+  name: Article
+
 policy_doc:
   name: PolicyDoc
 
diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml
index e8b53a4dded8c8f450a73c94f8ac39c1983275a0..3039cfa852186945c6a05201a853e27cc5d9a939 100644
--- a/test/fixtures/posts.yml
+++ b/test/fixtures/posts.yml
@@ -147,3 +147,44 @@ help_doc:
   user: admin
   community: sample
   license: cc_by_sa
+
+article_one:
+  post_type: article
+  title: Q1 ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  tags_cache:
+    - discussion
+    - support
+    - bug
+  tags:
+    - discussion
+    - support
+    - bug
+  score: 0
+  user: standard_user
+  community: sample
+  category: main
+  license: cc_by_sa
+
+deleted_article:
+  post_type: article
+  title: Q1 ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  tags_cache:
+    - discussion
+    - support
+    - bug
+  tags:
+    - discussion
+    - support
+    - bug
+  score: 0
+  user: standard_user
+  community: sample
+  category: main
+  license: cc_by_sa
+  deleted: true
+  deleted_at: 2019-01-01T00:00:00.000000Z
+  deleted_by: admin