diff --git a/app/edge/__module.js b/app/edge/__module.js
index 0a4b416a6..33237de70 100644
--- a/app/edge/__module.js
+++ b/app/edge/__module.js
@@ -62,7 +62,7 @@ angular
 
     const stacksNew = {
       name: 'edge.stacks.new',
-      url: '/new?templateId',
+      url: '/new?templateId&templateType',
       views: {
         'content@': {
           component: 'createEdgeStackView',
diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts
index 0835e50c3..b3d0eceb4 100644
--- a/app/edge/react/components/index.ts
+++ b/app/edge/react/components/index.ts
@@ -13,7 +13,7 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
 import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
 import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
 import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
-import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
+import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
 
 const ngModule = angular
   .module('portainer.edge.react.components', [])
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js
index 801e63306..a3969ae35 100644
--- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js
+++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js
@@ -13,7 +13,11 @@ import { StackType } from '@/react/common/stacks/types';
 import { applySetStateAction } from '@/react-tools/apply-set-state-action';
 import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
 import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
-import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
+import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
+import { getAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
+import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
+import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
+import { getDefaultValues as getAppVariablesDefaultValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset';
 
 export default class CreateEdgeStackViewController {
   /* @ngInject */
@@ -73,7 +77,7 @@ export default class CreateEdgeStackViewController {
   }
 
   /**
-   * @param {import('react').SetStateAction<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset').Values>} templateAction
+   * @param {import('react').SetStateAction<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset/types').Values>} templateAction
    */
   setTemplateValues(templateAction) {
     return this.$async(async () => {
@@ -82,44 +86,52 @@ export default class CreateEdgeStackViewController {
       const newTemplateId = newTemplateValues.template && newTemplateValues.template.Id;
       this.state.templateValues = newTemplateValues;
       if (newTemplateId !== oldTemplateId) {
-        await this.onChangeTemplate(newTemplateValues.template);
+        await this.onChangeTemplate(newTemplateValues.type, newTemplateValues.template);
       }
 
-      let definitions = [];
-      if (this.state.templateValues.template) {
-        definitions = this.state.templateValues.template.Variables;
-      }
-      const newFile = renderTemplate(this.state.templateValues.file, this.state.templateValues.variables, definitions);
+      if (newTemplateValues.type === 'custom') {
+        const definitions = this.state.templateValues.template.Variables;
+        const newFile = renderTemplate(this.state.templateValues.file, this.state.templateValues.variables, definitions);
 
-      this.formValues.StackFileContent = newFile;
+        this.formValues.StackFileContent = newFile;
+      }
     });
   }
 
-  onChangeTemplate(template) {
+  onChangeTemplate(type, template) {
     return this.$async(async () => {
       if (!template) {
         return;
       }
 
-      this.state.templateValues.template = template;
-      this.state.templateValues.variables = getVariablesFieldDefaultValues(template.Variables);
+      if (type === 'custom') {
+        const fileContent = await getCustomTemplateFile({ id: template.Id, git: !!template.GitConfig });
+        this.state.templateValues.file = fileContent;
 
-      const fileContent = await getCustomTemplateFile({ id: template.Id, git: !!template.GitConfig });
-      this.state.templateValues.file = fileContent;
+        this.formValues = {
+          ...this.formValues,
+          DeploymentType: template.Type === StackType.Kubernetes ? DeploymentType.Kubernetes : DeploymentType.Compose,
+          ...toGitFormModel(template.GitConfig),
+          ...(template.EdgeSettings
+            ? {
+                PrePullImage: template.EdgeSettings.PrePullImage || false,
+                RetryDeploy: template.EdgeSettings.RetryDeploy || false,
+                PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null,
+                ...template.EdgeSettings.RelativePathSettings,
+              }
+            : {}),
+        };
+      }
 
-      this.formValues = {
-        ...this.formValues,
-        DeploymentType: template.Type === StackType.Kubernetes ? DeploymentType.Kubernetes : DeploymentType.Compose,
-        ...toGitFormModel(template.GitConfig),
-        ...(template.EdgeSettings
-          ? {
-              PrePullImage: template.EdgeSettings.PrePullImage || false,
-              RetryDeploy: template.EdgeSettings.RetryDeploy || false,
-              PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null,
-              ...template.EdgeSettings.RelativePathSettings,
-            }
-          : {}),
-      };
+      if (type === 'app') {
+        this.formValues.StackFileContent = '';
+        try {
+          const fileContent = await fetchFilePreview(template.Id);
+          this.formValues.StackFileContent = fileContent;
+        } catch (err) {
+          this.Notifications.error('Failure', err, 'Unable to retrieve Template');
+        }
+      }
     });
   }
 
@@ -159,13 +171,27 @@ export default class CreateEdgeStackViewController {
     }
   }
 
-  async preSelectTemplate(templateId) {
+  /**
+   *
+   * @param {'app' | 'custom'} templateType
+   * @param {number} templateId
+   * @returns {Promise<void>}
+   */
+  async preSelectTemplate(templateType, templateId) {
     return this.$async(async () => {
       try {
         this.state.Method = 'template';
-        const template = await getCustomTemplate(templateId);
+        const template = await getTemplate(templateType, templateId);
+        if (!template) {
+          return;
+        }
 
-        this.setTemplateValues({ template });
+        this.setTemplateValues({
+          template,
+          type: templateType,
+          envVars: templateType === 'app' ? getAppVariablesDefaultValues(template.Env) : {},
+          variables: templateType === 'custom' ? getVariablesFieldDefaultValues(template.Variables) : [],
+        });
       } catch (e) {
         notifyError('Failed loading template', e);
       }
@@ -179,9 +205,10 @@ export default class CreateEdgeStackViewController {
       this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
     }
 
-    const templateId = this.$state.params.templateId;
-    if (templateId) {
-      this.preSelectTemplate(templateId);
+    const templateId = parseInt(this.$state.params.templateId, 10);
+    const templateType = this.$state.params.templateType;
+    if (templateType && templateId && !Number.isNaN(templateId)) {
+      this.preSelectTemplate(templateType, templateId);
     }
 
     this.$window.onbeforeunload = () => {
@@ -198,6 +225,12 @@ export default class CreateEdgeStackViewController {
   createStack() {
     return this.$async(async () => {
       const name = this.formValues.Name;
+
+      let envVars = this.formValues.envVars;
+      if (this.state.Method === 'template' && this.state.templateValues.type === 'app') {
+        envVars = [...envVars, ...Object.entries(this.state.templateValues.envVars).map(([key, value]) => ({ name: key, value }))];
+      }
+
       const method = getMethod(this.state.Method, this.state.templateValues.template);
 
       if (!this.validateForm(method)) {
@@ -206,7 +239,7 @@ export default class CreateEdgeStackViewController {
 
       this.state.actionInProgress = true;
       try {
-        await this.createStackByMethod(name, method);
+        await this.createStackByMethod(name, method, envVars);
 
         this.Notifications.success('Success', 'Stack successfully deployed');
         this.state.isEditorDirty = false;
@@ -258,19 +291,19 @@ export default class CreateEdgeStackViewController {
     return true;
   }
 
-  createStackByMethod(name, method) {
+  createStackByMethod(name, method, envVars) {
     switch (method) {
       case 'editor':
-        return this.createStackFromFileContent(name);
+        return this.createStackFromFileContent(name, envVars);
       case 'upload':
-        return this.createStackFromFileUpload(name);
+        return this.createStackFromFileUpload(name, envVars);
       case 'repository':
-        return this.createStackFromGitRepository(name);
+        return this.createStackFromGitRepository(name, envVars);
     }
   }
 
-  createStackFromFileContent(name) {
-    const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
+  createStackFromFileContent(name, envVars) {
+    const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
 
     return this.EdgeStackService.createStackFromFileContent({
       name,
@@ -282,8 +315,9 @@ export default class CreateEdgeStackViewController {
     });
   }
 
-  createStackFromFileUpload(name) {
-    const { StackFile, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
+  createStackFromFileUpload(name, envVars) {
+    const { StackFile, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
+
     return this.EdgeStackService.createStackFromFileUpload(
       {
         Name: name,
@@ -296,8 +330,9 @@ export default class CreateEdgeStackViewController {
     );
   }
 
-  createStackFromGitRepository(name) {
-    const { Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
+  async createStackFromGitRepository(name, envVars) {
+    const { Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
+
     const repositoryOptions = {
       RepositoryURL: this.formValues.RepositoryURL,
       RepositoryReferenceName: this.formValues.RepositoryReferenceName,
@@ -354,3 +389,25 @@ function getMethod(method, template) {
   }
   return 'editor';
 }
+
+/**
+ *
+ * @param {'app' | 'custom'} templateType
+ * @param {number} templateId
+ * @returns {Promise<import('@/react/portainer/templates/app-templates/view-model').TemplateViewModel | import('@/react/portainer/templates/custom-templates/types').CustomTemplate | undefined>}
+ */
+async function getTemplate(templateType, templateId) {
+  if (!['app', 'custom'].includes(templateType)) {
+    notifyError('Invalid template type', `Invalid template type: ${templateType}`);
+    return;
+  }
+
+  if (templateType === 'app') {
+    const templatesResponse = await getAppTemplates();
+    const template = templatesResponse.templates.find((t) => t.id === templateId);
+    return new TemplateViewModel(template, templatesResponse.version);
+  }
+
+  const template = await getCustomTemplate(templateId);
+  return template;
+}
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js
index 99b132f97..0403d9f3f 100644
--- a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js
+++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js
@@ -1,4 +1,4 @@
-import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
+import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
 import { editor, git, edgeStackTemplate, upload } from '@@/BoxSelector/common-options/build-methods';
 
 class DockerComposeFormController {
diff --git a/app/portainer/react/components/custom-templates/index.ts b/app/portainer/react/components/custom-templates/index.ts
index 5730e7131..790e1dd27 100644
--- a/app/portainer/react/components/custom-templates/index.ts
+++ b/app/portainer/react/components/custom-templates/index.ts
@@ -48,6 +48,7 @@ export const ngModule = angular
       'disabledTypes',
       'fixedCategories',
       'storageKey',
+      'templateLinkParams',
     ])
   )
   .component(
diff --git a/app/react/components/BoxSelector/common-options/build-methods.tsx b/app/react/components/BoxSelector/common-options/build-methods.tsx
index b703b8444..678a45c6d 100644
--- a/app/react/components/BoxSelector/common-options/build-methods.tsx
+++ b/app/react/components/BoxSelector/common-options/build-methods.tsx
@@ -37,7 +37,7 @@ export const edgeStackTemplate: BoxSelectorOption<'template'> = {
   icon: FileText,
   iconType: 'badge',
   label: 'Template',
-  description: 'Use an Edge stack template',
+  description: 'Use an Edge stack app or custom template',
   value: 'template',
 };
 
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx
deleted file mode 100644
index 7015a6709..000000000
--- a/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-import { SetStateAction, useEffect, useState } from 'react';
-import sanitize from 'sanitize-html';
-import { FormikErrors } from 'formik';
-
-import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
-import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
-import {
-  CustomTemplatesVariablesField,
-  VariablesFieldValue,
-  getVariablesFieldDefaultValues,
-} from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
-
-import { FormControl } from '@@/form-components/FormControl';
-import { PortainerSelect } from '@@/form-components/PortainerSelect';
-
-export interface Values {
-  template: CustomTemplate | undefined;
-  variables: VariablesFieldValue;
-}
-
-export function TemplateFieldset({
-  values: initialValues,
-  setValues: setInitialValues,
-  errors,
-}: {
-  errors?: FormikErrors<Values>;
-  values: Values;
-  setValues: (values: SetStateAction<Values>) => void;
-}) {
-  const [values, setControlledValues] = useState(initialValues); // todo remove when all view is in react
-
-  useEffect(() => {
-    if (initialValues.template?.Id !== values.template?.Id) {
-      setControlledValues(initialValues);
-    }
-  }, [initialValues, values.template?.Id]);
-
-  const templatesQuery = useCustomTemplates({
-    params: {
-      edge: true,
-    },
-  });
-
-  return (
-    <>
-      <TemplateSelector
-        error={errors?.template}
-        value={values.template?.Id}
-        onChange={(value) => {
-          setValues((values) => {
-            const template = templatesQuery.data?.find(
-              (template) => template.Id === value
-            );
-            return {
-              ...values,
-              template,
-              variables: getVariablesFieldDefaultValues(
-                template?.Variables || []
-              ),
-            };
-          });
-        }}
-      />
-      {values.template && (
-        <>
-          {values.template.Note && (
-            <div>
-              <div className="col-sm-12 form-section-title"> Information </div>
-              <div className="form-group">
-                <div className="col-sm-12">
-                  <div
-                    className="template-note"
-                    // eslint-disable-next-line react/no-danger
-                    dangerouslySetInnerHTML={{
-                      __html: sanitize(values.template.Note),
-                    }}
-                  />
-                </div>
-              </div>
-            </div>
-          )}
-
-          <CustomTemplatesVariablesField
-            onChange={(value) => {
-              setValues((values) => ({
-                ...values,
-                variables: value,
-              }));
-            }}
-            value={values.variables}
-            definitions={values.template.Variables}
-            errors={errors?.variables}
-          />
-        </>
-      )}
-    </>
-  );
-
-  function setValues(values: SetStateAction<Values>) {
-    setControlledValues(values);
-    setInitialValues(values);
-  }
-}
-
-function TemplateSelector({
-  value,
-  onChange,
-  error,
-}: {
-  value: CustomTemplate['Id'] | undefined;
-  onChange: (value: CustomTemplate['Id'] | undefined) => void;
-  error?: string;
-}) {
-  const templatesQuery = useCustomTemplates({
-    params: {
-      edge: true,
-    },
-  });
-
-  if (!templatesQuery.data) {
-    return null;
-  }
-
-  return (
-    <FormControl label="Template" inputId="stack_template" errors={error}>
-      <PortainerSelect
-        placeholder="Select an Edge stack template"
-        value={value}
-        onChange={handleChange}
-        options={templatesQuery.data.map((template) => ({
-          label: `${template.Title} - ${template.Description}`,
-          value: template.Id,
-        }))}
-      />
-    </FormControl>
-  );
-
-  function handleChange(value: CustomTemplate['Id']) {
-    onChange(value);
-  }
-}
-
-export function getInitialTemplateValues() {
-  return {
-    template: null,
-    variables: [],
-    file: '',
-  };
-}
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.test.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.test.tsx
new file mode 100644
index 000000000..228abca60
--- /dev/null
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen } from '@/react-tools/test-utils';
+import {
+  EnvVarType,
+  TemplateViewModel,
+} from '@/react/portainer/templates/app-templates/view-model';
+
+import { AppTemplateFieldset } from './AppTemplateFieldset';
+
+test('renders AppTemplateFieldset component', () => {
+  const testedEnv = {
+    name: 'VAR2',
+    label: 'Variable 2',
+    default: 'value2',
+    value: 'value2',
+    type: EnvVarType.Text,
+  };
+
+  const env = [
+    {
+      name: 'VAR1',
+      label: 'Variable 1',
+      default: 'value1',
+      value: 'value1',
+      type: EnvVarType.Text,
+    },
+    testedEnv,
+  ];
+  const template = {
+    Note: 'This is a template note',
+    Env: env,
+  } as TemplateViewModel;
+
+  const values: Record<string, string> = {
+    VAR1: 'value1',
+    VAR2: 'value2',
+  };
+
+  const onChange = vi.fn();
+
+  render(
+    <AppTemplateFieldset
+      template={template}
+      values={values}
+      onChange={onChange}
+    />
+  );
+
+  const templateNoteElement = screen.getByText('This is a template note');
+  expect(templateNoteElement).toBeInTheDocument();
+
+  const envVarsFieldsetElement = screen.getByLabelText(testedEnv.label, {
+    exact: false,
+  });
+  expect(envVarsFieldsetElement).toBeInTheDocument();
+});
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.tsx
new file mode 100644
index 000000000..184aeb614
--- /dev/null
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.tsx
@@ -0,0 +1,30 @@
+import { FormikErrors } from 'formik';
+
+import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
+
+import { EnvVarsFieldset } from './EnvVarsFieldset';
+import { TemplateNote } from './TemplateNote';
+
+export function AppTemplateFieldset({
+  template,
+  values,
+  onChange,
+  errors,
+}: {
+  template: TemplateViewModel;
+  values: Record<string, string>;
+  onChange: (value: Record<string, string>) => void;
+  errors?: FormikErrors<Record<string, string>>;
+}) {
+  return (
+    <>
+      <TemplateNote note={template.Note} />
+      <EnvVarsFieldset
+        options={template.Env || []}
+        value={values}
+        onChange={onChange}
+        errors={errors}
+      />
+    </>
+  );
+}
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/CustomTemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/CustomTemplateFieldset.tsx
new file mode 100644
index 000000000..12bc0c847
--- /dev/null
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/CustomTemplateFieldset.tsx
@@ -0,0 +1,32 @@
+import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
+import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
+
+import { ArrayError } from '@@/form-components/InputList/InputList';
+
+import { Values } from './types';
+import { TemplateNote } from './TemplateNote';
+
+export function CustomTemplateFieldset({
+  errors,
+  onChange,
+  values,
+  template,
+}: {
+  values: Values['variables'];
+  onChange: (values: Values['variables']) => void;
+  errors: ArrayError<Values['variables']> | undefined;
+  template: CustomTemplate;
+}) {
+  return (
+    <>
+      <TemplateNote note={template.Note} />
+
+      <CustomTemplatesVariablesField
+        onChange={onChange}
+        value={values}
+        definitions={template.Variables}
+        errors={errors}
+      />
+    </>
+  );
+}
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.test.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.test.tsx
new file mode 100644
index 000000000..d90af69eb
--- /dev/null
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.test.tsx
@@ -0,0 +1,118 @@
+import { vi } from 'vitest';
+import userEvent from '@testing-library/user-event';
+
+import { render, screen } from '@/react-tools/test-utils';
+
+import {
+  EnvVarsFieldset,
+  getDefaultValues,
+  envVarsFieldsetValidation,
+} from './EnvVarsFieldset';
+
+test('renders EnvVarsFieldset component', () => {
+  const onChange = vi.fn();
+  const options = [
+    { name: 'VAR1', label: 'Variable 1', preset: false },
+    { name: 'VAR2', label: 'Variable 2', preset: false },
+  ] as const;
+  const value = { VAR1: 'Value 1', VAR2: 'Value 2' };
+  const errors = {};
+
+  render(
+    <EnvVarsFieldset
+      onChange={onChange}
+      options={[...options]}
+      value={value}
+      errors={errors}
+    />
+  );
+
+  options.forEach((option) => {
+    const labelElement = screen.getByLabelText(option.label, { exact: false });
+    expect(labelElement).toBeInTheDocument();
+
+    const inputElement = screen.getByDisplayValue(value[option.name]);
+    expect(inputElement).toBeInTheDocument();
+  });
+});
+
+test('calls onChange when input value changes', async () => {
+  const user = userEvent.setup();
+  const onChange = vi.fn();
+  const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
+  const value = { VAR1: 'Value 1' };
+  const errors = {};
+
+  render(
+    <EnvVarsFieldset
+      onChange={onChange}
+      options={options}
+      value={value}
+      errors={errors}
+    />
+  );
+
+  const inputElement = screen.getByDisplayValue(value.VAR1);
+  await user.clear(inputElement);
+  expect(onChange).toHaveBeenCalledWith({ VAR1: '' });
+
+  const newValue = 'New Value';
+  await user.type(inputElement, newValue);
+  expect(onChange).toHaveBeenCalled();
+});
+
+test('renders error message when there are errors', () => {
+  const onChange = vi.fn();
+  const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
+  const value = { VAR1: 'Value 1' };
+  const errors = { VAR1: 'Required' };
+
+  render(
+    <EnvVarsFieldset
+      onChange={onChange}
+      options={options}
+      value={value}
+      errors={errors}
+    />
+  );
+
+  const errorElement = screen.getByText('Required');
+  expect(errorElement).toBeInTheDocument();
+});
+
+test('returns default values', () => {
+  const definitions = [
+    {
+      name: 'VAR1',
+      label: 'Variable 1',
+      preset: false,
+      default: 'Default Value 1',
+    },
+    {
+      name: 'VAR2',
+      label: 'Variable 2',
+      preset: false,
+      default: 'Default Value 2',
+    },
+  ];
+
+  const defaultValues = getDefaultValues(definitions);
+
+  expect(defaultValues).toEqual({
+    VAR1: 'Default Value 1',
+    VAR2: 'Default Value 2',
+  });
+});
+
+test('validates env vars fieldset', () => {
+  const schema = envVarsFieldsetValidation();
+
+  const validData = { VAR1: 'Value 1', VAR2: 'Value 2' };
+  const invalidData = { VAR1: '', VAR2: 'Value 2' };
+
+  const validResult = schema.isValidSync(validData);
+  const invalidResult = schema.isValidSync(invalidData);
+
+  expect(validResult).toBe(true);
+  expect(invalidResult).toBe(false);
+});
diff --git a/app/react/edge/templates/AppTemplatesView/EnvVarsFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.tsx
similarity index 69%
rename from app/react/edge/templates/AppTemplatesView/EnvVarsFieldset.tsx
rename to app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.tsx
index 917bdc7ef..715e49320 100644
--- a/app/react/edge/templates/AppTemplatesView/EnvVarsFieldset.tsx
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.tsx
@@ -1,4 +1,5 @@
 import { FormikErrors } from 'formik';
+import { SchemaOf, array, string } from 'yup';
 
 import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
 
@@ -20,13 +21,13 @@ export function EnvVarsFieldset({
 }) {
   return (
     <>
-      {options.map((env, index) => (
+      {options.map((env) => (
         <Item
           key={env.name}
           option={env}
           value={value[env.name]}
           onChange={(value) => handleChange(env.name, value)}
-          errors={errors?.[index]}
+          errors={errors?.[env.name]}
         />
       ))}
     </>
@@ -48,11 +49,13 @@ function Item({
   onChange: (value: string) => void;
   errors?: FormikErrors<string>;
 }) {
+  const inputId = `env_var_${option.name}`;
   return (
     <FormControl
       label={option.label || option.name}
       required={!option.preset}
       errors={errors}
+      inputId={inputId}
     >
       {option.select ? (
         <Select
@@ -63,14 +66,29 @@ function Item({
             value: o.value,
           }))}
           disabled={option.preset}
+          id={inputId}
         />
       ) : (
         <Input
           value={value}
           onChange={(e) => onChange(e.target.value)}
           disabled={option.preset}
+          id={inputId}
         />
       )}
     </FormControl>
   );
 }
+
+export function getDefaultValues(definitions: Array<TemplateEnv>): Value {
+  return Object.fromEntries(definitions.map((v) => [v.name, v.default || '']));
+}
+
+export function envVarsFieldsetValidation(): SchemaOf<Value> {
+  return (
+    array()
+      .transform((_, orig) => Object.values(orig))
+      // casting to return the correct type - validation works as expected
+      .of(string().required('Required')) as unknown as SchemaOf<Value>
+  );
+}
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx
new file mode 100644
index 000000000..a48991558
--- /dev/null
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx
@@ -0,0 +1,114 @@
+import { SetStateAction, useEffect, useState } from 'react';
+import { FormikErrors } from 'formik';
+
+import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
+
+import { getDefaultValues as getAppVariablesDefaultValues } from './EnvVarsFieldset';
+import { TemplateSelector } from './TemplateSelector';
+import { SelectedTemplateValue, Values } from './types';
+import { CustomTemplateFieldset } from './CustomTemplateFieldset';
+import { AppTemplateFieldset } from './AppTemplateFieldset';
+
+export function TemplateFieldset({
+  values: initialValues,
+  setValues: setInitialValues,
+  errors,
+}: {
+  errors?: FormikErrors<Values>;
+  values: Values;
+  setValues: (values: SetStateAction<Values>) => void;
+}) {
+  const [values, setControlledValues] = useState(initialValues); // todo remove when all view is in react
+
+  useEffect(() => {
+    if (
+      initialValues.type !== values.type ||
+      initialValues.template?.Id !== values.template?.Id
+    ) {
+      setControlledValues(initialValues);
+    }
+  }, [initialValues, values]);
+
+  return (
+    <>
+      <TemplateSelector
+        error={
+          typeof errors?.template === 'string' ? errors?.template : undefined
+        }
+        value={values}
+        onChange={handleChangeTemplate}
+      />
+      {values.template && (
+        <>
+          {values.type === 'custom' && (
+            <CustomTemplateFieldset
+              template={values.template}
+              values={values.variables}
+              onChange={(variables) =>
+                setValues((values) => ({ ...values, variables }))
+              }
+              errors={errors?.variables}
+            />
+          )}
+
+          {values.type === 'app' && (
+            <AppTemplateFieldset
+              template={values.template}
+              values={values.envVars}
+              onChange={(envVars) =>
+                setValues((values) => ({ ...values, envVars }))
+              }
+              errors={errors?.envVars}
+            />
+          )}
+        </>
+      )}
+    </>
+  );
+
+  function setValues(values: SetStateAction<Values>) {
+    setControlledValues(values);
+    setInitialValues(values);
+  }
+
+  function handleChangeTemplate(value?: SelectedTemplateValue) {
+    setValues(() => {
+      if (!value || !value.type || !value.template) {
+        return {
+          type: undefined,
+          template: undefined,
+          variables: [],
+          envVars: {},
+        };
+      }
+
+      if (value.type === 'app') {
+        return {
+          template: value.template,
+          type: value.type,
+          variables: [],
+          envVars: getAppVariablesDefaultValues(value.template.Env || []),
+        };
+      }
+
+      return {
+        template: value.template,
+        type: value.type,
+        variables: getVariablesFieldDefaultValues(
+          value.template.Variables || []
+        ),
+        envVars: {},
+      };
+    });
+  }
+}
+
+export function getInitialTemplateValues(): Values {
+  return {
+    template: undefined,
+    type: undefined,
+    variables: [],
+    file: '',
+    envVars: {},
+  };
+}
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.test.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.test.tsx
new file mode 100644
index 000000000..32f3d2ac1
--- /dev/null
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.test.tsx
@@ -0,0 +1,26 @@
+import { vi } from 'vitest';
+
+import { render, screen } from '@/react-tools/test-utils';
+
+import { TemplateNote } from './TemplateNote';
+
+vi.mock('sanitize-html', () => ({
+  default: (note: string) => note, // Mock the sanitize-html library to return the input as is
+}));
+
+test('renders template note', async () => {
+  render(<TemplateNote note="Test note" />);
+
+  const templateNoteElement = screen.getByText(/Information/);
+  expect(templateNoteElement).toBeInTheDocument();
+
+  const noteElement = screen.getByText(/Test note/);
+  expect(noteElement).toBeInTheDocument();
+});
+
+test('does not render template note when note is undefined', async () => {
+  render(<TemplateNote note={undefined} />);
+
+  const templateNoteElement = screen.queryByText(/Information/);
+  expect(templateNoteElement).not.toBeInTheDocument();
+});
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.tsx
new file mode 100644
index 000000000..49ead6eb3
--- /dev/null
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.tsx
@@ -0,0 +1,23 @@
+import sanitize from 'sanitize-html';
+
+export function TemplateNote({ note }: { note: string | undefined }) {
+  if (!note) {
+    return null;
+  }
+  return (
+    <div>
+      <div className="col-sm-12 form-section-title"> Information </div>
+      <div className="form-group">
+        <div className="col-sm-12">
+          <div
+            className="template-note"
+            // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{
+              __html: sanitize(note),
+            }}
+          />
+        </div>
+      </div>
+    </div>
+  );
+}
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.test.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.test.tsx
new file mode 100644
index 000000000..c68a1fb42
--- /dev/null
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.test.tsx
@@ -0,0 +1,149 @@
+import { vi } from 'vitest';
+import { HttpResponse, http } from 'msw';
+
+import { renderWithQueryClient, screen } from '@/react-tools/test-utils';
+import { AppTemplate } from '@/react/portainer/templates/app-templates/types';
+import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
+import { server } from '@/setup-tests/server';
+import selectEvent from '@/react/test-utils/react-select';
+
+import { SelectedTemplateValue } from './types';
+import { TemplateSelector } from './TemplateSelector';
+
+test('renders TemplateSelector component', async () => {
+  render();
+
+  const templateSelectorElement = screen.getByLabelText('Template');
+  expect(templateSelectorElement).toBeInTheDocument();
+});
+
+// eslint-disable-next-line vitest/expect-expect
+test('selects an edge app template', async () => {
+  const onChange = vi.fn();
+
+  const selectedTemplate = {
+    title: 'App Template 2',
+    description: 'Description 2',
+    id: 2,
+    categories: ['edge'],
+  };
+
+  const { select } = render({
+    onChange,
+    appTemplates: [
+      {
+        title: 'App Template 1',
+        description: 'Description 1',
+        id: 1,
+        categories: ['edge'],
+      },
+      selectedTemplate,
+    ],
+  });
+
+  await select('app', {
+    Title: selectedTemplate.title,
+    Description: selectedTemplate.description,
+  });
+});
+
+// eslint-disable-next-line vitest/expect-expect
+test('selects an edge custom template', async () => {
+  const onChange = vi.fn();
+
+  const selectedTemplate = {
+    Title: 'Custom Template 2',
+    Description: 'Description 2',
+    Id: 2,
+  };
+
+  const { select } = render({
+    onChange,
+    customTemplates: [
+      {
+        Title: 'Custom Template 1',
+        Description: 'Description 1',
+        Id: 1,
+      },
+      selectedTemplate,
+    ],
+  });
+
+  await select('custom', selectedTemplate);
+});
+
+test('renders with error', async () => {
+  render({
+    error: 'Invalid template',
+  });
+
+  const templateSelectorElement = screen.getByLabelText('Template');
+  expect(templateSelectorElement).toBeInTheDocument();
+
+  const errorElement = screen.getByText('Invalid template');
+  expect(errorElement).toBeInTheDocument();
+});
+
+test('renders TemplateSelector component with no custom templates available', async () => {
+  render({
+    customTemplates: [],
+  });
+
+  const templateSelectorElement = screen.getByLabelText('Template');
+  expect(templateSelectorElement).toBeInTheDocument();
+
+  await selectEvent.openMenu(templateSelectorElement);
+
+  const noCustomTemplatesElement = screen.getByText(
+    'No edge custom templates available'
+  );
+  expect(noCustomTemplatesElement).toBeInTheDocument();
+});
+
+function render({
+  onChange = vi.fn(),
+  appTemplates = [],
+  customTemplates = [],
+  error,
+}: {
+  onChange?: (value: SelectedTemplateValue) => void;
+  appTemplates?: Array<Partial<AppTemplate>>;
+  customTemplates?: Array<Partial<CustomTemplate>>;
+  error?: string;
+} = {}) {
+  server.use(
+    http.get('/api/registries', async () => HttpResponse.json([])),
+    http.get('/api/templates', async () =>
+      HttpResponse.json({ templates: appTemplates, version: '3' })
+    ),
+    http.get('/api/custom_templates', async () =>
+      HttpResponse.json(customTemplates)
+    )
+  );
+
+  renderWithQueryClient(
+    <TemplateSelector
+      value={{ template: undefined, type: undefined }}
+      onChange={onChange}
+      error={error}
+    />
+  );
+
+  return { select };
+
+  async function select(
+    type: 'app' | 'custom',
+    template: { Title: string; Description: string }
+  ) {
+    const templateSelectorElement = screen.getByLabelText('Template');
+    await selectEvent.select(
+      templateSelectorElement,
+      `${template.Title} - ${template.Description}`
+    );
+
+    expect(onChange).toHaveBeenCalledWith({
+      template: expect.objectContaining(template),
+      type,
+    });
+  }
+}
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.tsx
new file mode 100644
index 000000000..76f348a3d
--- /dev/null
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.tsx
@@ -0,0 +1,139 @@
+import { useMemo } from 'react';
+import { GroupBase } from 'react-select';
+
+import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
+import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
+import { TemplateType } from '@/react/portainer/templates/app-templates/types';
+
+import { FormControl } from '@@/form-components/FormControl';
+import { Select as ReactSelect } from '@@/form-components/ReactSelect';
+
+import { SelectedTemplateValue } from './types';
+
+export function TemplateSelector({
+  value,
+  onChange,
+  error,
+}: {
+  value: SelectedTemplateValue;
+  onChange: (value: SelectedTemplateValue) => void;
+  error?: string;
+}) {
+  const { getTemplate, options } = useOptions();
+
+  return (
+    <FormControl label="Template" inputId="template_selector" errors={error}>
+      <ReactSelect
+        inputId="template_selector"
+        formatGroupLabel={GroupLabel}
+        placeholder="Select an Edge stack template"
+        value={{
+          label: value.template?.Title,
+          id: value.template?.Id,
+          type: value.type,
+        }}
+        onChange={(value) => {
+          if (!value) {
+            onChange({
+              template: undefined,
+              type: undefined,
+            });
+            return;
+          }
+
+          const { id, type } = value;
+          if (!id || type === undefined) {
+            return;
+          }
+
+          const template = getTemplate({ id, type });
+          onChange({ template, type } as SelectedTemplateValue);
+        }}
+        options={options}
+      />
+    </FormControl>
+  );
+}
+
+function useOptions() {
+  const customTemplatesQuery = useCustomTemplates({
+    params: {
+      edge: true,
+    },
+  });
+
+  const appTemplatesQuery = useAppTemplates({
+    select: (templates) =>
+      templates.filter(
+        (template) =>
+          template.Categories.includes('edge') &&
+          template.Type !== TemplateType.Container
+      ),
+  });
+
+  const options = useMemo(
+    () =>
+      [
+        {
+          label: 'Edge App Templates',
+          options:
+            appTemplatesQuery.data?.map((template) => ({
+              label: `${template.Title} - ${template.Description}`,
+              id: template.Id,
+              type: 'app' as 'app' | 'custom',
+            })) || [],
+        },
+        {
+          label: 'Edge Custom Templates',
+          options:
+            customTemplatesQuery.data && customTemplatesQuery.data.length > 0
+              ? customTemplatesQuery.data.map((template) => ({
+                  label: `${template.Title} - ${template.Description}`,
+                  id: template.Id,
+                  type: 'custom' as 'app' | 'custom',
+                }))
+              : [
+                  {
+                    label: 'No edge custom templates available',
+                    id: 0,
+                    type: 'custom' as 'app' | 'custom',
+                  },
+                ],
+        },
+      ] as const,
+    [appTemplatesQuery.data, customTemplatesQuery.data]
+  );
+
+  return { options, getTemplate };
+
+  function getTemplate({ type, id }: { type: 'app' | 'custom'; id: number }) {
+    if (type === 'app') {
+      const template = appTemplatesQuery.data?.find(
+        (template) => template.Id === id
+      );
+
+      if (!template) {
+        throw new Error(`App template not found: ${id}`);
+      }
+
+      return template;
+    }
+
+    const template = customTemplatesQuery.data?.find(
+      (template) => template.Id === id
+    );
+
+    if (!template) {
+      throw new Error(`Custom template not found: ${id}`);
+    }
+    return template;
+  }
+}
+
+function GroupLabel({ label }: GroupBase<unknown>) {
+  return (
+    <span className="font-bold text-black th-dark:text-white th-highcontrast:text-white">
+      {label}
+    </span>
+  );
+}
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/types.ts b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/types.ts
new file mode 100644
index 000000000..4f8273e11
--- /dev/null
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/types.ts
@@ -0,0 +1,14 @@
+import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
+import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
+import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
+
+export type SelectedTemplateValue =
+  | { template: CustomTemplate; type: 'custom' }
+  | { template: TemplateViewModel; type: 'app' }
+  | { template: undefined; type: undefined };
+
+export type Values = {
+  file?: string;
+  variables: VariablesFieldValue;
+  envVars: Record<string, string>;
+} & SelectedTemplateValue;
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/validation.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/validation.tsx
new file mode 100644
index 000000000..eeabded15
--- /dev/null
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/validation.tsx
@@ -0,0 +1,32 @@
+import { mixed, object, SchemaOf, string } from 'yup';
+
+import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
+import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
+
+import { envVarsFieldsetValidation } from './EnvVarsFieldset';
+
+export function validation({
+  definitions,
+}: {
+  definitions: VariableDefinition[];
+}) {
+  return object({
+    type: string().oneOf(['custom', 'app']).required(),
+    envVars: envVarsFieldsetValidation()
+      .optional()
+      .when('type', {
+        is: 'app',
+        then: (schema: SchemaOf<unknown, never>) => schema.required(),
+      }),
+    file: mixed().optional(),
+    template: object().optional().default(null),
+    variables: variablesFieldValidation(definitions)
+      .optional()
+      .when('type', {
+        is: 'custom',
+        then: (schema) => schema.required(),
+      }),
+  });
+}
+
+export { validation as templateFieldsetValidation };
diff --git a/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx b/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx
index 86f47efb0..8c6a84785 100644
--- a/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx
+++ b/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx
@@ -1,37 +1,22 @@
-import { useParamState } from '@/react/hooks/useParamState';
 import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
 import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
 import { TemplateType } from '@/react/portainer/templates/app-templates/types';
 
 import { PageHeader } from '@@/PageHeader';
 
-import { DeployFormWidget } from './DeployForm';
-
 export function AppTemplatesView() {
-  const [selectedTemplateId, setSelectedTemplateId] = useParamState<number>(
-    'template',
-    (param) => (param ? parseInt(param, 10) : 0)
-  );
   const templatesQuery = useAppTemplates();
-  const selectedTemplate = selectedTemplateId
-    ? templatesQuery.data?.find(
-        (template) => template.Id === selectedTemplateId
-      )
-    : undefined;
+
   return (
     <>
       <PageHeader title="Application templates list" breadcrumbs="Templates" />
-      {selectedTemplate && (
-        <DeployFormWidget
-          template={selectedTemplate}
-          unselect={() => setSelectedTemplateId()}
-        />
-      )}
 
       <AppTemplatesList
         templates={templatesQuery.data}
-        selectedId={selectedTemplateId}
-        onSelect={(template) => setSelectedTemplateId(template.Id)}
+        templateLinkParams={(template) => ({
+          to: 'edge.stacks.new',
+          params: { templateId: template.Id, templateType: 'app' },
+        })}
         disabledTypes={[TemplateType.Container]}
         fixedCategories={['edge']}
         storageKey="edge-app-templates"
diff --git a/app/react/edge/templates/AppTemplatesView/DeployForm.tsx b/app/react/edge/templates/AppTemplatesView/DeployForm.tsx
deleted file mode 100644
index a0121a6f7..000000000
--- a/app/react/edge/templates/AppTemplatesView/DeployForm.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import { Rocket } from 'lucide-react';
-import { Form, Formik } from 'formik';
-import { array, lazy, number, object, string } from 'yup';
-import { useRouter } from '@uirouter/react';
-import _ from 'lodash';
-
-import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
-import { EnvironmentType } from '@/react/portainer/environments/types';
-import { notifySuccess } from '@/portainer/services/notifications';
-
-import { Widget } from '@@/Widget';
-import { FallbackImage } from '@@/FallbackImage';
-import { Icon } from '@@/Icon';
-import { FormActions } from '@@/form-components/FormActions';
-import { Button } from '@@/buttons';
-
-import { EdgeGroupsSelector } from '../../edge-stacks/components/EdgeGroupsSelector';
-import {
-  NameField,
-  nameValidation,
-} from '../../edge-stacks/CreateView/NameField';
-import { EdgeGroup } from '../../edge-groups/types';
-import { DeploymentType, EdgeStack } from '../../edge-stacks/types';
-import { useEdgeStacks } from '../../edge-stacks/queries/useEdgeStacks';
-import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
-import { useCreateEdgeStack } from '../../edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack';
-
-import { EnvVarsFieldset } from './EnvVarsFieldset';
-
-export function DeployFormWidget({
-  template,
-  unselect,
-}: {
-  template: TemplateViewModel;
-  unselect: () => void;
-}) {
-  return (
-    <div className="row">
-      <div className="col-sm-12">
-        <Widget>
-          <Widget.Title
-            icon={
-              <FallbackImage
-                src={template.Logo}
-                fallbackIcon={<Icon icon={Rocket} />}
-              />
-            }
-            title={template.Title}
-          />
-          <Widget.Body>
-            <DeployForm template={template} unselect={unselect} />
-          </Widget.Body>
-        </Widget>
-      </div>
-    </div>
-  );
-}
-
-interface FormValues {
-  name: string;
-  edgeGroupIds: Array<EdgeGroup['Id']>;
-  envVars: Record<string, string>;
-}
-
-function DeployForm({
-  template,
-  unselect,
-}: {
-  template: TemplateViewModel;
-  unselect: () => void;
-}) {
-  const router = useRouter();
-  const mutation = useCreateEdgeStack();
-  const edgeStacksQuery = useEdgeStacks();
-  const edgeGroupsQuery = useEdgeGroups({
-    select: (groups) =>
-      Object.fromEntries(groups.map((g) => [g.Id, g.EndpointTypes])),
-  });
-
-  const initialValues: FormValues = {
-    edgeGroupIds: [],
-    name: template.Name || '',
-    envVars:
-      Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
-      {},
-  };
-
-  if (!edgeStacksQuery.data || !edgeGroupsQuery.data) {
-    return null;
-  }
-
-  return (
-    <Formik
-      initialValues={initialValues}
-      onSubmit={handleSubmit}
-      validationSchema={() =>
-        validation(edgeStacksQuery.data, edgeGroupsQuery.data)
-      }
-      validateOnMount
-    >
-      {({ values, errors, setFieldValue, isValid }) => (
-        <Form className="form-horizontal">
-          <NameField
-            value={values.name}
-            onChange={(v) => setFieldValue('name', v)}
-            errors={errors.name}
-          />
-
-          <EdgeGroupsSelector
-            horizontal
-            value={values.edgeGroupIds}
-            error={errors.edgeGroupIds}
-            onChange={(value) => setFieldValue('edgeGroupIds', value)}
-            required
-          />
-
-          <EnvVarsFieldset
-            value={values.envVars}
-            options={template.Env}
-            errors={errors.envVars}
-            onChange={(values) => setFieldValue('envVars', values)}
-          />
-
-          <FormActions
-            isLoading={mutation.isLoading}
-            isValid={isValid}
-            loadingText="Deployment in progress..."
-            submitLabel="Deploy the stack"
-          >
-            <Button type="reset" onClick={() => unselect()} color="default">
-              Hide
-            </Button>
-          </FormActions>
-        </Form>
-      )}
-    </Formik>
-  );
-
-  function handleSubmit(values: FormValues) {
-    return mutation.mutate(
-      {
-        method: 'git',
-        payload: {
-          name: values.name,
-          edgeGroups: values.edgeGroupIds,
-          deploymentType: DeploymentType.Compose,
-
-          envVars: Object.entries(values.envVars).map(([name, value]) => ({
-            name,
-            value,
-          })),
-          git: {
-            RepositoryURL: template.Repository.url,
-            ComposeFilePathInRepository: template.Repository.stackfile,
-          },
-        },
-      },
-      {
-        onSuccess() {
-          notifySuccess('Success', 'Edge Stack created');
-          router.stateService.go('edge.stacks');
-        },
-      }
-    );
-  }
-}
-
-function validation(
-  stacks: EdgeStack[],
-  edgeGroupsType: Record<EdgeGroup['Id'], Array<EnvironmentType>>
-) {
-  return lazy((values: FormValues) => {
-    const types = getTypes(values.edgeGroupIds);
-
-    return object({
-      name: nameValidation(
-        stacks,
-        types?.includes(EnvironmentType.EdgeAgentOnDocker)
-      ),
-      edgeGroupIds: array(number().required().default(0))
-        .min(1, 'At least one group is required')
-        .test(
-          'same-type',
-          'Groups should be of the same type',
-          (value) => _.uniq(getTypes(value)).length === 1
-        ),
-      envVars: array()
-        .transform((_, orig) => Object.values(orig))
-        .of(string().required('Required')),
-    });
-  });
-
-  function getTypes(value: number[] | undefined) {
-    return value?.flatMap((g) => edgeGroupsType[g]);
-  }
-}
diff --git a/app/react/edge/templates/custom-templates/ListView/ListView.tsx b/app/react/edge/templates/custom-templates/ListView/ListView.tsx
index 2e1b83294..ef93cf78c 100644
--- a/app/react/edge/templates/custom-templates/ListView/ListView.tsx
+++ b/app/react/edge/templates/custom-templates/ListView/ListView.tsx
@@ -24,7 +24,7 @@ export function ListView() {
         onDelete={handleDelete}
         templateLinkParams={(template) => ({
           to: 'edge.stacks.new',
-          params: { templateId: template.Id },
+          params: { templateId: template.Id, templateType: 'custom' },
         })}
         storageKey="edge-custom-templates"
       />
diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.test.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.test.tsx
new file mode 100644
index 000000000..084430e2c
--- /dev/null
+++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.test.tsx
@@ -0,0 +1,98 @@
+import { vi } from 'vitest';
+import userEvent from '@testing-library/user-event';
+
+import { render, screen } from '@/react-tools/test-utils';
+
+import {
+  CustomTemplatesVariablesField,
+  Values,
+} from './CustomTemplatesVariablesField';
+
+test('renders CustomTemplatesVariablesField component', () => {
+  const onChange = vi.fn();
+  const definitions = [
+    {
+      name: 'Variable1',
+      label: 'Variable 1',
+      description: 'Description 1',
+      defaultValue: 'Default 1',
+    },
+    {
+      name: 'Variable2',
+      label: 'Variable 2',
+      description: 'Description 2',
+      defaultValue: 'Default 2',
+    },
+  ];
+  const value: Values = [
+    { key: 'Variable1', value: 'Value 1' },
+    { key: 'Variable2', value: 'Value 2' },
+  ];
+
+  render(
+    <CustomTemplatesVariablesField
+      onChange={onChange}
+      definitions={definitions}
+      value={value}
+    />
+  );
+
+  const variableFieldItems = screen.getAllByLabelText(/Variable \d/);
+  expect(variableFieldItems).toHaveLength(2);
+});
+
+test('calls onChange when variable value is changed', async () => {
+  const user = userEvent.setup();
+  const onChange = vi.fn();
+  const definitions = [
+    {
+      name: 'Variable1',
+      label: 'Variable 1',
+      description: 'Description 1',
+      defaultValue: 'Default 1',
+    },
+  ];
+  const value: Values = [{ key: 'Variable1', value: 'Value 1' }];
+
+  render(
+    <CustomTemplatesVariablesField
+      onChange={onChange}
+      definitions={definitions}
+      value={value}
+    />
+  );
+
+  const inputElement = screen.getByLabelText('Variable 1');
+
+  await user.clear(inputElement);
+  expect(onChange).toHaveBeenCalledWith([{ key: 'Variable1', value: '' }]);
+
+  await user.type(inputElement, 'New Value');
+  expect(onChange).toHaveBeenCalled();
+});
+
+test('renders error message when errors prop is provided', () => {
+  const onChange = vi.fn();
+  const definitions = [
+    {
+      name: 'Variable1',
+      label: 'Variable 1',
+      description: 'Description 1',
+      defaultValue: 'Default 1',
+    },
+  ];
+  const value: Values = [{ key: 'Variable1', value: 'Value 1' }];
+  const errors = [{ value: 'Error message' }];
+
+  render(
+    <CustomTemplatesVariablesField
+      onChange={onChange}
+      definitions={definitions}
+      value={value}
+      errors={errors}
+    />
+  );
+
+  const errorElement = screen.getByText('Error message');
+  expect(errorElement).toBeInTheDocument();
+});
diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.tsx
index 3e7d6ecc8..d5b408c03 100644
--- a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.tsx
+++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.tsx
@@ -1,13 +1,11 @@
-import { array, object, string } from 'yup';
-
-import { FormControl } from '@@/form-components/FormControl';
 import { FormSection } from '@@/form-components/FormSection/FormSection';
-import { Input } from '@@/form-components/Input';
 import { ArrayError } from '@@/form-components/InputList/InputList';
 import { FormError } from '@@/form-components/FormError';
 
 import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
 
+import { VariableFieldItem } from './VariableFieldItem';
+
 export type Values = Array<{ key: string; value?: string }>;
 
 interface Props {
@@ -33,8 +31,8 @@ export function CustomTemplatesVariablesField({
         <VariableFieldItem
           key={definition.name}
           definition={definition}
-          value={value.find((v) => v.key === definition.name)?.value || ''}
           error={getError(errors, index)}
+          value={value.find((v) => v.key === definition.name)?.value}
           onChange={(fieldValue) => {
             onChange(
               value.map((v) =>
@@ -50,39 +48,6 @@ export function CustomTemplatesVariablesField({
   );
 }
 
-function VariableFieldItem({
-  definition,
-  value,
-  error,
-  onChange,
-}: {
-  definition: VariableDefinition;
-  value: string;
-  error?: string;
-  onChange: (value: string) => void;
-}) {
-  const inputId = `${definition.name}-input`;
-
-  return (
-    <FormControl
-      required={!definition.defaultValue}
-      label={definition.label}
-      key={definition.name}
-      inputId={inputId}
-      tooltip={definition.description}
-      size="small"
-      errors={error}
-    >
-      <Input
-        name={`variables.${definition.name}`}
-        value={value}
-        id={inputId}
-        onChange={(e) => onChange(e.target.value)}
-      />
-    </FormControl>
-  );
-}
-
 function getError(errors: ArrayError<Values> | undefined, index: number) {
   if (!errors || typeof errors !== 'object') {
     return undefined;
@@ -95,22 +60,3 @@ function getError(errors: ArrayError<Values> | undefined, index: number) {
 
   return typeof error === 'object' ? error.value : error;
 }
-export function validation(definitions: VariableDefinition[]) {
-  return array(
-    object({
-      key: string().default(''),
-      value: string().default(''),
-    }).test('required-if-no-default-value', 'This field is required', (obj) => {
-      const definition = definitions.find((d) => d.name === obj.key);
-      if (!definition) {
-        return true;
-      }
-
-      if (!definition.defaultValue && !obj.value) {
-        return false;
-      }
-
-      return true;
-    })
-  );
-}
diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.test.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.test.tsx
new file mode 100644
index 000000000..9b37bf6c2
--- /dev/null
+++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.test.tsx
@@ -0,0 +1,53 @@
+import { vi } from 'vitest';
+import userEvent from '@testing-library/user-event';
+
+import { render, screen } from '@/react-tools/test-utils';
+
+import { VariableFieldItem } from './VariableFieldItem';
+
+test('renders VariableFieldItem component', () => {
+  const definition = {
+    name: 'variableName',
+    label: 'Variable Label',
+    description: 'Variable Description',
+    defaultValue: 'Default Value',
+  };
+
+  render(<VariableFieldItem definition={definition} onChange={vi.fn()} />);
+
+  const labelElement = screen.getByText('Variable Label');
+  expect(labelElement).toBeInTheDocument();
+
+  const inputElement = screen.getByPlaceholderText(
+    'Enter value or leave blank to use default of Default Value'
+  );
+  expect(inputElement).toBeInTheDocument();
+});
+
+test('calls onChange when input value changes', async () => {
+  const user = userEvent.setup();
+  const onChange = vi.fn();
+
+  const definition = {
+    name: 'variableName',
+    label: 'Variable Label',
+    description: 'Variable Description',
+    defaultValue: 'Default Value',
+  };
+
+  render(
+    <VariableFieldItem
+      definition={definition}
+      onChange={onChange}
+      value="value"
+    />
+  );
+
+  const inputElement = screen.getByLabelText(definition.label);
+
+  await user.clear(inputElement);
+  expect(onChange).toHaveBeenCalledWith('');
+
+  await user.type(inputElement, 'New Value');
+  expect(onChange).toHaveBeenCalled();
+});
diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.tsx
new file mode 100644
index 000000000..e4083f909
--- /dev/null
+++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.tsx
@@ -0,0 +1,38 @@
+import { FormControl } from '@@/form-components/FormControl';
+import { Input } from '@@/form-components/Input';
+
+import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
+
+export function VariableFieldItem({
+  definition,
+  error,
+  onChange,
+  value,
+}: {
+  definition: VariableDefinition;
+  error?: string;
+  onChange: (value: string) => void;
+  value?: string;
+}) {
+  const inputId = `${definition.name}-input`;
+
+  return (
+    <FormControl
+      required={!definition.defaultValue}
+      label={definition.label}
+      key={definition.name}
+      inputId={inputId}
+      tooltip={definition.description}
+      size="small"
+      errors={error}
+    >
+      <Input
+        name={`variables.${definition.name}`}
+        id={inputId}
+        value={value}
+        onChange={(e) => onChange(e.target.value)}
+        placeholder={`Enter value or leave blank to use default of ${definition.defaultValue}`}
+      />
+    </FormControl>
+  );
+}
diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/index.ts b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/index.ts
index 014903d90..9824f371f 100644
--- a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/index.ts
+++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/index.ts
@@ -1,7 +1,8 @@
 export {
   CustomTemplatesVariablesField,
   type Values as VariablesFieldValue,
-  validation as variablesFieldValidation,
 } from './CustomTemplatesVariablesField';
 
+export { validation as variablesFieldValidation } from './validation';
+
 export { getDefaultValues as getVariablesFieldDefaultValues } from './getDefaultValues';
diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/validation.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/validation.tsx
new file mode 100644
index 000000000..be4668d65
--- /dev/null
+++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/validation.tsx
@@ -0,0 +1,23 @@
+import { array, object, string } from 'yup';
+
+import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
+
+export function validation(definitions: VariableDefinition[]) {
+  return array(
+    object({
+      key: string().default(''),
+      value: string().default(''),
+    }).test('required-if-no-default-value', 'This field is required', (obj) => {
+      const definition = definitions.find((d) => d.name === obj.key);
+      if (!definition) {
+        return true;
+      }
+
+      if (!definition.defaultValue && !obj.value) {
+        return false;
+      }
+
+      return true;
+    })
+  );
+}
diff --git a/app/react/portainer/templates/app-templates/AppTemplatesList.tsx b/app/react/portainer/templates/app-templates/AppTemplatesList.tsx
index ab013e2d2..1184f34d6 100644
--- a/app/react/portainer/templates/app-templates/AppTemplatesList.tsx
+++ b/app/react/portainer/templates/app-templates/AppTemplatesList.tsx
@@ -21,13 +21,18 @@ export function AppTemplatesList({
   disabledTypes,
   fixedCategories,
   storageKey,
+  templateLinkParams,
 }: {
   storageKey: string;
   templates?: TemplateViewModel[];
-  onSelect: (template: TemplateViewModel) => void;
+  onSelect?: (template: TemplateViewModel) => void;
   selectedId?: TemplateViewModel['Id'];
   disabledTypes?: Array<TemplateType>;
   fixedCategories?: Array<string>;
+  templateLinkParams?: (template: TemplateViewModel) => {
+    to: string;
+    params: object;
+  };
 }) {
   const [page, setPage] = useState(0);
   const [store] = useState(() =>
@@ -88,6 +93,7 @@ export function AppTemplatesList({
             template={template}
             onSelect={onSelect}
             isSelected={selectedId === template.Id}
+            linkParams={templateLinkParams?.(template)}
           />
         ))}
         {!templates && <div className="text-muted text-center">Loading...</div>}
diff --git a/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx b/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx
index 9e0abdc82..abba78ccf 100644
--- a/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx
+++ b/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx
@@ -12,10 +12,12 @@ export function AppTemplatesListItem({
   template,
   onSelect,
   isSelected,
+  linkParams,
 }: {
   template: TemplateViewModel;
-  onSelect: (template: TemplateViewModel) => void;
+  onSelect?: (template: TemplateViewModel) => void;
   isSelected: boolean;
+  linkParams?: { to: string; params: object };
 }) {
   const duplicateCustomTemplateType = getCustomTemplateType(template.Type);
 
@@ -25,7 +27,8 @@ export function AppTemplatesListItem({
       typeLabel={
         template.Type === TemplateType.Container ? 'container' : 'stack'
       }
-      onSelect={() => onSelect(template)}
+      linkParams={linkParams}
+      onSelect={() => onSelect?.(template)}
       isSelected={isSelected}
       renderActions={
         duplicateCustomTemplateType && (
diff --git a/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts b/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts
index cdc116463..589c761d0 100644
--- a/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts
+++ b/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts
@@ -10,7 +10,9 @@ import { TemplateViewModel } from '../view-model';
 
 import { buildUrl } from './build-url';
 
-export function useAppTemplates() {
+export function useAppTemplates<T = Array<TemplateViewModel>>({
+  select,
+}: { select?: (templates: Array<TemplateViewModel>) => T } = {}) {
   const registriesQuery = useRegistries();
 
   return useQuery(
@@ -18,6 +20,7 @@ export function useAppTemplates() {
     () => getTemplatesWithRegistry(registriesQuery.data),
     {
       enabled: !!registriesQuery.data,
+      select,
     }
   );
 }
@@ -29,7 +32,7 @@ async function getTemplatesWithRegistry(
     return [];
   }
 
-  const { templates, version } = await getTemplates();
+  const { templates, version } = await getAppTemplates();
   return templates.map((item) => {
     const template = new TemplateViewModel(item, version);
     const registryURL = item.registry;
@@ -41,7 +44,7 @@ async function getTemplatesWithRegistry(
   });
 }
 
-async function getTemplates() {
+export async function getAppTemplates() {
   try {
     const { data } = await axios.get<{
       version: string;
diff --git a/app/react/portainer/templates/app-templates/view-model.ts b/app/react/portainer/templates/app-templates/view-model.ts
index 4468fc2b8..5714058c0 100644
--- a/app/react/portainer/templates/app-templates/view-model.ts
+++ b/app/react/portainer/templates/app-templates/view-model.ts
@@ -150,7 +150,7 @@ function templateVolumes(data: AppTemplate) {
   );
 }
 
-enum EnvVarType {
+export enum EnvVarType {
   PreSelected = 1,
   Text = 2,
   Select = 3,
diff --git a/app/react/test-utils/react-select.ts b/app/react/test-utils/react-select.ts
new file mode 100644
index 000000000..43c6fed42
--- /dev/null
+++ b/app/react/test-utils/react-select.ts
@@ -0,0 +1,188 @@
+/** Simulate user events on react-select dropdowns
+ *
+ * taken from https://github.com/lokalise/react-select-event/blob/migrate-to-user-event/src/index.ts
+ * until package is updated
+ */
+
+import {
+  Matcher,
+  findAllByText,
+  findByText,
+  waitFor,
+} from '@testing-library/dom';
+import userEvent from '@testing-library/user-event';
+
+// find the react-select container from its input field 🤷
+function getReactSelectContainerFromInput(input: HTMLElement): HTMLElement {
+  return input.parentNode!.parentNode!.parentNode!.parentNode!
+    .parentNode as HTMLElement;
+}
+
+type User = ReturnType<typeof userEvent.setup> | typeof userEvent;
+
+type UserEventOptions = {
+  user?: User;
+};
+
+/**
+ * Utility for opening the select's dropdown menu.
+ * @param {HTMLElement} input The input field (eg. `getByLabelText('The label')`)
+ */
+export async function openMenu(
+  input: HTMLElement,
+  { user = userEvent }: UserEventOptions = {}
+) {
+  await user.click(input);
+  await user.type(input, '{ArrowDown}');
+}
+
+// type text in the input field
+async function type(
+  input: HTMLElement,
+  text: string,
+  { user }: Required<UserEventOptions>
+) {
+  await user.type(input, text);
+}
+
+// press the "clear" button, and reset various states
+async function clear(
+  clearButton: Element,
+  { user }: Required<UserEventOptions>
+) {
+  await user.click(clearButton);
+}
+
+interface Config extends UserEventOptions {
+  /** A container where the react-select dropdown gets rendered to.
+   *  Useful when rendering the dropdown in a portal using `menuPortalTarget`.
+   */
+  container?: HTMLElement | (() => HTMLElement);
+}
+
+/**
+ * Utility for selecting a value in a `react-select` dropdown.
+ * @param {HTMLElement} input The input field (eg. `getByLabelText('The label')`)
+ * @param {Matcher|Matcher[]} optionOrOptions The display name(s) for the option(s) to select
+ * @param {Object} config Optional config options
+ * @param {HTMLElement | (() => HTMLElement)} config.container A container for the react-select and its dropdown (defaults to the react-select container)
+ *            Useful when rending the dropdown to a portal using react-select's `menuPortalTarget`.
+ *            Can be specified as a function if it needs to be lazily evaluated.
+ */
+export async function select(
+  input: HTMLElement,
+  optionOrOptions: Matcher | Array<Matcher>,
+  { user = userEvent, ...config }: Config = {}
+) {
+  const options = Array.isArray(optionOrOptions)
+    ? optionOrOptions
+    : [optionOrOptions];
+
+  // Select the items we care about
+  // eslint-disable-next-line no-restricted-syntax
+  for (const option of options) {
+    await openMenu(input, { user });
+
+    let container;
+    if (typeof config.container === 'function') {
+      // when specified as a function, the container needs to be lazily evaluated, so
+      // we have to wait for it to be visible:
+      await waitFor(config.container);
+      container = config.container();
+    } else if (config.container) {
+      container = config.container;
+    } else {
+      container = getReactSelectContainerFromInput(input);
+    }
+
+    // only consider visible, interactive elements
+    const matchingElements = await findAllByText(container, option, {
+      ignore: "[aria-live] *,[style*='visibility: hidden']",
+    });
+
+    // When the target option is already selected, the react-select display text
+    // will also match the selector. In this case, the actual dropdown element is
+    // positioned last in the DOM tree.
+    const optionElement = matchingElements[matchingElements.length - 1];
+    await user.click(optionElement);
+  }
+}
+
+interface CreateConfig extends Config, UserEventOptions {
+  createOptionText?: string | RegExp;
+  waitForElement?: boolean;
+}
+/**
+ * Utility for creating and selecting a value in a Creatable `react-select` dropdown.
+ * @async
+ * @param {HTMLElement} input The input field (eg. `getByLabelText('The label')`)
+ * @param {String} option The display name for the option to type and select
+ * @param {Object} config Optional config options
+ * @param {HTMLElement} config.container A container for the react-select and its dropdown (defaults to the react-select container)
+ *                         Useful when rending the dropdown to a portal using react-select's `menuPortalTarget`
+ * @param {boolean} config.waitForElement Whether create should wait for new option to be populated in the select container
+ * @param {String|RegExp} config.createOptionText Custom label for the "create new ..." option in the menu (string or regexp)
+ */
+export async function create(
+  input: HTMLElement,
+  option: string,
+  { waitForElement = true, user = userEvent, ...config }: CreateConfig = {}
+) {
+  const createOptionText = config.createOptionText || /^Create "/;
+  await openMenu(input, { user });
+  await type(input, option, { user });
+
+  await select(input, createOptionText, { ...config, user });
+
+  if (waitForElement) {
+    await findByText(getReactSelectContainerFromInput(input), option);
+  }
+}
+
+/**
+ * Utility for clearing the first value of a `react-select` dropdown.
+ * @param {HTMLElement} input The input field (eg. `getByLabelText('The label')`)
+ */
+export async function clearFirst(
+  input: HTMLElement,
+  { user = userEvent }: UserEventOptions = {}
+) {
+  const container = getReactSelectContainerFromInput(input);
+  // The "clear" button is the first svg element that is hidden to screen readers
+  const clearButton = container.querySelector('svg[aria-hidden="true"]')!;
+  await clear(clearButton, { user });
+}
+
+/**
+ * Utility for clearing all values in a `react-select` dropdown.
+ * @param {HTMLElement} input The input field (eg. `getByLabelText('The label')`)
+ */
+export async function clearAll(
+  input: HTMLElement,
+  { user = userEvent }: UserEventOptions = {}
+) {
+  const container = getReactSelectContainerFromInput(input);
+  // The "clear all" button is the penultimate svg element that is hidden to screen readers
+  // (the last one is the dropdown arrow)
+  const elements = container.querySelectorAll('svg[aria-hidden="true"]');
+  const clearAllButton = elements[elements.length - 2];
+  await clear(clearAllButton, { user });
+}
+
+function setup(user: User): typeof selectEvent {
+  return {
+    select: (...params: Parameters<typeof select>) =>
+      select(params[0], params[1], { user, ...params[2] }),
+    create: (...params: Parameters<typeof create>) =>
+      create(params[0], params[1], { user, ...params[2] }),
+    clearFirst: (...params: Parameters<typeof clearFirst>) =>
+      clearFirst(params[0], { user, ...params[1] }),
+    clearAll: (...params: Parameters<typeof clearAll>) =>
+      clearAll(params[0], { user, ...params[1] }),
+    openMenu: (...params: Parameters<typeof openMenu>) =>
+      openMenu(params[0], { user, ...params[1] }),
+  };
+}
+
+const selectEvent = { select, create, clearFirst, clearAll, openMenu };
+export default { ...selectEvent, setup };
diff --git a/app/setup-tests/setup-msw.ts b/app/setup-tests/setup-msw.ts
index 4df7ca3c6..118196636 100644
--- a/app/setup-tests/setup-msw.ts
+++ b/app/setup-tests/setup-msw.ts
@@ -1,6 +1,10 @@
 import { server } from './server';
 
-beforeAll(() => server.listen());
+beforeAll(() =>
+  server.listen({
+    onUnhandledRequest: 'error',
+  })
+);
 // if you need to add a handler after calling setupServer for some specific test
 // this will remove that handler for the rest of them
 // (which is important for test isolation):