diff --git a/shared/src/metabase/shared/parameters/parameters.cljc b/shared/src/metabase/shared/parameters/parameters.cljc
index 9f72615c235d402320d7df614d5234d37b59608d..c8b478a30c49e7b2d58d46739dec78ec3ccf20be 100644
--- a/shared/src/metabase/shared/parameters/parameters.cljc
+++ b/shared/src/metabase/shared/parameters/parameters.cljc
@@ -1,5 +1,6 @@
 (ns metabase.shared.parameters.parameters
-  "Util functions for dealing with parameters"
+  "Util functions for dealing with parameters. Primarily used for substituting parameters into variables in Markdown
+  dashboard cards."
   #?@
       (:clj
        [(:require [clojure.string :as str]
@@ -113,36 +114,92 @@
   [text]
   (str/replace text escaped-chars-regex #(str \\ %)))
 
-(defn- replacement
-  [tag->param locale match]
-  (let [tag-name (second match)
-        param    (get tag->param tag-name)
+(defn- value
+  [tag-name tag->param locale]
+  (let [param    (get tag->param tag-name)
         value    (:value param)
         tyype    (:type param)]
-    (if value
+    (when value
       (try (-> (formatted-value tyype value locale)
                escape-chars)
            (catch #?(:clj Throwable :cljs js/Error) _
              ;; If we got an exception (most likely during date parsing/formatting), fallback to the default
              ;; implementation of formatted-value
-             (formatted-value :default value locale)))
-      ;; If this parameter has no value, return the original {{tag}} so that no substitution is done.
-      (first match))))
-
-(defn- normalize-parameter
-  "Normalize a single parameter by calling [[mbql.normalize/normalize-fragment]] on it, and converting all string keys
-  to keywords."
-  [parameter]
-  (->> (mbql.normalize/normalize-fragment [:parameters] [parameter])
-       first
-       (reduce-kv (fn [acc k v] (assoc acc (keyword k) v)) {})))
+             (formatted-value :default value locale))))))
 
 (def ^:private template-tag-regex
   "A regex to find template tags in a text card on a dashboard. This should mirror the regex used to find template
   tags in native queries, with the exception of snippets and card ID references (see the metabase-lib function
-  `recognizeTemplateTags` for that regex)."
+  `recognizeTemplateTags` for that regex).
+
+  If you modify this, also modify `template-tag-splitting-regex` below."
   #"\{\{\s*([A-Za-z0-9_\.]+?)\s*\}\}")
 
+;; Represents a variable parsed out of a text card. `tag` contains the tag name alone, as a string. `source` contains
+;; the full original syntax for the parameter)
+(defrecord ^:private TextParam [tag source]
+  Object
+  (toString
+    [x]
+    (or (:value x) source)))
+
+(defn- TextParam?
+  [x]
+  (instance? TextParam x))
+
+(def ^:private template-tag-splitting-regex
+  (let [base "\\{\\{\\s*[A-Za-z0-9_\\.]+?\\s*\\}\\}"]
+    ;; Use lookahead and lookbehind to retain matches in split
+    (re-pattern (str "(?<=" base ")|(?=" base ")"))))
+
+(defn- split-on-tags
+  "Given the text of a Markdown card, splits it into a sequence of alternating strings and TextParam records."
+  [text]
+  (let [split-text (str/split text template-tag-splitting-regex)]
+    (map (fn [text]
+           (if-let [[_, match] (re-matches template-tag-regex text)]
+             (->TextParam match text)
+             text))
+         split-text)))
+
+(defn- join-consecutive-strings
+  "Given a vector of strings and/or TextParam, concatenate consecutive strings and TextParams without values."
+  [strs-or-vars]
+  (->> strs-or-vars
+       (partition-by (fn [str-or-var]
+                         (or (string? str-or-var)
+                             (not (:value str-or-var)))))
+       (mapcat (fn [strs-or-var]
+                   (if (string? (first strs-or-var))
+                     [(str/join strs-or-var)]
+                     strs-or-var)))))
+
+(defn- add-values-to-variables
+  "Given `split-text`, containing a list of alternating strings and TextParam, add a :value key to any TextParams
+  with a corresponding value in `tag->normalized-param`."
+  [tag->normalized-param locale split-text]
+  (map
+   (fn [maybe-variable]
+     (if (TextParam? maybe-variable)
+         (assoc maybe-variable :value (value (:tag maybe-variable) tag->normalized-param locale))
+         maybe-variable))
+   split-text))
+
+(def ^:private optional-block-regex
+  #"\[\[.+\]\]")
+
+(def ^:private non-optional-block-regex
+  #"\[\[(.+?)\]\]")
+
+(defn- strip-optional-blocks
+  "Removes any [[optional]] blocks from individual strings in `split-text`, which are blocks that have no parameters
+  with values. Then, concatenates the full string and removes the brackets from any remaining optional blocks."
+  [split-text]
+  (let [s (->> split-text
+               (map #(if (TextParam? %) % (str/replace % optional-block-regex "")))
+               str/join)]
+    (str/replace s non-optional-block-regex second)))
+
 (defn ^:export tag_names
   "Given the content of a text dashboard card, return a set of the unique names of template tags in the text."
   [text]
@@ -152,6 +209,14 @@
     #?(:clj  tag-names
        :cljs (clj->js tag-names))))
 
+(defn- normalize-parameter
+  "Normalize a single parameter by calling [[mbql.normalize/normalize-fragment]] on it, and converting all string keys
+  to keywords."
+  [parameter]
+  (->> (mbql.normalize/normalize-fragment [:parameters] [parameter])
+       first
+       (reduce-kv (fn [acc k v] (assoc acc (keyword k) v)) {})))
+
 (defn ^:export substitute_tags
   "Given the context of a text dashboard card, replace all template tags in the text with their corresponding values,
   formatted and escaped appropriately."
@@ -165,4 +230,17 @@
                                               (assoc acc tag (normalize-parameter param)))
                                             {}
                                             tag->param)]
-       (str/replace text template-tag-regex (partial replacement tag->normalized-param locale))))))
+       ;; Most of the functions in this pipeline are relating to handling optional blocks in the text which use
+       ;; the [[ ]] syntax.
+       ;; For example, given an input "[[a {{b}}]] [[{{c}}]]", where `b` has no value and `c` = 3:
+       ;; 1. `split-on-tags` =>
+       ;;      ("[[a " {:tag "b" :source "{{b}}"} "]] [[" {:tag "c" :source "{{c}}"} "]]")
+       ;; 2. `add-values-to-variables` =>
+       ;;      ("[[a " {:tag "b" :source "{{b}}" :value nil} "]] [[" {:tag "c" :source "{{c}}" :value 3} "]]")
+       ;; 3. `join-consecutive-strings` => ("[[a {{b}}]] [[" {:tag "b" :source "{{c}}" :value 3} "]])
+       ;; 4. `strip-optional-blocks` => "3"
+       (->> text
+            split-on-tags
+            (add-values-to-variables tag->normalized-param locale)
+            join-consecutive-strings
+            strip-optional-blocks)))))
diff --git a/shared/test/metabase/shared/parameters/parameters_test.cljc b/shared/test/metabase/shared/parameters/parameters_test.cljc
index 921199971ae0b5d88ecc1af3094ba7f3d0417ade..2ee352b83353c3393cbee762683b167fa477bd8c 100644
--- a/shared/test/metabase/shared/parameters/parameters_test.cljc
+++ b/shared/test/metabase/shared/parameters/parameters_test.cljc
@@ -185,3 +185,49 @@
        "{{foo}}"
        {"foo" {:type :date/month-year :value "2019-08"}}
        "agosto\\, 2019"))))
