diff --git a/frontend/src/metabase-types/api/index.ts b/frontend/src/metabase-types/api/index.ts
index 83c3028cca13dd5a3b2734694c68d6b081561e00..aaec5ccc2a776e196de9d4014fc1c3f7f77f2910 100644
--- a/frontend/src/metabase-types/api/index.ts
+++ b/frontend/src/metabase-types/api/index.ts
@@ -20,6 +20,7 @@ export * from "./revision";
 export * from "./segment";
 export * from "./settings";
 export * from "./slack";
+export * from "./snippets";
 export * from "./table";
 export * from "./timeline";
 export * from "./user";
diff --git a/frontend/src/metabase-types/api/mocks/index.ts b/frontend/src/metabase-types/api/mocks/index.ts
index d9e5f5c4d7bf523386cc72773c03f27ef11e2a31..3b3030f2ad40afb9fbb0f84617239f6bca414623 100644
--- a/frontend/src/metabase-types/api/mocks/index.ts
+++ b/frontend/src/metabase-types/api/mocks/index.ts
@@ -13,5 +13,6 @@ export * from "./segment";
 export * from "./table";
 export * from "./timeline";
 export * from "./settings";
+export * from "./snippets";
 export * from "./user";
 export * from "./writeback";
diff --git a/frontend/src/metabase-types/api/mocks/snippets.ts b/frontend/src/metabase-types/api/mocks/snippets.ts
new file mode 100644
index 0000000000000000000000000000000000000000..085a7e9aeba962b4d02e5476dfdace34bf8fd0d1
--- /dev/null
+++ b/frontend/src/metabase-types/api/mocks/snippets.ts
@@ -0,0 +1,21 @@
+import { NativeQuerySnippet } from "metabase-types/api";
+import { createMockUser } from "./user";
+
+export const createMockNativeQuerySnippet = ({
+  creator = createMockUser(),
+  creator_id = creator.id,
+  ...opts
+}: Partial<NativeQuerySnippet> = {}): NativeQuerySnippet => ({
+  id: 1,
+  name: "My Snippet",
+  description: null,
+  content: "SELECT * FROM my_table",
+  collection_id: null,
+  creator,
+  creator_id,
+  entity_id: "snippet_entity_id",
+  archived: false,
+  created_at: new Date().toISOString(),
+  updated_at: new Date().toISOString(),
+  ...opts,
+});
diff --git a/frontend/src/metabase-types/api/snippets.ts b/frontend/src/metabase-types/api/snippets.ts
new file mode 100644
index 0000000000000000000000000000000000000000..902b0f5baef5da49209c3cdab294dd55cccda16e
--- /dev/null
+++ b/frontend/src/metabase-types/api/snippets.ts
@@ -0,0 +1,18 @@
+import { RegularCollectionId } from "./collection";
+import { User, UserId } from "./user";
+
+export type NativeQuerySnippetId = number;
+
+export interface NativeQuerySnippet {
+  id: NativeQuerySnippetId;
+  name: string;
+  description: string | null;
+  content: string;
+  collection_id: RegularCollectionId | null;
+  creator_id: UserId;
+  creator: User;
+  archived: boolean;
+  entity_id: string;
+  created_at: string;
+  updated_at: string;
+}
diff --git a/frontend/src/metabase-types/store/entities.ts b/frontend/src/metabase-types/store/entities.ts
index eed38d36f4064446b43931229e1de7361965a65c..a5ab03a7d5a8e60d3017cda717c96f068a172eee 100644
--- a/frontend/src/metabase-types/store/entities.ts
+++ b/frontend/src/metabase-types/store/entities.ts
@@ -4,6 +4,8 @@ import {
   DataApp,
   DataAppId,
   Database,
+  NativeQuerySnippet,
+  NativeQuerySnippetId,
   Table,
 } from "metabase-types/api";
 
@@ -12,4 +14,5 @@ export interface EntitiesState {
   dataApps?: Record<DataAppId, DataApp>;
   databases?: Record<number, Database>;
   tables?: Record<number | string, Table>;
+  snippets?: Record<NativeQuerySnippetId, NativeQuerySnippet>;
 }
diff --git a/frontend/src/metabase/collections/containers/FormCollectionPicker/FormCollectionPicker.tsx b/frontend/src/metabase/collections/containers/FormCollectionPicker/FormCollectionPicker.tsx
index 3f2cf29e399d0db3cd73e1d78b1fb9c4b8670129..6512044f2990ea32d1c66a7c0d34d4ca01e055ce 100644
--- a/frontend/src/metabase/collections/containers/FormCollectionPicker/FormCollectionPicker.tsx
+++ b/frontend/src/metabase/collections/containers/FormCollectionPicker/FormCollectionPicker.tsx
@@ -15,6 +15,10 @@ import SelectButton from "metabase/core/components/SelectButton";
 import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger";
 
 import CollectionName from "metabase/containers/CollectionName";
