diff --git a/frontend/src/metabase/lib/formatting.js b/frontend/src/metabase/lib/formatting.js
index d026a665fbc790fcf15f27e556513a45d5d27b64..77684b6f81cc6a0f2f6df2f6042168a822fee9e2 100644
--- a/frontend/src/metabase/lib/formatting.js
+++ b/frontend/src/metabase/lib/formatting.js
@@ -7,6 +7,9 @@ import Humanize from "humanize-plus";
 import React from "react";
 import { ngettext, msgid } from "c-3po";
 
+import Mustache from "mustache";
+import ReactMarkdown from "react-markdown";
+
 import ExternalLink from "metabase/components/ExternalLink.jsx";
 
 import {
@@ -437,21 +440,29 @@ function formatStringFallback(value: Value, options: FormattingOptions = {}) {
   return value;
 }
 
+const MARKDOWN_RENDERERS = {
+  // eslint-disable-next-line react/display-name
+  link: ({ href, children }) => (
+    <ExternalLink href={href}>{children}</ExternalLink>
+  ),
+};
+
 export function formatValue(value: Value, options: FormattingOptions = {}) {
-  const { prefix = "", suffix = "", jsx } = options;
   const formatted = formatValueRaw(value, options);
-  if (prefix || suffix) {
-    if (jsx && typeof formatted !== "string") {
-      return (
-        <span>
-          {prefix}
-          {formatted}
-          {suffix}
-        </span>
-      );
+  if (options.markdown_template) {
+    if (options.jsx) {
+      // inject the formatted value as "value" and the unformatted value as "raw"
+      const markdown = Mustache.render(options.markdown_template, {
+        value: formatted,
+        raw: value,
+      });
+      return <ReactMarkdown source={markdown} renderers={MARKDOWN_RENDERERS} />;
     } else {
-      // $FlowFixMe: formatted will always be a string if jsx = false but flow doesn't know that
-      return `${prefix}${formatted}${suffix}`;
+      // FIXME: render and get the innerText?
+      console.warn(
+        "formatValue: options.markdown_template not supported when options.jsx = false",
+      );
+      return formatted;
     }
   } else {
     return formatted;
diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingInput.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingInput.jsx
index c5bdbe249f26fae3a0c7afecdfe260369715918e..f7705f1931a3e60143ba55ed0e22a2150dbbf9d1 100644
--- a/frontend/src/metabase/visualizations/components/settings/ChartSettingInput.jsx
+++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingInput.jsx
@@ -1,7 +1,8 @@
 import React from "react";
 
-const ChartSettingInput = ({ value, onChange }) => (
+const ChartSettingInput = ({ value, onChange, ...props }) => (
   <input
+    {...props}
     className="input block full"
     value={value}
     onChange={e => onChange(e.target.value)}
diff --git a/frontend/src/metabase/visualizations/lib/settings/column.js b/frontend/src/metabase/visualizations/lib/settings/column.js
index 5561f08c65a6f6305aeba981e9bf60355071255d..6127c0161baaa637559392a363e9a69c05546b03 100644
--- a/frontend/src/metabase/visualizations/lib/settings/column.js
+++ b/frontend/src/metabase/visualizations/lib/settings/column.js
@@ -1,4 +1,5 @@
 import { t } from "c-3po";
+import _ from "underscore";
 
 import ChartSettingColumnSettings from "metabase/visualizations/components/settings/ChartSettingColumnSettings";
 
@@ -168,13 +169,12 @@ export const NUMBER_COLUMN_SETTINGS = {
 };
 
 const COMMON_COLUMN_SETTINGS = {
-  prefix: {
-    title: t`Add a prefix`,
-    widget: "input",
-  },
-  suffix: {
-    title: t`Add a suffix`,
+  markdown_template: {
+    title: t`Markdown template`,
     widget: "input",
+    props: {
+      placeholder: "{{value}}",
+    },
   },
 };
 
@@ -190,7 +190,13 @@ export function getSettingDefintionsForColumn(column) {
 
 export function getComputedSettingsForColumn(column, storedSettings) {
   const settingsDefs = getSettingDefintionsForColumn(column);
-  return getComputedSettings(settingsDefs, column, storedSettings);
+  const computedSettings = getComputedSettings(
+    settingsDefs,
+    column,
+    storedSettings,
+  );
+  // remove undefined settings since they override other settings when merging object
+  return _.pick(computedSettings, value => value !== undefined);
 }
 
 export function getSettingsWidgetsForColumm(
diff --git a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx
index bbc3b0d14fdb40847a91340b2a2e9952aab283e0..cf1a5bbf4fcdd6bb4518730e7360812356faa256 100644
--- a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx
+++ b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx
@@ -17,13 +17,20 @@ import _ from "underscore";
 
 import type { VisualizationProps } from "metabase/meta/types/Visualization";
 
-function getLegacyScalarSettings(settings) {
-  return _.chain(settings)
+// convert legacy `scalar.*` visualization settings to format options
+function legacyScalarSettingsToFormatOptions(settings) {
+  const o = _.chain(settings)
     .pairs()
     .filter(([key, value]) => key.startsWith("scalar.") && value !== undefined)
     .map(([key, value]) => [key.replace(/^scalar\./, ""), value])
     .object()
     .value();
+  // `prefix`/`suffix` replaced with `markdown_template`
+  if (o.prefix || o.suffix) {
+    o.markdown_template = `${o.prefix || ""}{{value}}${o.suffix || ""}`;
+    delete o.prefix, o.suffix;
+  }
+  return o;
 }
 
 export default class Scalar extends Component {
@@ -133,7 +140,7 @@ export default class Scalar extends Component {
     const column = cols[0];
 
     const formatOptions = {
-      ...getLegacyScalarSettings(settings),
+      ...legacyScalarSettingsToFormatOptions(settings),
       ...settings.column(column),
       jsx: true,
     };
diff --git a/package.json b/package.json
index f39b8d74ce6ee6e07d43ebd955e9db18dd577218..c88f81d2da57671ddc512dfe665e86c8b5a0bb5b 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
     "leaflet.heat": "^0.2.0",
     "lodash.memoize": "^4.1.2",
     "moment": "2.19.3",
+    "mustache": "^2.3.2",
     "node-libs-browser": "^2.0.0",
     "normalizr": "^3.0.2",
     "npm": "^5.8.0",
diff --git a/yarn.lock b/yarn.lock
index 1a7e08ab4608cc626f858b2a9893cdde5da7323c..e3777a20bb1867f39961976ef5e77aec9010beaa 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7185,6 +7185,10 @@ multicast-dns@^6.0.1:
     dns-packet "^1.0.1"
     thunky "^0.1.0"
 
+mustache@^2.3.2:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/mustache/-/mustache-2.3.2.tgz#a6d4d9c3f91d13359ab889a812954f9230a3d0c5"
+
 mute-stream@0.0.5:
   version "0.0.5"
   resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0"