+
+(t/deftest substitute-tags-optional-blocks-test
+  (t/testing "Optional blocks are removed when necessary"
+    (t/are [text tag->param expected] (= expected (params/substitute_tags text tag->param))
+      "[[{{foo}}]]"
+      {}
+      ""
+
+      "[[{{foo}}]]"
+      {"foo" {:type :string/= :value "bar"}}
+      "bar"
+
+      "Customers[[ with over {{order_count}} orders]]"
+      {"order_count" {:type :number/= :value nil}}
+      "Customers"
+
+      "Customers[[ with over {{order_count}} orders]]"
+      {"order_count" {:type :number/= :value 10}}
+      "Customers with over 10 orders"
+
+      ;; Optional block is retained when *any* parameters within are substituted
+      "[[{{foo}} {{baz}}]]"
+      {"foo" {:type :string/= :value "bar"}}
+      "bar {{baz}}"
+
+      ;; Make sure `join-consecutive-strings` retains consecutive non-strings (this was a bug during implementation)
+      "[[{{foo}}{{foo}}]]"
+      {"foo" {:type :string/= :value "foo"}}
+      "foofoo"
+
+      "[[{{foo}}]] [[{{bar}}]]"
+      {"foo" {:type :string/= :value 1} "bar" {:type :string/= :value 2}}
+      "1 2"
+
+      "[[{{foo}}]"
+      {"foo" {:type :string/= :value "bar"}}
+      "[[bar]"
+
+      "[{{foo}}]]"
+      {"foo" {:type :string/= :value "bar"}}
+      "[bar]]"
+
+      ;; Don't strip square brackets that are in parameter values
+      "{{foo}}"
+      {"foo" {:type :string/= :value "[[bar]]"}}
+      "\\[\\[bar\\]\\]")))