+import SnippetCollectionName from "metabase/containers/SnippetCollectionName";
+
+import Collections from "metabase/entities/collections";
+import SnippetCollections from "metabase/entities/snippet-collections";
 
 import { isValidCollectionId } from "metabase/collections/utils";
 
@@ -30,16 +34,32 @@ export interface FormCollectionPickerProps
   name: string;
   title?: string;
   placeholder?: string;
+  type?: "collections" | "snippet-collections";
 }
 
 const ITEM_PICKER_MODELS = ["collection"];
 
+function ItemName({
+  id,
+  type = "collections",
+}: {
+  id: CollectionId;
+  type?: "collections" | "snippet-collections";
+}) {
+  return type === "snippet-collections" ? (
+    <SnippetCollectionName id={id} />
+  ) : (
+    <CollectionName id={id} />
+  );
+}
+
 function FormCollectionPicker({
   className,
   style,
   name,
   title,
   placeholder = t`Select a collection`,
+  type = "collections",
 }: FormCollectionPickerProps) {
   const id = useUniqueId();
   const [{ value }, { error, touched }, { setValue }] = useField(name);
@@ -66,29 +86,38 @@ function FormCollectionPicker({
       >
         <SelectButton onClick={handleShowPopover}>
           {isValidCollectionId(value) ? (
-            <CollectionName id={value} />
+            <ItemName id={value} type={type} />
           ) : (
             placeholder
           )}
         </SelectButton>
       </FormField>
     ),
-    [id, value, title, placeholder, error, touched, className, style],
+    [id, value, type, title, placeholder, error, touched, className, style],
   );
 
   const renderContent = useCallback(
-    ({ closePopover }) => (
-      <PopoverItemPicker
-        value={{ id: value, model: "collection" }}
-        models={ITEM_PICKER_MODELS}
-        onChange={({ id }: { id: CollectionId }) => {
-          setValue(id);
-          closePopover();
-        }}
-        width={width}
-      />
-    ),
-    [value, width, setValue],
+    ({ closePopover }) => {
+      // Search API doesn't support collection namespaces yet
+      const hasSearch = type === "collections";
+
+      const entity = type === "collections" ? Collections : SnippetCollections;
+
+      return (
+        <PopoverItemPicker
+          value={{ id: value, model: "collection" }}
+          models={ITEM_PICKER_MODELS}
+          entity={entity}
+          onChange={({ id }: { id: CollectionId }) => {
+            setValue(id);
+            closePopover();
+          }}
+          showSearch={hasSearch}
+          width={width}
+        />
+      );
+    },
+    [value, type, width, setValue],
   );
 
   return (
diff --git a/frontend/src/metabase/collections/utils.ts b/frontend/src/metabase/collections/utils.ts
index ddff06f13830b145f4d2f2123306ce82144ca5a3..e5d55bc16462d7ac9a1dbffa13e002fa78139164 100644
--- a/frontend/src/metabase/collections/utils.ts
+++ b/frontend/src/metabase/collections/utils.ts
@@ -54,7 +54,7 @@ export function isPersonalCollectionChild(
   return Boolean(parentCollection && !!parentCollection.personal_owner_id);
 }
 
-export function isRootCollection(collection: Collection): boolean {
+export function isRootCollection(collection: Pick<Collection, "id">): boolean {
   return canonicalCollectionId(collection.id) === null;
 }
 
diff --git a/frontend/src/metabase/containers/SnippetCollectionName.tsx b/frontend/src/metabase/containers/SnippetCollectionName.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..51a1af3eaf05ff13ea08e9cfab09d45378a19ea7
--- /dev/null
+++ b/frontend/src/metabase/containers/SnippetCollectionName.tsx
@@ -0,0 +1,19 @@
+import React from "react";
+import { t } from "ttag";
+
+import SnippetCollections from "metabase/entities/snippet-collections";
+import { isRootCollection } from "metabase/collections/utils";
+
+import type { CollectionId } from "metabase-types/api";
+
+function SnippetCollectionName({ id }: { id: CollectionId }) {
+  if (isRootCollection({ id })) {
+    return <span>{t`Top folder`}</span>;
+  }
+  if (!Number.isSafeInteger(id)) {
+    return null;
+  }
+  return <SnippetCollections.Name id={id} />;
+}
+
+export default SnippetCollectionName;
diff --git a/frontend/src/metabase/core/components/FormTextArea/FormTextArea.tsx b/frontend/src/metabase/core/components/FormTextArea/FormTextArea.tsx
index bb1bb4b5bf489af42b73e33cab01eff75ac55657..00ac867cd867295ae2bb384de55769ab83eb7a61 100644
--- a/frontend/src/metabase/core/components/FormTextArea/FormTextArea.tsx
+++ b/frontend/src/metabase/core/components/FormTextArea/FormTextArea.tsx
@@ -73,4 +73,6 @@ const FormTextArea = forwardRef(function FormTextArea(
   );
 });
 
-export default FormTextArea;
+export default Object.assign(FormTextArea, {
+  Root: TextArea.Root,
+});
diff --git a/frontend/src/metabase/core/components/TextArea/TextArea.tsx b/frontend/src/metabase/core/components/TextArea/TextArea.tsx
index 8a9b8116596b7c623d30a665690e521b77bee2fa..56b4e561f104a4195aaceb3c2c59473b04d6020c 100644
--- a/frontend/src/metabase/core/components/TextArea/TextArea.tsx
+++ b/frontend/src/metabase/core/components/TextArea/TextArea.tsx
@@ -16,4 +16,6 @@ const TextArea = forwardRef(function TextArea(
   );
 });
 
-export default TextArea;
+export default Object.assign(TextArea, {
+  Root: TextAreaRoot,
+});
diff --git a/frontend/src/metabase/entities/snippets.js b/frontend/src/metabase/entities/snippets.js
index 4beb0b76dedf4e4b2b7c4d4e9863abdd785ffff8..955c8304c0cbc07a00abb1bd1f84fb9af5c8d344 100644
--- a/frontend/src/metabase/entities/snippets.js
+++ b/frontend/src/metabase/entities/snippets.js
@@ -1,34 +1,4 @@
-import { t } from "ttag";
-
 import { createEntity } from "metabase/lib/entities";
-import validate from "metabase/lib/validate";
-import { canonicalCollectionId } from "metabase/collections/utils";
-
-const formFields = [
-  {
-    name: "content",
-    title: t`Enter some SQL here so you can reuse it later`,
-    placeholder: "AND canceled_at IS null\nAND account_type = 'PAID'",
-    type: "text",
-    className:
-      "Form-input full text-monospace text-normal text-small bg-light text-spaced",
-    rows: 4,
-    autoFocus: true,
-    validate: validate.required().maxLength(10000),
-  },
-  {
-    name: "name",
-    title: t`Give your snippet a name`,
-    placeholder: t`Current Customers`,
-    validate: validate.required().maxLength(100),
-  },
-  {
-    name: "description",
-    title: t`Add a description`,
-    placeholder: t`It's optional but oh, so helpful`,
-    validate: validate.maxLength(500),
-  },
-];
 
 const Snippets = createEntity({
   name: "snippets",
@@ -38,28 +8,6 @@ const Snippets = createEntity({
     getFetched: (state, props) =>
       getFetched(state, props) || getObject(state, props),
   }),
-  forms: {
-    withoutVisibleCollectionPicker: {
-      fields: [
-        ...formFields,
-        {
-          name: "collection_id",
-          hidden: true,
-        },
-      ],
-    },
-    withVisibleCollectionPicker: {
-      fields: [
-        ...formFields,
-        {
-          name: "collection_id",
-          title: t`Folder this should be in`,
-          type: "snippetCollection",
-          normalize: canonicalCollectionId,
-        },
-      ],
-    },
-  },
 });
 
 export default Snippets;
diff --git a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx
index 0fc895a6ed72033c780766ea4323b73289505845..918db028e3d212842ed64eabe23338a89e17a598 100644
--- a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx
+++ b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx
@@ -26,10 +26,11 @@ import { isEventOverElement } from "metabase/lib/dom";
 import { getEngineNativeAceMode } from "metabase/lib/engine";
 import { SQLBehaviour } from "metabase/lib/ace/sql_behaviour";
 import ExplicitSize from "metabase/components/ExplicitSize";
+import Modal from "metabase/components/Modal";
 
 import Snippets from "metabase/entities/snippets";
 import SnippetCollections from "metabase/entities/snippet-collections";
-import SnippetModal from "metabase/query_builder/components/template_tags/SnippetModal";
+import SnippetFormModal from "metabase/query_builder/components/template_tags/SnippetFormModal";
 import Questions from "metabase/entities/questions";
 import { CARD_TAG_REGEX } from "metabase-lib/queries/NativeQuery";
 import { ResponsiveParametersList } from "./ResponsiveParametersList";
@@ -584,17 +585,20 @@ class NativeQueryEditor extends Component {
           />
 
           {this.props.modalSnippet && (
-            <SnippetModal
-              onSnippetUpdate={(newSnippet, oldSnippet) => {
-                if (newSnippet.name !== oldSnippet.name) {
-                  setDatasetQuery(query.updateSnippetNames([newSnippet]));
-                }
-              }}
-              snippet={this.props.modalSnippet}
-              insertSnippet={this.props.insertSnippet}
-              closeModal={this.props.closeSnippetModal}
-            />
+            <Modal onClose={this.props.closeSnippetModal}>
+              <SnippetFormModal
+                snippet={this.props.modalSnippet}
+                onCreate={this.props.insertSnippet}
+                onUpdate={(newSnippet, oldSnippet) => {
+                  if (newSnippet.name !== oldSnippet.name) {
+                    setDatasetQuery(query.updateSnippetNames([newSnippet]));
+                  }
+                }}
+                onClose={this.props.closeSnippetModal}
+              />
+            </Modal>
           )}
+
           {hasEditingSidebar && !readOnly && (
             <NativeQueryEditorSidebar
               runQuery={this.runQuery}
diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/SnippetForm.styled.tsx b/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/SnippetForm.styled.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..79300b12d18329cddb85822a87f5f1cd41774f31
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/SnippetForm.styled.tsx
@@ -0,0 +1,28 @@
+import styled from "@emotion/styled";
+import FormTextArea from "metabase/core/components/FormTextArea";
+import { color } from "metabase/lib/colors";
+
+export const FormSnippetTextArea = styled(FormTextArea)`
+  ${FormTextArea.Root} {
+    width: 100%;
+    background-color: ${color("bg-light")};
+
+    font-family: Monaco, monospace;
+    font-size: 0.875em;
+    font-weight: 400;
+    line-height: 1.5em;
+  }
+`;
+
+export const SnippetFormFooter = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+`;
+
+export const SnippetFormFooterContent = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 0.5rem;
+`;
diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/SnippetForm.tsx b/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/SnippetForm.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0070c275b001a4a7d40a80e4169dd473e2b892f5
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/SnippetForm.tsx
@@ -0,0 +1,214 @@
+import React, { useCallback, useMemo } from "react";
+import { t } from "ttag";
+import _ from "underscore";
+import * as Yup from "yup";
+import { connect } from "react-redux";
+
+import Button from "metabase/core/components/Button";
+import Form from "metabase/core/components/Form";
+import FormProvider from "metabase/core/components/FormProvider";
+import FormInput from "metabase/core/components/FormInput";
+import FormSubmitButton from "metabase/core/components/FormSubmitButton";
+import FormErrorMessage from "metabase/core/components/FormErrorMessage";
+
+import * as Errors from "metabase/core/utils/errors";
+
+import Snippets from "metabase/entities/snippets";
+import SnippetCollections from "metabase/entities/snippet-collections";
+
+import FormCollectionPicker from "metabase/collections/containers/FormCollectionPicker";
+import { canonicalCollectionId } from "metabase/collections/utils";
+
+import type {
+  Collection,
+  NativeQuerySnippet,
+  NativeQuerySnippetId,
+} from "metabase-types/api";
+
+import {
+  FormSnippetTextArea,
+  SnippetFormFooterContent,
+  SnippetFormFooter,
+} from "./SnippetForm.styled";
+
+const SNIPPET_SCHEMA = Yup.object({
+  name: Yup.string()
+    .required(Errors.required)
+    .max(100, Errors.maxLength)
+    .default(""),
+  description: Yup.string().nullable().max(500, Errors.maxLength).default(null),
+  content: Yup.string()
+    .required(Errors.required)
+    .max(10000, Errors.maxLength)
+    .default(""),
+  collection_id: Yup.number().nullable().default(null),
+});
+
+type SnippetFormValues = Pick<
+  NativeQuerySnippet,
+  "name" | "description" | "content" | "collection_id"
+>;
+
+type UpdateSnippetFormValues = Partial<SnippetFormValues> &
+  Pick<NativeQuerySnippet, "id"> & {
+    archived?: boolean;
+  };
+
+export interface SnippetFormOwnProps {
+  snippet: Partial<NativeQuerySnippet>;
+  onCreate?: (snippet: NativeQuerySnippet) => void;
+  onUpdate?: (
+    nextSnippet: NativeQuerySnippet,
+    originalSnippet: NativeQuerySnippet,
+  ) => void;
+  onArchive?: () => void;
+  onCancel?: () => void;
+}
+
+interface SnippetLoaderProps {
+  snippetCollections: Collection[];
+}
+
+interface SnippetFormDispatchProps {
+  handleCreateSnippet: (
+    snippet: SnippetFormValues,
+  ) => Promise<NativeQuerySnippet>;
+  handleUpdateSnippet: (
+    snippet: UpdateSnippetFormValues,
+  ) => Promise<NativeQuerySnippet>;
+}
+
+type SnippetFormProps = SnippetFormOwnProps &
+  SnippetLoaderProps &
+  SnippetFormDispatchProps;
+
+const mapDispatchToProps = {
+  handleCreateSnippet: Snippets.actions.create,
+  handleUpdateSnippet: Snippets.actions.update,
+};
+
+function SnippetForm({
+  snippet,
+  snippetCollections,
+  handleCreateSnippet,
+  handleUpdateSnippet,
+  onCreate,
+  onUpdate,
+  onArchive,
+  onCancel,
+}: SnippetFormProps) {
+  const isEditing = snippet.id != null;
+  const hasManyCollections = snippetCollections.length > 1;
+
+  const initialValues = useMemo(
+    () =>
+      SNIPPET_SCHEMA.cast(
+        {
+          ...snippet,
+          content: snippet.content || "",
+          parent_id: canonicalCollectionId(snippet.id),
+        },
+        { stripUnknown: true },
+      ),
+    [snippet],
+  );
+
+  const handleCreate = useCallback(
+    async (values: SnippetFormValues) => {
+      const action = await handleCreateSnippet(values);
+      const snippet = Snippets.HACK_getObjectFromAction(action);
+      onCreate?.(snippet);
+    },
+    [handleCreateSnippet, onCreate],
+  );
+
+  const handleUpdate = useCallback(
+    async (values: UpdateSnippetFormValues) => {
+      const action = await handleUpdateSnippet(values);
+      const nextSnippet = Snippets.HACK_getObjectFromAction(action);
+      onUpdate?.(nextSnippet, snippet as NativeQuerySnippet);
+    },
+    [snippet, handleUpdateSnippet, onUpdate],
+  );
+
+  const handleSubmit = useCallback(
+    async values => {
+      if (isEditing) {
+        await handleUpdate({ ...values, id: snippet.id });
+      } else {
+        await handleCreate(values);
+      }
+    },
+    [snippet.id, isEditing, handleCreate, handleUpdate],
+  );
+
+  const handleArchive = useCallback(async () => {
+    await handleUpdateSnippet({
+      id: snippet.id as NativeQuerySnippetId,
+      archived: true,
+    });
+    onArchive?.();
+  }, [snippet.id, handleUpdateSnippet, onArchive]);
+
+  return (
+    <FormProvider
+      initialValues={initialValues}
+      validationSchema={SNIPPET_SCHEMA}
+      onSubmit={handleSubmit}
+    >
+      {({ dirty }) => (
+        <Form disabled={!dirty}>
+          <FormSnippetTextArea
+            name="content"
+            title={t`Enter some SQL here so you can reuse it later`}
+            placeholder="AND canceled_at IS null\nAND account_type = 'PAID'"
+            autoFocus
+            rows={4}
+          />
+          <FormInput
+            name="name"
+            title={t`Give your snippet a name`}
+            placeholder={t`Current Customers`}
+          />
+          <FormInput
+            name="description"
+            title={t`Add a description`}
+            placeholder={t`It's optional but oh, so helpful`}
+            nullable
+          />
+          {hasManyCollections && (
+            <FormCollectionPicker
+              name="collection_id"
+              title={t`Folder this should be in`}
+              type="snippet-collections"
+            />
+          )}
+          <SnippetFormFooter>
+            <SnippetFormFooterContent>
+              {isEditing && (
+                <Button
+                  type="button"
+                  icon="archive"
+                  borderless
+                  onClick={handleArchive}
+                >{t`Archive`}</Button>
+              )}
+              <FormErrorMessage inline />
+            </SnippetFormFooterContent>
+            <SnippetFormFooterContent>
+              {!!onCancel && (
+                <Button type="button" onClick={onCancel}>{t`Cancel`}</Button>
+              )}
+              <FormSubmitButton title={t`Save`} disabled={!dirty} primary />
+            </SnippetFormFooterContent>
+          </SnippetFormFooter>
+        </Form>
+      )}
+    </FormProvider>
+  );
+}
+
+export default _.compose(
+  SnippetCollections.loadList(),
+  connect(null, mapDispatchToProps),
+)(SnippetForm);
diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/index.ts b/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d87d24f678b5a6300e41d7b8968604811037a2e3
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./SnippetForm";
+export type { SnippetFormOwnProps } from "./SnippetForm";
diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/SnippetFormModal.tsx b/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/SnippetFormModal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..136cd2114614e2fce9b7fab7d0f15ef1c94942b1
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/SnippetFormModal.tsx
@@ -0,0 +1,59 @@
+import React, { useCallback } from "react";
+import { t } from "ttag";
+
+import ModalContent from "metabase/components/ModalContent";
+
+import type { NativeQuerySnippet } from "metabase-types/api";
+
+import SnippetForm, { SnippetFormOwnProps } from "../SnippetForm";
+
+interface SnippetFormModalOwnProps
+  extends Omit<SnippetFormOwnProps, "onCancel"> {
+  onClose?: () => void;
+}
+
+type SnippetModalProps = SnippetFormModalOwnProps;
+
+function SnippetFormModal({
+  snippet,
+  onCreate,
+  onUpdate,
+  onClose,
+  ...props
+}: SnippetModalProps) {
+  const isEditing = snippet.id != null;
+  const title = isEditing
+    ? t`Editing ${snippet.name}`
+    : t`Create your new snippet`;
+
+  const handleCreate = useCallback(
+    (snippet: NativeQuerySnippet) => {
+      onCreate?.(snippet);
+      onClose?.();
+    },
+    [onCreate, onClose],
+  );
+
+  const handleUpdate = useCallback(
+    (nextSnippet: NativeQuerySnippet, originalSnippet: NativeQuerySnippet) => {
+      onUpdate?.(nextSnippet, originalSnippet);
+      onClose?.();
+    },
+    [onUpdate, onClose],
+  );
+
+  return (
+    <ModalContent title={title} onClose={onClose}>
+      <SnippetForm
+        {...props}
+        snippet={snippet}
+        onCreate={handleCreate}
+        onUpdate={handleUpdate}
+        onArchive={onClose}
+        onCancel={onClose}
+      />
+    </ModalContent>
+  );
+}
+
+export default SnippetFormModal;
diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/SnippetFormModal.unit.spec.tsx b/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/SnippetFormModal.unit.spec.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9b13329d29b2bd2c22f53a90ed160d8924d0bc23
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/SnippetFormModal.unit.spec.tsx
@@ -0,0 +1,274 @@
+import React from "react";
+import userEvent from "@testing-library/user-event";
+import xhrMock from "xhr-mock";
+
+import {
+  act,
+  renderWithProviders,
+  screen,
+  waitForElementToBeRemoved,
+} from "__support__/ui";
+import { setupEnterpriseTest } from "__support__/enterprise";
+
+import type { NativeQuerySnippet } from "metabase-types/api";
+import {
+  createMockCollection,
+  createMockNativeQuerySnippet,
+} from "metabase-types/api/mocks";
+
+import SnippetFormModal from "./SnippetFormModal";
+
+const TOP_SNIPPETS_FOLDER = {
+  id: "root",
+  name: "Top folder",
+  can_write: true,
+};
+
+type SetupOpts = {
+  snippet?: Partial<NativeQuerySnippet>;
+  onClose?: null | (() => void);
+  withDefaultFoldersList?: boolean;
+};
+
+async function setup({
+  snippet = {},
+  withDefaultFoldersList = true,
+  onClose = jest.fn(),
+}: SetupOpts = {}) {
+  xhrMock.get("/api/collection/root?namespace=snippets", {
+    body: JSON.stringify(TOP_SNIPPETS_FOLDER),
+  });
+
+  if (withDefaultFoldersList) {
+    xhrMock.get("/api/collection?namespace=snippets", {
+      body: JSON.stringify([TOP_SNIPPETS_FOLDER]),
+    });
+  }
+
+  xhrMock.post("/api/native-query-snippet", (req, res) =>
+    res.status(200).body(createMockNativeQuerySnippet(req.body())),
+  );
+
+  if (snippet.id) {
+    xhrMock.put(`/api/native-query-snippet/${snippet.id}`, (req, res) =>
+      res.status(200).body(createMockNativeQuerySnippet(req.body())),
+    );
+  }
+
+  renderWithProviders(
+    <SnippetFormModal snippet={snippet} onClose={onClose || undefined} />,
+  );
+
+  await waitForElementToBeRemoved(() => screen.getByText(/Loading/i));
+
+  return { onClose };
+}
+
+function setupEditing({
+  snippet = createMockNativeQuerySnippet(),
+  ...opts
+}: SetupOpts = {}) {
+  return setup({ snippet, ...opts });
+}
+
+const LABEL = {
+  NAME: /Give your snippet a name/i,
+  DESCRIPTION: /Add a description/i,
+  CONTENT: /Enter some SQL here so you can reuse it later/i,
+  FOLDER: /Folder this should be in/i,
+};
+
+describe("SnippetFormModal", () => {
+  beforeEach(() => {
+    xhrMock.setup();
+  });
+
+  afterEach(() => {
+    xhrMock.teardown();
+  });
+
+  describe("new snippet", () => {
+    it("displays correct blank state", async () => {
+      await setup();
+
+      expect(screen.getByLabelText(LABEL.NAME)).toBeInTheDocument();
+      expect(screen.getByLabelText(LABEL.NAME)).toHaveValue("");
+
+      expect(screen.getByLabelText(LABEL.DESCRIPTION)).toBeInTheDocument();
+      expect(screen.getByLabelText(LABEL.DESCRIPTION)).toHaveValue("");
+
+      expect(screen.getByLabelText(LABEL.CONTENT)).toBeInTheDocument();
+      expect(screen.getByLabelText(LABEL.CONTENT)).toHaveValue("");
+
+      expect(screen.queryByText(LABEL.FOLDER)).not.toBeInTheDocument();
+
+      expect(
+        screen.getByRole("button", { name: "Cancel" }),
+      ).toBeInTheDocument();
+      expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
+    });
+
+    it("shows expected title", async () => {
+      await setup();
+      expect(screen.getByText(/Create your new snippet/i)).toBeInTheDocument();
+    });
+
+    it("shows folder picker if there are many folders", async () => {
+      xhrMock.get("/api/collection?namespace=snippets", {
+        body: JSON.stringify([TOP_SNIPPETS_FOLDER, createMockCollection()]),
+      });
+
+      await setup({ withDefaultFoldersList: false });
+
+      expect(screen.getByText(LABEL.FOLDER)).toBeInTheDocument();
+      expect(screen.getByText(TOP_SNIPPETS_FOLDER.name)).toBeInTheDocument();
+    });
+
+    it("can't submit if content is empty", async () => {
+      await setup();
+      userEvent.type(screen.getByLabelText(LABEL.NAME), "My snippet");
+      expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+    });
+
+    it("can't submit if name is empty", async () => {
+      await setup();
+      userEvent.type(
+        screen.getByLabelText(LABEL.CONTENT),
+        "WHERE discount > 0",
+      );
+      expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+    });
+
+    it("can submit with name and content", async () => {
+      await setup();
+
+      await act(async () => {
+        await userEvent.type(screen.getByLabelText(LABEL.NAME), "My snippet");
+        await userEvent.type(
+          screen.getByLabelText(LABEL.CONTENT),
+          "WHERE discount > 0",
+        );
+      });
+
+      expect(screen.getByRole("button", { name: "Save" })).not.toBeDisabled();
+    });
+
+    it("doesn't show cancel button if onClose props is not set", async () => {
+      await setup({ onClose: null });
+      expect(
+        screen.queryByRole("button", { name: "Cancel" }),
+      ).not.toBeInTheDocument();
+    });
+
+    it("calls onClose when cancel button is clicked", async () => {
+      const { onClose } = await setup();
+      userEvent.click(screen.getByRole("button", { name: "Cancel" }));
+      expect(onClose).toHaveBeenCalledTimes(1);
+    });
+
+    it("doesn't show the archive button", async () => {
+      await setup();
+      expect(screen.queryByText("Archive")).not.toBeInTheDocument();
+    });
+  });
+
+  describe("editing snippet", () => {
+    it("shows correct initial state", async () => {
+      const snippet = createMockNativeQuerySnippet({
+        name: "has name",
+        content: "has content",
+        description: "has description",
+      });
+      await setupEditing({ snippet });
+
+      expect(screen.getByLabelText(LABEL.NAME)).toBeInTheDocument();
+      expect(screen.getByLabelText(LABEL.NAME)).toHaveValue(snippet.name);
+
+      expect(screen.getByLabelText(LABEL.DESCRIPTION)).toBeInTheDocument();
+      expect(screen.getByLabelText(LABEL.DESCRIPTION)).toHaveValue(
+        snippet.description,
+      );
+
+      expect(screen.getByLabelText(LABEL.CONTENT)).toBeInTheDocument();
+      expect(screen.getByLabelText(LABEL.CONTENT)).toHaveValue(snippet.content);
+
+      expect(screen.queryByText(LABEL.FOLDER)).not.toBeInTheDocument();
+
+      expect(
+        screen.getByRole("button", { name: "Cancel" }),
+      ).toBeInTheDocument();
+      expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
+    });
+
+    it("shows expected title", async () => {
+      const snippet = createMockNativeQuerySnippet();
+      await setupEditing({ snippet });
+      expect(screen.getByText(`Editing ${snippet.name}`)).toBeInTheDocument();
+    });
+
+    it("shows folder picker if there are many folders", async () => {
+      xhrMock.get("/api/collection?namespace=snippets", {
+        body: JSON.stringify([TOP_SNIPPETS_FOLDER, createMockCollection()]),
+      });
+
+      await setupEditing({ withDefaultFoldersList: false });
+
+      expect(screen.getByText(LABEL.FOLDER)).toBeInTheDocument();
+      expect(screen.getByText(TOP_SNIPPETS_FOLDER.name)).toBeInTheDocument();
+    });
+
+    it("can't submit until changes are made", async () => {
+      await setupEditing();
+      expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+    });
+
+    it("can't submit if content is empty", async () => {
+      await setupEditing();
+      await act(async () => {
+        await userEvent.clear(screen.getByLabelText(LABEL.NAME));
+      });
+      expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+    });
+
+    it("can't submit if name is empty", async () => {
+      await setupEditing();
+      await act(async () => {
+        await userEvent.clear(screen.getByLabelText(LABEL.CONTENT));
+      });
+      expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+    });
+
+    it("can submit with name and content", async () => {
+      await setupEditing();
+
+      userEvent.type(screen.getByLabelText(LABEL.NAME), "My snippet");
+      userEvent.type(
+        screen.getByLabelText(LABEL.CONTENT),
+        "WHERE discount > 0",
+      );
+
+      expect(screen.getByRole("button", { name: "Save" })).not.toBeDisabled();
+    });
+
+    it("doesn't show cancel button if onClose props is not set", async () => {
+      await setupEditing({ onClose: null });
+      expect(
+        screen.queryByRole("button", { name: "Cancel" }),
+      ).not.toBeInTheDocument();
+    });
+
+    it("calls onClose when cancel button is clicked", async () => {
+      const { onClose } = await setupEditing();
+      userEvent.click(screen.getByRole("button", { name: "Cancel" }));
+      expect(onClose).toHaveBeenCalledTimes(1);
+    });
+
+    it("closes the modal after archiving", async () => {
+      const { onClose } = await setupEditing();
+      await act(async () => {
+        await userEvent.click(screen.getByText("Archive"));
+      });
+      expect(onClose).toBeCalledTimes(1);
+    });
+  });
+});
diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/index.ts b/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..62db133c436052ece653d76edde975550328f619
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/index.ts
@@ -0,0 +1 @@
+export { default } from "./SnippetFormModal";
diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetModal.jsx b/frontend/src/metabase/query_builder/components/template_tags/SnippetModal.jsx
deleted file mode 100644
index 105780c52b5581c7e9b6804317e1a9d3a769482e..0000000000000000000000000000000000000000
--- a/frontend/src/metabase/query_builder/components/template_tags/SnippetModal.jsx
+++ /dev/null
@@ -1,68 +0,0 @@
-/* eslint-disable react/prop-types */
-import React from "react";
-
-import { t } from "ttag";
-
-import Icon from "metabase/components/Icon";
-import Modal from "metabase/components/Modal";
-import Link from "metabase/core/components/Link";
-import Snippets from "metabase/entities/snippets";
-import SnippetCollections from "metabase/entities/snippet-collections";
-
-class SnippetModal extends React.Component {
-  render() {
-    const {
-      insertSnippet,
-      onSnippetUpdate,
-      closeModal,
-      snippet,
-      snippetCollections,
-    } = this.props;
-
-    return (
-      <Modal onClose={closeModal}>
-        <Snippets.ModalForm
-          snippet={snippet}
-          form={
-            snippetCollections.length <= 1
-              ? Snippets.forms.withoutVisibleCollectionPicker
-              : Snippets.forms.withVisibleCollectionPicker
-          }
-          title={
-            snippet.id != null
-              ? t`Editing ${snippet.name}`
-              : t`Create your new snippet`
-          }
-          onSaved={savedSnippet => {
-            if (snippet.id == null) {
-              insertSnippet(savedSnippet);
-            } else {
-              // this will update the query if the name changed
-              onSnippetUpdate(savedSnippet, snippet);
-            }
-            closeModal();
-          }}
-          onClose={closeModal} // the "x" button
-          submitTitle={t`Save`}
-          footerExtraButtons={
-            // only display archive for saved snippets
-            snippet.id != null ? (
-              <Link
-                onClick={async () => {
-                  await snippet.update({ archived: true });
-                  closeModal();
-                }}
-                className="flex align-center text-medium text-bold"
-              >
-                <Icon name="archive" className="mr1" />
-                {t`Archive`}
-              </Link>
-            ) : null
-          }
-        />
-      </Modal>
-    );
-  }
-}
-
-export default SnippetCollections.loadList()(SnippetModal);