diff --git a/.changeset/zod-4-support.md b/.changeset/zod-4-support.md
new file mode 100644
index 00000000000..fd739c5be76
--- /dev/null
+++ b/.changeset/zod-4-support.md
@@ -0,0 +1,11 @@
+---
+"@trigger.dev/core": minor
+"@trigger.dev/sdk": minor
+"trigger.dev": minor
+"@trigger.dev/redis-worker": minor
+"@trigger.dev/schema-to-json": minor
+---
+
+Add zod v4 support. You can now use zod 3.25+ or zod 4 (the minimum zod 3 version is now 3.25.0). `zod` is now a peer dependency of `@trigger.dev/core`, `@trigger.dev/sdk`, `@trigger.dev/redis-worker`, and `@trigger.dev/schema-to-json`, so it shares a single copy with your project; if you depend on `@trigger.dev/core` directly, add `zod` to your own dependencies.
+
+The published type declarations are built against zod 4. If you are still on zod 3, keep `skipLibCheck: true` in your tsconfig (the default in Trigger.dev's templates).
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 683f39d34ab..0eb42f93fbb 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";
@@ -53,11 +53,11 @@ export function Feedback({ button, defaultValue = "bug", onOpenChange }: Feedbac
if (
navigation.formAction === "/resources/feedback" &&
navigation.state === "loading" &&
- Object.keys(form.allErrors).length === 0
+ (form.errors === undefined || form.errors.length === 0)
) {
setOpen(false);
}
- }, [navigation.formAction, navigation.state, form.allErrors]);
+ }, [navigation, form]);
// Handle URL param functionality
useEffect(() => {
@@ -104,7 +104,7 @@ export function Feedback({ button, defaultValue = "bug", onOpenChange }: Feedbac
{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 c69d6f0c175..5e051b04f8d 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";
@@ -36,7 +36,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 53b3c1695de..55359ad5e3f 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 587dffb299d..587feff5392 100644
--- a/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx
+++ b/apps/webapp/app/components/errors/ConfigureErrorAlerts.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 {
EnvelopeIcon,
GlobeAltIcon,
diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx
index cde5465721b..0754d365dec 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 d165afcb0a7..2b54edff709 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 a362e61acef..1852553a426 100644
--- a/apps/webapp/app/models/orgIntegration.server.ts
+++ b/apps/webapp/app/models/orgIntegration.server.ts
@@ -20,7 +20,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 df43864b53a..8a4dfc9dc68 100644
--- a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts
@@ -45,7 +45,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 348b2bb33c9..419fe3fd6eb 100644
--- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts
@@ -1004,6 +1004,9 @@ export class SpanPresenter extends BasePresenter {
filePath: run.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 e6173da559f..76eb2645b56 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 597abb3b170..8513a998c70 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";
@@ -217,7 +217,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();
@@ -251,7 +250,7 @@ export default function Page() {
if (navigation.state !== "idle") return;
if (lastSubmission !== undefined) return;
- formRef.current?.reset();
+ form.reset();
}, [navigation.state, lastSubmission]);
return (
@@ -265,7 +264,7 @@ export default function Page() {
>
New alert
-