diff --git a/app/assets/javascripts/discourse/app/lib/topic-fancy-title.js b/app/assets/javascripts/discourse/app/lib/topic-fancy-title.js
new file mode 100644
index 00000000000..1da4ddaad8e
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/topic-fancy-title.js
@@ -0,0 +1,18 @@
+import Site from "discourse/models/site";
+import { censor } from "pretty-text/censored-words";
+import { emojiUnescape } from "discourse/lib/text";
+import { isRTL } from "discourse/lib/text-direction";
+
+export function fancyTitle(title, supportMixedTextDirection) {
+  let fancyTitle = censor(
+    emojiUnescape(title) || "",
+    Site.currentProp("censored_regexp")
+  );
+
+  if (supportMixedTextDirection) {
+    const titleDir = isRTL(title) ? "rtl" : "ltr";
+    return `<span dir="${titleDir}">${fancyTitle}</span>`;
+  }
+
+  return fancyTitle;
+}
diff --git a/app/assets/javascripts/discourse/app/models/bookmark.js b/app/assets/javascripts/discourse/app/models/bookmark.js
index 1fb54720f8c..51da517b8c4 100644
--- a/app/assets/javascripts/discourse/app/models/bookmark.js
+++ b/app/assets/javascripts/discourse/app/models/bookmark.js
@@ -2,10 +2,7 @@ import getURL from "discourse-common/lib/get-url";
 import I18n from "I18n";
 import Category from "discourse/models/category";
 import User from "discourse/models/user";
-import { isRTL } from "discourse/lib/text-direction";
-import { censor } from "pretty-text/censored-words";
-import { emojiUnescape } from "discourse/lib/text";
-import Site from "discourse/models/site";
+import { fancyTitle } from "discourse/lib/topic-fancy-title";
 import { longDate } from "discourse/lib/formatter";
 import { none } from "@ember/object/computed";
 import { computed } from "@ember/object";
