diff --git a/.changeset/zod-4-support.md b/.changeset/zod-4-support.md
new file mode 100644
index 00000000000..ef90f6396e3
--- /dev/null
+++ b/.changeset/zod-4-support.md
@@ -0,0 +1,14 @@
+---
+"@trigger.dev/build": minor
+"@trigger.dev/core": minor
+"@trigger.dev/react-hooks": minor
+"@trigger.dev/rsc": minor
+"@trigger.dev/sdk": minor
+"trigger.dev": minor
+"@trigger.dev/redis-worker": minor
+"@trigger.dev/schema-to-json": minor
+---
+
+Add Zod 4 support. You can now use Zod 3.25.76+ or Zod 4.
+
+Zod remains a runtime dependency of packages that execute schemas, so existing and new installations continue to receive it automatically. The matching peer dependency range allows package managers to reuse either a compatible Zod 3 or Zod 4 installation from your project.
diff --git a/AGENTS.md b/AGENTS.md
index cc947c7a57d..e459a74c9c6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -108,7 +108,7 @@ When modifying only server components (`apps/webapp/`, `apps/supervisor/`, etc.)
## Dependency Pinning
-Zod is pinned to a single version across the entire monorepo (currently `3.25.76`). When adding zod to a new or existing package, use the **exact same version** as the rest of the repo - never a different version or a range. Mismatched zod versions cause runtime type incompatibilities (e.g., schemas from one package can't be used as body validators in another).
+Zod is pinned to a single version across the entire monorepo (currently `4.4.3`). When adding zod to a new or existing package, use the **exact same version** as the rest of the repo - never a different version or a range. Mismatched zod versions cause runtime type incompatibilities (e.g., schemas from one package can't be used as body validators in another).
## Architecture Overview
diff --git a/apps/supervisor/package.json b/apps/supervisor/package.json
index 2725fe2b729..d4555061708 100644
--- a/apps/supervisor/package.json
+++ b/apps/supervisor/package.json
@@ -23,7 +23,7 @@
"prom-client": "^15.1.0",
"socket.io": "4.7.4",
"std-env": "^3.8.0",
- "zod": "3.25.76"
+ "zod": "4.4.3"
},
"devDependencies": {
"@internal/testcontainers": "workspace:*",
diff --git a/apps/webapp/app/components/Feedback.tsx b/apps/webapp/app/components/Feedback.tsx
index b679b042da1..d7204dde41a 100644
--- a/apps/webapp/app/components/Feedback.tsx
+++ b/apps/webapp/app/components/Feedback.tsx
@@ -5,7 +5,7 @@ import {
getTextareaProps,
useForm,
} from "@conform-to/react";
-import { parseWithZod } from "@conform-to/zod";
+import { parseWithZod } from "@conform-to/zod/v4";
import { InformationCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid";
import { EnvelopeIcon, ShieldCheckIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useLocation, useNavigation, useSearchParams } from "@remix-run/react";
@@ -129,7 +129,7 @@ export function Feedback({
{type === "feature" && (
diff --git a/apps/webapp/app/components/billing/BillingAlertsSection.tsx b/apps/webapp/app/components/billing/BillingAlertsSection.tsx
index ce2f5e5e0fc..7d85e9880b0 100644
--- a/apps/webapp/app/components/billing/BillingAlertsSection.tsx
+++ b/apps/webapp/app/components/billing/BillingAlertsSection.tsx
@@ -1,5 +1,5 @@
import { getFormProps, getInputProps, useForm, type SubmissionResult } from "@conform-to/react";
-import { parseWithZod } from "@conform-to/zod";
+import { parseWithZod } from "@conform-to/zod/v4";
import { PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useSearchParams } from "@remix-run/react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
diff --git a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx
index e6362d4cb1d..675f444c6f6 100644
--- a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx
+++ b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx
@@ -1,6 +1,6 @@
import { getFormProps, useForm, type SubmissionResult } from "@conform-to/react";
-import { parseWithZod } from "@conform-to/zod";
+import { parseWithZod } from "@conform-to/zod/v4";
import { Form, useActionData } from "@remix-run/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod";
@@ -37,7 +37,7 @@ export const billingLimitFormSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("custom"),
amount: z.coerce
- .number({ invalid_type_error: "Not a valid amount" })
+ .number({ error: "Not a valid amount" })
.positive("Amount must be greater than 0"),
cancelInProgressRuns: z
.preprocess((v) => v === "on" || v === true || v === "true", z.boolean())
diff --git a/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx b/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx
index 1dfcdd73707..c0ecbc402c2 100644
--- a/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx
+++ b/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx
@@ -1,5 +1,5 @@
import { getFormProps, useForm, type SubmissionResult } from "@conform-to/react";
-import { parseWithZod } from "@conform-to/zod";
+import { parseWithZod } from "@conform-to/zod/v4";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod";
@@ -21,7 +21,7 @@ export const billingLimitRecoveryFormSchema = z
.object({
action: z.enum(["increase", "remove"]),
newAmount: z.coerce
- .number({ invalid_type_error: "Not a valid amount" })
+ .number({ error: "Not a valid amount" })
.positive("Amount must be greater than 0")
.optional(),
resumeMode: z.enum(["queue", "new_only"]),
@@ -87,7 +87,7 @@ export function BillingLimitRecoveryPanel({
},
defaultValue: {
action: "increase",
- newAmount: suggestedNewLimitDollars,
+ newAmount: String(suggestedNewLimitDollars),
resumeMode: "queue",
},
});
diff --git a/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx
index b126e77b34b..fe43b7a794f 100644
--- a/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx
+++ b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx
@@ -1,6 +1,6 @@
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon";
-import { parseWithZod } from "@conform-to/zod";
+import { parseWithZod } from "@conform-to/zod/v4";
import { EnvelopeIcon, HashtagIcon, LockClosedIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { BellAlertIcon } from "@heroicons/react/24/solid";
import { useFetcher, useNavigate } from "@remix-run/react";
diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx
index 9f821816046..564f3e27718 100644
--- a/apps/webapp/app/components/metrics/QueryWidget.tsx
+++ b/apps/webapp/app/components/metrics/QueryWidget.tsx
@@ -55,7 +55,7 @@ const chartConfigOptions = {
sortByColumn: z.string().nullable(),
sortDirection: SortDirection,
aggregation: AggregationType,
- seriesColors: z.record(z.string()).optional(),
+ seriesColors: z.record(z.string(), z.string()).optional(),
};
const ChartConfiguration = z.object({ ...chartConfigOptions });
diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx
index 86747f9e277..a54344bad03 100644
--- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx
+++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx
@@ -1,5 +1,5 @@
import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react";
-import { parseWithZod } from "@conform-to/zod";
+import { parseWithZod } from "@conform-to/zod/v4";
import { RectangleStackIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useActionData, useNavigation, useParams, useSubmit } from "@remix-run/react";
diff --git a/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx b/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx
index 3054a0cdf48..428389e129b 100644
--- a/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx
+++ b/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx
@@ -1,5 +1,5 @@
import { getFormProps, useForm } from "@conform-to/react";
-import { parseWithZod } from "@conform-to/zod";
+import { parseWithZod } from "@conform-to/zod/v4";
import { EnvelopeIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { useFetcher } from "@remix-run/react";
diff --git a/apps/webapp/app/models/orgIntegration.server.ts b/apps/webapp/app/models/orgIntegration.server.ts
index f2bd0feebce..f136c9cb483 100644
--- a/apps/webapp/app/models/orgIntegration.server.ts
+++ b/apps/webapp/app/models/orgIntegration.server.ts
@@ -22,7 +22,7 @@ const SlackSecretSchema = z.object({
refreshToken: z.string().optional(),
botScopes: z.array(z.string()).optional(),
userScopes: z.array(z.string()).optional(),
- raw: z.record(z.any()).optional(),
+ raw: z.record(z.string(), z.any()).optional(),
});
type SlackSecret = z.infer;
diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts
index 3a1aaf4b8ea..324a1651270 100644
--- a/apps/webapp/app/models/vercelIntegration.server.ts
+++ b/apps/webapp/app/models/vercelIntegration.server.ts
@@ -129,7 +129,7 @@ export const VercelSecretSchema = z.object({
teamId: z.string().nullable().optional(),
userId: z.string().optional(),
installationId: z.string().optional(),
- raw: z.record(z.any()).optional(),
+ raw: z.record(z.string(), z.any()).optional(),
});
export type VercelSecret = z.infer;
diff --git a/apps/webapp/app/models/vercelSdkRecovery.server.ts b/apps/webapp/app/models/vercelSdkRecovery.server.ts
index d3e1bfd6961..31253fe430e 100644
--- a/apps/webapp/app/models/vercelSdkRecovery.server.ts
+++ b/apps/webapp/app/models/vercelSdkRecovery.server.ts
@@ -145,11 +145,11 @@ export const VercelSchemas = {
.union([
z
.object({
- envs: z.array(z.record(z.unknown())),
+ envs: z.array(z.record(z.string(), z.unknown())),
pagination: z.unknown().optional(),
})
.passthrough(),
- z.array(z.record(z.unknown())),
+ z.array(z.record(z.string(), z.unknown())),
])
.transform((val) => (Array.isArray(val) ? { envs: val } : val)),
diff --git a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts
index 0b84e971b2f..5c43a7ff63c 100644
--- a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts
@@ -48,7 +48,7 @@ export const DashboardLayout = z.discriminatedUnion("version", [
z.object({
version: z.literal("1"),
layout: z.array(LayoutItem),
- widgets: z.record(Widget),
+ widgets: z.record(z.string(), Widget),
}),
]);
diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts
index b680a1c6a8e..209e906e50f 100644
--- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts
@@ -1024,6 +1024,9 @@ export class SpanPresenter extends BasePresenter {
filePath: lockedWorker?.lockedBy?.filePath ?? "",
},
run: {
+ // zod v4 types the run-context `context` (z.any) field as required; it was
+ // never populated here (optional under v3), so undefined preserves behavior.
+ context: undefined,
id: run.friendlyId,
createdAt: run.createdAt,
tags: run.runTags,
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx
index b6fe0b09c68..b51a6d5ae1a 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx
@@ -1,5 +1,5 @@
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
-import { parseWithZod } from "@conform-to/zod";
+import { parseWithZod } from "@conform-to/zod/v4";
import {
ArrowUpCircleIcon,
EnvelopeIcon,
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx
index f121900e901..275f6556eb3 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx
@@ -1,11 +1,11 @@
import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react";
-import { parseWithZod } from "@conform-to/zod";
+import { parseWithZod } from "@conform-to/zod/v4";
import { HashtagIcon, LockClosedIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/router";
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { SlackIcon } from "@trigger.dev/companyicons";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
@@ -233,7 +233,6 @@ export default function Page() {
const project = useProject();
const environment = useEnvironment();
const [currentAlertChannel, setCurrentAlertChannel] = useState(option ?? "EMAIL");
- const formRef = useRef(null);
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState();
@@ -267,7 +266,7 @@ export default function Page() {
if (navigation.state !== "idle") return;
if (lastSubmission !== undefined) return;
- formRef.current?.reset();
+ form.reset();
}, [navigation.state, lastSubmission]);
return (
@@ -281,7 +280,7 @@ export default function Page() {
>
New alert
-