diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx
new file mode 100644
index 0000000000..46a57bab40
--- /dev/null
+++ b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx
@@ -0,0 +1,31 @@
+import type { FC } from "react";
+import { useQuery } from "react-query";
+import { Navigate } from "react-router";
+import { deploymentConfig } from "#/api/queries/deployment";
+import { Loader } from "#/components/Loader/Loader";
+import { pageTitle } from "#/utils/page";
+import { TemplateBuilderPageView } from "./TemplateBuilderPageView";
+
+const TemplateBuilderPage: FC = () => {
+ const { data, error, isLoading } = useQuery(deploymentConfig());
+
+ if (isLoading) {
+ return ;
+ }
+
+ // if the template builder is disabled in the deployment config,
+ // redirect to the new template page
+ const builderDisabled = data?.config?.template_builder?.disabled ?? false;
+ if (builderDisabled) {
+ return ;
+ }
+
+ return (
+ <>
+
{pageTitle("Create Template")}
+
+ >
+ );
+};
+
+export default TemplateBuilderPage;
diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx
new file mode 100644
index 0000000000..df5eb67754
--- /dev/null
+++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx
@@ -0,0 +1,119 @@
+import { type FC, useReducer, useState } from "react";
+import { ErrorAlert } from "#/components/Alert/ErrorAlert";
+import { Button } from "#/components/Button/Button";
+import { Link } from "#/components/Link/Link";
+import { Margins } from "#/components/Margins/Margins";
+import {
+ PageHeader,
+ PageHeaderSubtitle,
+ PageHeaderTitle,
+} from "#/components/PageHeader/PageHeader";
+import { docs } from "#/utils/docs";
+import { SelectionSummary } from "./SelectionSummary";
+import {
+ findNextVisibleIndex,
+ findPrevVisibleIndex,
+ nearestVisible,
+ WIZARD_STEPS,
+} from "./steps";
+import { initialWizardState, wizardReducer } from "./wizardState";
+
+interface TemplateBuilderPageViewProps {
+ error: unknown;
+}
+
+export const TemplateBuilderPageView: FC = ({
+ error,
+}) => {
+ const [state, dispatch] = useReducer(wizardReducer, initialWizardState);
+ const [stepIndex, setStepIndex] = useState(0);
+
+ const currentIndex = nearestVisible(stepIndex, state);
+ const currentStep = WIZARD_STEPS[currentIndex];
+ const nextIndex = findNextVisibleIndex(currentIndex, state);
+ const prevIndex = findPrevVisibleIndex(currentIndex, state);
+ const isFirstStep = prevIndex === -1;
+ const isLastStep = nextIndex === -1;
+
+ const handleBack = () => {
+ setStepIndex(prevIndex);
+ };
+
+ const handleNext = () => {
+ if (isLastStep) {
+ // Compose will be wired in a follow-up issue.
+ return;
+ }
+ setStepIndex(nextIndex);
+ };
+
+ const handleDeselectModule = (moduleId: string) => {
+ dispatch({
+ type: "SET_MODULES",
+ modules: state.modules.filter((m) => m.id !== moduleId),
+ meta: state.selectedModules.filter((m) => m.id !== moduleId),
+ });
+ };
+
+ return (
+
+
+ Create new template
+
+ A Terraform blueprint for reproducible workspaces
+
+ View docs
+
+
+
+
+ {error != null && }
+
+
+ {/* Main content area */}
+
+
+
+ Step: {currentStep.id}
+
+
+
+ {/* Navigation controls */}
+
+ {isFirstStep ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Sidebar */}
+
+ 0
+ ? state.selectedModules
+ : undefined
+ }
+ onDeselectModule={handleDeselectModule}
+ />
+
+
+
+ );
+};
diff --git a/site/src/pages/TemplateBuilder/steps.test.ts b/site/src/pages/TemplateBuilder/steps.test.ts
new file mode 100644
index 0000000000..b77c09506a
--- /dev/null
+++ b/site/src/pages/TemplateBuilder/steps.test.ts
@@ -0,0 +1,228 @@
+import { describe, expect, it } from "vitest";
+import {
+ findNextVisibleIndex,
+ findPrevVisibleIndex,
+ nearestVisible,
+ stepModuleSettingsRequired,
+ WIZARD_STEPS,
+} from "./steps";
+import type { TemplateBuilderWizardState } from "./wizardState";
+import { initialWizardState } from "./wizardState";
+
+function stateWith(
+ overrides: Partial,
+): TemplateBuilderWizardState {
+ return { ...initialWizardState, ...overrides };
+}
+
+describe("WIZARD_STEPS", () => {
+ it("has five steps in three groups", () => {
+ expect(WIZARD_STEPS).toHaveLength(5);
+ expect(WIZARD_STEPS.map((s) => s.group)).toEqual([1, 1, 2, 2, 3]);
+ });
+});
+
+describe("shouldSkip", () => {
+ const stepById = (id: string) => WIZARD_STEPS.find((s) => s.id === id)!;
+
+ it("never skips base-infra", () => {
+ expect(stepById("base-infra").shouldSkip(initialWizardState)).toBe(false);
+ });
+
+ it("skips base-parameters when base has no parameters", () => {
+ const noParams = stateWith({
+ selectedBase: {
+ id: "docker",
+ name: "Docker",
+ hasParameters: false,
+ },
+ });
+ expect(stepById("base-parameters").shouldSkip(noParams)).toBe(true);
+ });
+
+ it("does not skip base-parameters when base has parameters", () => {
+ const withParams = stateWith({
+ selectedBase: {
+ id: "aws-linux",
+ name: "AWS Linux",
+ hasParameters: true,
+ },
+ });
+ expect(stepById("base-parameters").shouldSkip(withParams)).toBe(false);
+ });
+
+ it("skips base-parameters when no base is selected", () => {
+ expect(stepById("base-parameters").shouldSkip(initialWizardState)).toBe(
+ true,
+ );
+ });
+
+ it("never skips module-select", () => {
+ expect(stepById("module-select").shouldSkip(initialWizardState)).toBe(
+ false,
+ );
+ });
+
+ it("skips module-settings when no modules have configurable vars", () => {
+ const noConfigVars = stateWith({
+ selectedModules: [
+ {
+ id: "npm-config",
+ name: "npm-config",
+ iconUrl: "/npm.svg",
+ hasConfigurableVars: false,
+ },
+ ],
+ });
+ expect(stepById("module-settings").shouldSkip(noConfigVars)).toBe(true);
+ });
+
+ it("does not skip module-settings when modules have configurable vars", () => {
+ const withConfigVars = stateWith({
+ selectedModules: [
+ {
+ id: "code-server",
+ name: "code-server",
+ iconUrl: "/icon.svg",
+ hasConfigurableVars: true,
+ },
+ ],
+ });
+ expect(stepById("module-settings").shouldSkip(withConfigVars)).toBe(false);
+ });
+
+ it("skips module-settings when no modules are selected", () => {
+ expect(stepById("module-settings").shouldSkip(initialWizardState)).toBe(
+ true,
+ );
+ });
+
+ it("never skips customizations", () => {
+ expect(stepById("customizations").shouldSkip(initialWizardState)).toBe(
+ false,
+ );
+ });
+});
+
+describe("stepModuleSettingsRequired", () => {
+ it("returns false when no modules are selected", () => {
+ expect(stepModuleSettingsRequired(initialWizardState)).toBe(false);
+ });
+
+ it("returns true when at least one module has configurable vars", () => {
+ const state = stateWith({
+ selectedModules: [
+ {
+ id: "a",
+ name: "A",
+ iconUrl: "/a.svg",
+ hasConfigurableVars: false,
+ },
+ {
+ id: "b",
+ name: "B",
+ iconUrl: "/b.svg",
+ hasConfigurableVars: true,
+ },
+ ],
+ });
+ expect(stepModuleSettingsRequired(state)).toBe(true);
+ });
+});
+
+describe("findNextVisibleIndex", () => {
+ it("skips base-parameters when base has no parameters", () => {
+ const state = stateWith({
+ selectedBase: {
+ id: "docker",
+ name: "Docker",
+ hasParameters: false,
+ },
+ });
+ // From base-infra (index 0), next visible should be module-select (index 2).
+ const next = findNextVisibleIndex(0, state);
+ expect(WIZARD_STEPS[next].id).toBe("module-select");
+ });
+
+ it("skips module-settings when no modules have configurable vars", () => {
+ // From module-select (index 2), next visible should be customizations (index 4).
+ const next = findNextVisibleIndex(2, initialWizardState);
+ expect(WIZARD_STEPS[next].id).toBe("customizations");
+ });
+
+ it("returns -1 from the last step", () => {
+ expect(findNextVisibleIndex(4, initialWizardState)).toBe(-1);
+ });
+
+ it("advances to adjacent step when nothing is skipped", () => {
+ const state = stateWith({
+ selectedBase: {
+ id: "aws-linux",
+ name: "AWS Linux",
+ hasParameters: true,
+ },
+ selectedModules: [
+ {
+ id: "code-server",
+ name: "code-server",
+ iconUrl: "/icon.svg",
+ hasConfigurableVars: true,
+ },
+ ],
+ });
+ // All steps visible: 0 -> 1 -> 2 -> 3 -> 4
+ expect(findNextVisibleIndex(0, state)).toBe(1);
+ expect(findNextVisibleIndex(1, state)).toBe(2);
+ expect(findNextVisibleIndex(2, state)).toBe(3);
+ expect(findNextVisibleIndex(3, state)).toBe(4);
+ expect(findNextVisibleIndex(4, state)).toBe(-1);
+ });
+});
+
+describe("findPrevVisibleIndex", () => {
+ it("skips base-parameters backward when base has no parameters", () => {
+ const state = stateWith({
+ selectedBase: {
+ id: "docker",
+ name: "Docker",
+ hasParameters: false,
+ },
+ });
+ // From module-select (index 2), prev visible should be base-infra (index 0).
+ const prev = findPrevVisibleIndex(2, state);
+ expect(WIZARD_STEPS[prev].id).toBe("base-infra");
+ });
+
+ it("returns -1 from the first step", () => {
+ expect(findPrevVisibleIndex(0, initialWizardState)).toBe(-1);
+ });
+});
+
+describe("nearestVisible", () => {
+ it("returns the same index if not skipped", () => {
+ expect(nearestVisible(0, initialWizardState)).toBe(0);
+ expect(nearestVisible(4, initialWizardState)).toBe(4);
+ });
+
+ it("falls back to a prior visible step", () => {
+ // base-parameters (index 1) is skipped when no base has parameters.
+ const nearest = nearestVisible(1, initialWizardState);
+ expect(WIZARD_STEPS[nearest].id).toBe("base-infra");
+ });
+
+ it("falls forward when no prior step is visible", () => {
+ // Construct a state where everything is visible except base-infra.
+ // Since base-infra is never skipped this is a synthetic edge case,
+ // but it validates the forward search path.
+ const allSkippable: TemplateBuilderWizardState = {
+ ...initialWizardState,
+ selectedBase: {
+ id: "docker",
+ name: "Docker",
+ hasParameters: false,
+ },
+ };
+ // Index 1 (base-parameters) is skipped, nearest backward is 0 (base-infra).
+ expect(nearestVisible(1, allSkippable)).toBe(0);
+ });
+});
diff --git a/site/src/pages/TemplateBuilder/steps.ts b/site/src/pages/TemplateBuilder/steps.ts
new file mode 100644
index 0000000000..9d0d7fd682
--- /dev/null
+++ b/site/src/pages/TemplateBuilder/steps.ts
@@ -0,0 +1,121 @@
+import type { TemplateBuilderWizardState } from "./wizardState";
+
+type StepId =
+ | "base-infra"
+ | "base-parameters"
+ | "module-select"
+ | "module-settings"
+ | "customizations";
+
+type WizardStep = {
+ id: StepId;
+ /** Maps to one of the three sidebar groups in SelectionSummary. */
+ group: 1 | 2 | 3;
+ /** Return true to skip this step during navigation. */
+ shouldSkip: (state: TemplateBuilderWizardState) => boolean;
+};
+
+/**
+ * Returns true when at least one selected module exposes variables that
+ * need user configuration (non-sensitive).
+ */
+export const stepModuleSettingsRequired = (
+ state: TemplateBuilderWizardState,
+): boolean => {
+ return state.selectedModules.some((m) => m.hasConfigurableVars);
+};
+
+/**
+ * Ordered registry of all wizard steps.
+ *
+ * Invariant: a step must not edit the state slice that controls its own
+ * shouldSkip predicate.
+ */
+export const WIZARD_STEPS: readonly WizardStep[] = [
+ {
+ id: "base-infra",
+ group: 1,
+ shouldSkip: () => false,
+ },
+ {
+ id: "base-parameters",
+ group: 1,
+ shouldSkip: (state) => !state.selectedBase?.hasParameters,
+ },
+ {
+ id: "module-select",
+ group: 2,
+ shouldSkip: () => false,
+ },
+ {
+ id: "module-settings",
+ group: 2,
+ shouldSkip: (state) => !stepModuleSettingsRequired(state),
+ },
+ {
+ id: "customizations",
+ group: 3,
+ shouldSkip: () => false,
+ },
+];
+
+/**
+ * Find the next visible (non-skipped) step index starting after `fromIndex`.
+ * Returns -1 if no visible step exists ahead.
+ */
+export function findNextVisibleIndex(
+ fromIndex: number,
+ state: TemplateBuilderWizardState,
+): number {
+ for (let i = fromIndex + 1; i < WIZARD_STEPS.length; i++) {
+ if (!WIZARD_STEPS[i].shouldSkip(state)) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+/**
+ * Find the previous visible (non-skipped) step index before `fromIndex`.
+ * Returns -1 if no visible step exists behind.
+ */
+export function findPrevVisibleIndex(
+ fromIndex: number,
+ state: TemplateBuilderWizardState,
+): number {
+ for (let i = fromIndex - 1; i >= 0; i--) {
+ if (!WIZARD_STEPS[i].shouldSkip(state)) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+/**
+ * Given an index that may point to a skipped step, find the nearest
+ * visible step. Searches backward first, then forward.
+ */
+export function nearestVisible(
+ index: number,
+ state: TemplateBuilderWizardState,
+): number {
+ // Handle skipped steps
+ if (index >= 0 && index < WIZARD_STEPS.length) {
+ if (!WIZARD_STEPS[index].shouldSkip(state)) {
+ return index;
+ }
+ }
+ // Search backward.
+ for (let i = index - 1; i >= 0; i--) {
+ if (!WIZARD_STEPS[i].shouldSkip(state)) {
+ return i;
+ }
+ }
+ // Search forward.
+ for (let i = index + 1; i < WIZARD_STEPS.length; i++) {
+ if (!WIZARD_STEPS[i].shouldSkip(state)) {
+ return i;
+ }
+ }
+ return 0;
+}
diff --git a/site/src/pages/TemplateBuilder/wizardState.test.ts b/site/src/pages/TemplateBuilder/wizardState.test.ts
new file mode 100644
index 0000000000..b9c5312255
--- /dev/null
+++ b/site/src/pages/TemplateBuilder/wizardState.test.ts
@@ -0,0 +1,358 @@
+import { describe, expect, it } from "vitest";
+import {
+ initialWizardState,
+ moduleHasConfigurableVars,
+ type TemplateBuilderWizardState,
+ toComposeRequest,
+ type WizardAction,
+ wizardReducer,
+} from "./wizardState";
+
+function reduce(
+ actions: WizardAction[],
+ state: TemplateBuilderWizardState = initialWizardState,
+): TemplateBuilderWizardState {
+ return actions.reduce(wizardReducer, state);
+}
+
+describe("wizardReducer", () => {
+ describe("SET_BASE", () => {
+ it("sets the selected base", () => {
+ const state = reduce([
+ {
+ type: "SET_BASE",
+ base: {
+ id: "docker",
+ name: "Docker",
+ hasParameters: false,
+ },
+ },
+ ]);
+ expect(state.baseTemplateId).toBe("docker");
+ expect(state.selectedBase?.name).toBe("Docker");
+ });
+
+ it("clears base variable values when base changes", () => {
+ const state = reduce([
+ {
+ type: "SET_BASE",
+ base: { id: "docker", name: "Docker", hasParameters: true },
+ },
+ {
+ type: "SET_BASE_VARIABLES",
+ values: { image: "ubuntu" },
+ },
+ {
+ type: "SET_BASE",
+ base: { id: "aws-linux", name: "AWS Linux", hasParameters: true },
+ },
+ ]);
+ expect(state.baseTemplateId).toBe("aws-linux");
+ expect(state.baseVariableValues).toEqual({});
+ });
+
+ it("preserves base variable values when same base is re-selected", () => {
+ const state = reduce([
+ {
+ type: "SET_BASE",
+ base: { id: "docker", name: "Docker", hasParameters: true },
+ },
+ {
+ type: "SET_BASE_VARIABLES",
+ values: { image: "ubuntu" },
+ },
+ {
+ type: "SET_BASE",
+ base: { id: "docker", name: "Docker", hasParameters: true },
+ },
+ ]);
+ expect(state.baseVariableValues).toEqual({ image: "ubuntu" });
+ });
+ });
+
+ describe("SET_BASE_VARIABLES", () => {
+ it("replaces all base variable values", () => {
+ const state = reduce([
+ {
+ type: "SET_BASE_VARIABLES",
+ values: { image: "ubuntu", region: "us-east-1" },
+ },
+ ]);
+ expect(state.baseVariableValues).toEqual({
+ image: "ubuntu",
+ region: "us-east-1",
+ });
+ });
+ });
+
+ describe("SET_MODULES", () => {
+ it("sets modules and metadata", () => {
+ const state = reduce([
+ {
+ type: "SET_MODULES",
+ modules: [{ id: "code-server" }],
+ meta: [
+ {
+ id: "code-server",
+ name: "code-server",
+ iconUrl: "/icon.svg",
+ hasConfigurableVars: true,
+ },
+ ],
+ },
+ ]);
+ expect(state.modules).toHaveLength(1);
+ expect(state.selectedModules).toHaveLength(1);
+ expect(state.modules[0].id).toBe("code-server");
+ });
+
+ it("preserves existing variable values for still-selected modules", () => {
+ const state = reduce([
+ {
+ type: "SET_MODULES",
+ modules: [{ id: "code-server" }],
+ meta: [
+ {
+ id: "code-server",
+ name: "code-server",
+ iconUrl: "/icon.svg",
+ hasConfigurableVars: true,
+ },
+ ],
+ },
+ {
+ type: "SET_MODULE_VARIABLES",
+ moduleId: "code-server",
+ variables: { port: "13337" },
+ },
+ {
+ type: "SET_MODULES",
+ modules: [{ id: "code-server" }, { id: "npm-config" }],
+ meta: [
+ {
+ id: "code-server",
+ name: "code-server",
+ iconUrl: "/icon.svg",
+ hasConfigurableVars: true,
+ },
+ {
+ id: "npm-config",
+ name: "npm-config",
+ iconUrl: "/npm.svg",
+ hasConfigurableVars: false,
+ },
+ ],
+ },
+ ]);
+ const codeServer = state.modules.find((m) => m.id === "code-server");
+ expect(codeServer?.variables).toEqual({ port: "13337" });
+ expect(state.modules).toHaveLength(2);
+ });
+
+ it("does not overwrite incoming variables with stale values", () => {
+ const state = reduce([
+ {
+ type: "SET_MODULES",
+ modules: [{ id: "code-server" }],
+ meta: [
+ {
+ id: "code-server",
+ name: "code-server",
+ iconUrl: "/icon.svg",
+ hasConfigurableVars: true,
+ },
+ ],
+ },
+ {
+ type: "SET_MODULE_VARIABLES",
+ moduleId: "code-server",
+ variables: { port: "13337" },
+ },
+ {
+ type: "SET_MODULES",
+ modules: [{ id: "code-server", variables: { port: "8080" } }],
+ meta: [
+ {
+ id: "code-server",
+ name: "code-server",
+ iconUrl: "/icon.svg",
+ hasConfigurableVars: true,
+ },
+ ],
+ },
+ ]);
+ const codeServer = state.modules.find((m) => m.id === "code-server");
+ expect(codeServer?.variables).toEqual({ port: "8080" });
+ });
+ });
+
+ describe("SET_MODULE_VARIABLES", () => {
+ it("updates variables for a specific module", () => {
+ const state = reduce([
+ {
+ type: "SET_MODULES",
+ modules: [{ id: "code-server" }, { id: "npm-config" }],
+ meta: [
+ {
+ id: "code-server",
+ name: "code-server",
+ iconUrl: "/icon.svg",
+ hasConfigurableVars: true,
+ },
+ {
+ id: "npm-config",
+ name: "npm-config",
+ iconUrl: "/npm.svg",
+ hasConfigurableVars: true,
+ },
+ ],
+ },
+ {
+ type: "SET_MODULE_VARIABLES",
+ moduleId: "code-server",
+ variables: { port: "13337" },
+ },
+ ]);
+ expect(
+ state.modules.find((m) => m.id === "code-server")?.variables,
+ ).toEqual({ port: "13337" });
+ // Other module is unchanged.
+ expect(
+ state.modules.find((m) => m.id === "npm-config")?.variables,
+ ).toBeUndefined();
+ });
+ });
+
+ describe("SET_CUSTOMIZATION", () => {
+ it("updates individual customization fields", () => {
+ const state = reduce([
+ {
+ type: "SET_CUSTOMIZATION",
+ field: "displayName",
+ value: "My Template",
+ },
+ {
+ type: "SET_CUSTOMIZATION",
+ field: "description",
+ value: "A test template",
+ },
+ {
+ type: "SET_CUSTOMIZATION",
+ field: "organizationId",
+ value: "org-123",
+ },
+ ]);
+ expect(state.displayName).toBe("My Template");
+ expect(state.description).toBe("A test template");
+ expect(state.organizationId).toBe("org-123");
+ });
+ });
+
+ describe("RESET", () => {
+ it("returns to initial state", () => {
+ const state = reduce([
+ {
+ type: "SET_BASE",
+ base: { id: "docker", name: "Docker", hasParameters: true },
+ },
+ {
+ type: "SET_CUSTOMIZATION",
+ field: "displayName",
+ value: "My Template",
+ },
+ { type: "RESET" },
+ ]);
+ expect(state).toEqual(initialWizardState);
+ });
+ });
+});
+
+describe("moduleHasConfigurableVars", () => {
+ it("returns true when module has non-sensitive variables", () => {
+ const result = moduleHasConfigurableVars({
+ id: "code-server",
+ display_name: "code-server",
+ description: "",
+ icon: "",
+ category: "IDE",
+ version: "1.0.0",
+ compatible_os: ["linux"],
+ conflicts_with: [],
+ variables: [
+ {
+ name: "port",
+ type: "number",
+ description: "Port",
+ required: false,
+ sensitive: false,
+ },
+ ],
+ });
+ expect(result).toBe(true);
+ });
+
+ it("returns false when all variables are sensitive", () => {
+ const result = moduleHasConfigurableVars({
+ id: "npm-config",
+ display_name: "npm-config",
+ description: "",
+ icon: "",
+ category: "Utility",
+ version: "1.0.0",
+ compatible_os: ["linux"],
+ conflicts_with: [],
+ variables: [
+ {
+ name: "npm_token",
+ type: "string",
+ description: "Token",
+ required: true,
+ sensitive: true,
+ },
+ ],
+ });
+ expect(result).toBe(false);
+ });
+
+ it("returns false for empty variables", () => {
+ const result = moduleHasConfigurableVars({
+ id: "git-clone",
+ display_name: "git-clone",
+ description: "",
+ icon: "",
+ category: "Utility",
+ version: "1.0.0",
+ compatible_os: ["linux"],
+ conflicts_with: [],
+ variables: [],
+ });
+ expect(result).toBe(false);
+ });
+});
+
+describe("toComposeRequest", () => {
+ it("produces the correct API request shape", () => {
+ const state: TemplateBuilderWizardState = {
+ ...initialWizardState,
+ baseTemplateId: "docker",
+ modules: [
+ { id: "code-server", variables: { port: "13337" } },
+ { id: "npm-config" },
+ ],
+ };
+ const request = toComposeRequest(state);
+ expect(request).toEqual({
+ base_template_id: "docker",
+ modules: [
+ { id: "code-server", variables: { port: "13337" } },
+ { id: "npm-config" },
+ ],
+ });
+ });
+
+ it("uses empty string for base_template_id when no base is selected", () => {
+ const request = toComposeRequest(initialWizardState);
+ expect(request.base_template_id).toBe("");
+ expect(request.modules).toEqual([]);
+ });
+});
diff --git a/site/src/pages/TemplateBuilder/wizardState.ts b/site/src/pages/TemplateBuilder/wizardState.ts
new file mode 100644
index 0000000000..89eccc1280
--- /dev/null
+++ b/site/src/pages/TemplateBuilder/wizardState.ts
@@ -0,0 +1,147 @@
+import type {
+ TemplateBuilderComposeModule,
+ TemplateBuilderComposeRequest,
+ TemplateBuilderModule,
+} from "#/api/typesGenerated";
+
+/**
+ * UI-only metadata for the selected base template.
+ * Kept separate from the API request payload.
+ */
+type SelectedBaseMeta = {
+ id: string;
+ name: string;
+ iconUrl?: string;
+ os?: string;
+ hasParameters: boolean;
+};
+
+/**
+ * UI-only metadata for a selected module.
+ * Kept separate from the API request payload.
+ */
+type SelectedModuleMeta = {
+ id: string;
+ name: string;
+ iconUrl: string;
+ hasConfigurableVars: boolean;
+};
+
+export type TemplateBuilderWizardState = {
+ baseTemplateId: string | null;
+ baseVariableValues: Record;
+ modules: TemplateBuilderComposeModule[];
+ organizationId?: string;
+ displayName: string;
+ description: string;
+ selectedBase: SelectedBaseMeta | null;
+ selectedModules: SelectedModuleMeta[];
+};
+
+export const initialWizardState: TemplateBuilderWizardState = {
+ baseTemplateId: null,
+ baseVariableValues: {},
+ modules: [],
+ displayName: "",
+ description: "",
+ selectedBase: null,
+ selectedModules: [],
+};
+
+export type WizardAction =
+ | { type: "SET_BASE"; base: SelectedBaseMeta }
+ | { type: "SET_BASE_VARIABLES"; values: Record }
+ | {
+ type: "SET_MODULES";
+ modules: TemplateBuilderComposeModule[];
+ meta: SelectedModuleMeta[];
+ }
+ | {
+ type: "SET_MODULE_VARIABLES";
+ moduleId: string;
+ variables: Record;
+ }
+ | {
+ type: "SET_CUSTOMIZATION";
+ field: "organizationId" | "displayName" | "description";
+ value: string;
+ }
+ | { type: "RESET" };
+
+export function wizardReducer(
+ state: TemplateBuilderWizardState,
+ action: WizardAction,
+): TemplateBuilderWizardState {
+ switch (action.type) {
+ case "SET_BASE": {
+ const baseChanged = state.baseTemplateId !== action.base.id;
+ return {
+ ...state,
+ baseTemplateId: action.base.id,
+ selectedBase: action.base,
+ // Clear base variable values when base changes.
+ baseVariableValues: baseChanged ? {} : state.baseVariableValues,
+ };
+ }
+ case "SET_BASE_VARIABLES":
+ return {
+ ...state,
+ baseVariableValues: action.values,
+ };
+ case "SET_MODULES": {
+ // Preserve existing variable values for modules that remain selected.
+ const existingById = new Map(state.modules.map((m) => [m.id, m]));
+ const merged = action.modules.map((incoming) => {
+ const existing = existingById.get(incoming.id);
+ if (existing?.variables && !incoming.variables) {
+ return { ...incoming, variables: existing.variables };
+ }
+ return incoming;
+ });
+ return {
+ ...state,
+ modules: merged,
+ selectedModules: action.meta,
+ };
+ }
+ case "SET_MODULE_VARIABLES": {
+ return {
+ ...state,
+ modules: state.modules.map((m) =>
+ m.id === action.moduleId ? { ...m, variables: action.variables } : m,
+ ),
+ };
+ }
+ case "SET_CUSTOMIZATION":
+ return {
+ ...state,
+ [action.field]: action.value,
+ };
+ case "RESET":
+ return initialWizardState;
+ default:
+ return state;
+ }
+}
+
+/**
+ * Returns true when a module has at least one variable that should be
+ * shown to the user for configuration (not sensitive, not computed).
+ */
+export const moduleHasConfigurableVars = (
+ module: TemplateBuilderModule,
+): boolean => {
+ return module.variables.some((v) => !v.sensitive);
+};
+
+/**
+ * Project wizard state into the API request shape for the compose endpoint.
+ */
+export const toComposeRequest = (
+ state: TemplateBuilderWizardState,
+): TemplateBuilderComposeRequest => {
+ return {
+ base_template_id: state.baseTemplateId ?? "",
+ modules: state.modules,
+ };
+};
diff --git a/site/src/router.tsx b/site/src/router.tsx
index 22757783c5..c85ccdb3d4 100644
--- a/site/src/router.tsx
+++ b/site/src/router.tsx
@@ -201,6 +201,9 @@ const StarterTemplatePage = lazy(
const CreateTemplatePage = lazy(
() => import("./pages/CreateTemplatePage/CreateTemplatePage"),
);
+const TemplateBuilderPage = lazy(
+ () => import("./pages/TemplateBuilder/TemplateBuilderPage"),
+);
const TemplateVariablesPage = lazy(
() =>
import(
@@ -560,7 +563,10 @@ export const router = createBrowserRouter(
} />
- } />
+
+ } />
+ } />
+
{templateRouter()}
{templateRouter()}