@@ -78,16 +75,7 @@ const Bookmark = RestModel.extend({
 
   @discourseComputed("title")
   fancyTitle(title) {
-    let fancyTitle = censor(
-      emojiUnescape(title) || "",
-      Site.currentProp("censored_regexp")
-    );
-
-    if (this.siteSettings.support_mixed_text_direction) {
-      const titleDir = isRTL(title) ? "rtl" : "ltr";
-      return `<span dir="${titleDir}">${fancyTitle}</span>`;
-    }
-    return fancyTitle;
+    return fancyTitle(title, this.siteSettings.support_mixed_text_direction);
   },
 
   @discourseComputed("created_at")
diff --git a/app/assets/javascripts/discourse/app/models/post.js b/app/assets/javascripts/discourse/app/models/post.js
index 6c211a8fe44..f45a9668c1e 100644
--- a/app/assets/javascripts/discourse/app/models/post.js
+++ b/app/assets/javascripts/discourse/app/models/post.js
@@ -16,6 +16,7 @@ import { Promise } from "rsvp";
 import Site from "discourse/models/site";
 import User from "discourse/models/user";
 import showModal from "discourse/lib/show-modal";
+import { fancyTitle } from "discourse/lib/topic-fancy-title";
 
 const Post = RestModel.extend({
   @discourseComputed("url")
@@ -102,6 +103,19 @@ const Post = RestModel.extend({
     );
   },
 
+  @discourseComputed(
+    "siteSettings.use_pg_headlines_for_excerpt",
+    "topic_title_headline"
+  )
+  useTopicTitleHeadline(enabled, title) {
+    return enabled && title;
+  },
+
+  @discourseComputed("topic_title_headline")
+  topicTitleHead(title) {
+    return fancyTitle(title, this.siteSettings.support_mixed_text_direction);
+  },
+
   afterUpdate(res) {
     if (res.category) {
       this.site.updateCategory(res.category);
diff --git a/app/assets/javascripts/discourse/app/models/topic.js b/app/assets/javascripts/discourse/app/models/topic.js
index 5815893c99f..e3e6b01f620 100644
--- a/app/assets/javascripts/discourse/app/models/topic.js
+++ b/app/assets/javascripts/discourse/app/models/topic.js
@@ -7,13 +7,12 @@ import { flushMap } from "discourse/models/store";
 import RestModel from "discourse/models/rest";
 import { propertyEqual, fmt } from "discourse/lib/computed";
 import { longDate } from "discourse/lib/formatter";
-import { isRTL } from "discourse/lib/text-direction";
 import ActionSummary from "discourse/models/action-summary";
 import { popupAjaxError } from "discourse/lib/ajax-error";
-import { censor } from "pretty-text/censored-words";
 import { emojiUnescape } from "discourse/lib/text";
 import PreloadStore from "discourse/lib/preload-store";
 import { userPath } from "discourse/lib/url";
+import { fancyTitle } from "discourse/lib/topic-fancy-title";
 import discourseComputed, {
   observes,
   on
@@ -119,16 +118,7 @@ const Topic = RestModel.extend({
 
   @discourseComputed("fancy_title")
   fancyTitle(title) {
-    let fancyTitle = censor(
-      emojiUnescape(title) || "",
-      Site.currentProp("censored_regexp")
-    );
-
-    if (this.siteSettings.support_mixed_text_direction) {
-      const titleDir = isRTL(title) ? "rtl" : "ltr";
-      return `<span dir="${titleDir}">${fancyTitle}</span>`;
-    }
-    return fancyTitle;
+    return fancyTitle(title, this.siteSettings.support_mixed_text_direction);
   },
 
   // returns createdAt if there's no bumped date
diff --git a/app/assets/javascripts/discourse/app/templates/full-page-search.hbs b/app/assets/javascripts/discourse/app/templates/full-page-search.hbs
index dedf5bed201..3f1657c013a 100644
--- a/app/assets/javascripts/discourse/app/templates/full-page-search.hbs
+++ b/app/assets/javascripts/discourse/app/templates/full-page-search.hbs
@@ -88,7 +88,15 @@
 
                   <a href={{result.url}} {{action "logClick" result.topic_id}} class="search-link">
                     {{topic-status topic=result.topic disableActions=true showPrivateMessageIcon=true}}
-                    <span class="topic-title">{{#highlight-search highlight=q}}{{html-safe result.topic.fancyTitle}}{{/highlight-search}}</span>
+                    <span class="topic-title">
+                      {{#if result.useTopicTitleHeadline}}
+                        {{html-safe result.topicTitleHead}}
+                      {{else}}
+                        {{#highlight-search highlight=q}}
+                          {{html-safe result.topic.fancyTitle}}
+                        {{/highlight-search}}
+                      {{/if}}
+                    </span>
                   </a>
 
                   <div class="search-category">
diff --git a/app/serializers/search_post_serializer.rb b/app/serializers/search_post_serializer.rb
index 0759dac792f..bca2f7e04eb 100644
--- a/app/serializers/search_post_serializer.rb
+++ b/app/serializers/search_post_serializer.rb
@@ -3,7 +3,19 @@
 class SearchPostSerializer < BasicPostSerializer
   has_one :topic, serializer: SearchTopicListItemSerializer
 
-  attributes :like_count, :blurb, :post_number
+  attributes :like_count, :blurb, :post_number, :topic_title_headline
+
+  def include_topic_title_headline?
+    if SiteSetting.use_pg_headlines_for_excerpt
+      object.topic_title_headline.present?
+    else
+      false
+    end
+  end
+
+  def topic_title_headline
+    object.topic_title_headline
+  end
 
   def blurb
     options[:result].blurb(object)
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 47d8d60383c..a325968330c 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -1796,6 +1796,7 @@ search:
   use_pg_headlines_for_excerpt:
     default: false
     hidden: true
+    client: true
   search_ranking_normalization:
     default: "0"
     hidden: true
diff --git a/lib/search.rb b/lib/search.rb
index d8f2e40bb3c..fe19f17937e 100644
--- a/lib/search.rb
+++ b/lib/search.rb
@@ -1166,10 +1166,15 @@ class Search
 
   def posts_scope(default_scope = Post.all)
     if SiteSetting.use_pg_headlines_for_excerpt
+      search_term = @term.present? ? PG::Connection.escape_string(@term) : nil
+      ts_config = default_ts_config
+
       default_scope
         .joins("INNER JOIN post_search_data pd ON pd.post_id = posts.id")
+        .joins("INNER JOIN topics t1 ON t1.id = posts.topic_id")
         .select(
-          "TS_HEADLINE(#{default_ts_config}, pd.raw_data, PLAINTO_TSQUERY(#{default_ts_config}, '#{@term.present? ? PG::Connection.escape_string(@term) : nil}'), 'ShortWord=0, MaxFragments=1, MinWords=50, MaxWords=51, StartSel=''<span class=\"#{HIGHLIGHT_CSS_CLASS}\">'', StopSel=''</span>''') AS headline",
+          "TS_HEADLINE(#{ts_config}, t1.fancy_title, PLAINTO_TSQUERY(#{ts_config}, '#{search_term}'), 'StartSel=''<span class=\"#{HIGHLIGHT_CSS_CLASS}\">'', StopSel=''</span>''') AS topic_title_headline",
+          "TS_HEADLINE(#{ts_config}, pd.raw_data, PLAINTO_TSQUERY(#{ts_config}, '#{search_term}'), 'ShortWord=0, MaxFragments=1, MinWords=50, MaxWords=51, StartSel=''<span class=\"#{HIGHLIGHT_CSS_CLASS}\">'', StopSel=''</span>''') AS headline",
           default_scope.arel.projections
         )
     else
diff --git a/spec/components/search_spec.rb b/spec/components/search_spec.rb
index 00ceb74a535..85c0183b8f2 100644
--- a/spec/components/search_spec.rb
+++ b/spec/components/search_spec.rb
@@ -422,7 +422,11 @@ describe Search do
       )
 
       expect(result.posts.map(&:id)).to contain_exactly(reply.id)
-      expect(result.blurb(result.posts.first)).to eq(expected_blurb)
+
+      post = result.posts.first
+
+      expect(result.blurb(post)).to eq(expected_blurb)
+      expect(post.topic_title_headline).to eq(topic.fancy_title)
     end
 
     it 'returns the right post and blurb for searches with phrase' do
diff --git a/spec/requests/search_controller_spec.rb b/spec/requests/search_controller_spec.rb
index 7a54ced1a24..45ff4eab6ad 100644
--- a/spec/requests/search_controller_spec.rb
+++ b/spec/requests/search_controller_spec.rb
@@ -101,6 +101,10 @@ describe SearchController do
     it "can search correctly" do
       SiteSetting.use_pg_headlines_for_excerpt = true
 
+      awesome_post_3 = Fabricate(:post,
+        topic: Fabricate(:topic, title: 'this is an awesome title')
+      )
+
       get "/search/query.json", params: {
         term: 'awesome'
       }
@@ -109,14 +113,26 @@ describe SearchController do
 
       data = response.parsed_body
 
-      expect(data['posts'].length).to eq(2)
-      expect(data['posts'][0]['id']).to eq(awesome_post_2.id)
-      expect(data['posts'][0]['blurb']).to eq("this is my really <span class=\"#{Search::HIGHLIGHT_CSS_CLASS}\">awesome</span> post")
-      expect(data['topics'][0]['id']).to eq(awesome_post_2.topic_id)
+      expect(data['posts'].length).to eq(3)
 
-      expect(data['posts'][1]['id']).to eq(awesome_post.id)
-      expect(data['posts'][1]['blurb']).to eq("this is my really <span class=\"#{Search::HIGHLIGHT_CSS_CLASS}\">awesome</span> post")
-      expect(data['topics'][1]['id']).to eq(awesome_post.topic_id)
+      expect(data['posts'][0]['id']).to eq(awesome_post_3.id)
+      expect(data['posts'][0]['blurb']).to eq(awesome_post_3.raw)
+      expect(data['posts'][0]['topic_title_headline']).to eq(
+        "This is an <span class=\"#{Search::HIGHLIGHT_CSS_CLASS}\">awesome</span> title"
+      )
+      expect(data['topics'][0]['id']).to eq(awesome_post_3.topic_id)
+
+      expect(data['posts'][1]['id']).to eq(awesome_post_2.id)
+      expect(data['posts'][1]['blurb']).to eq(
+        "this is my really <span class=\"#{Search::HIGHLIGHT_CSS_CLASS}\">awesome</span> post"
+      )
+      expect(data['topics'][1]['id']).to eq(awesome_post_2.topic_id)
+
+      expect(data['posts'][2]['id']).to eq(awesome_post.id)
+      expect(data['posts'][2]['blurb']).to eq(
+        "this is my really <span class=\"#{Search::HIGHLIGHT_CSS_CLASS}\">awesome</span> post"
+      )
+      expect(data['topics'][2]['id']).to eq(awesome_post.topic_id)
     end
 
     it "can search correctly with advanced search filters" do