@@ -138,12 +104,10 @@ export function CustomDomainRow({ domain, onDelete, onRetry }: CustomDomainRowPr
size="icon"
variant="outline"
onClick={handleRetry}
- disabled={isLoading}
+ disabled={isRetrying}
className="size-7 text-gray-9 hover:text-gray-11"
>
-
+
Retry verification
@@ -163,7 +127,6 @@ export function CustomDomainRow({ domain, onDelete, onRetry }: CustomDomainRowPr
ref={deleteButtonRef}
size="icon"
variant="outline"
- disabled={isLoading}
onClick={() => setIsConfirmOpen(true)}
className="size-7 text-gray-9 hover:text-error-9"
>
@@ -192,7 +155,6 @@ export function CustomDomainRow({ domain, onDelete, onRetry }: CustomDomainRowPr
verificationToken={domain.verificationToken}
ownershipVerified={domain.ownershipVerified}
cnameVerified={domain.cnameVerified}
- projectId={projectId}
/>
)}
@@ -205,59 +167,15 @@ type DnsRecordTableProps = {
verificationToken: string;
ownershipVerified: boolean;
cnameVerified: boolean;
- projectId: string;
};
-// Backend checks every 60 seconds via Restate
-const CHECK_INTERVAL_MS = 60 * 1000;
-
function DnsRecordTable({
domain,
targetCname,
- verificationToken: initialVerificationToken,
- ownershipVerified: initialOwnershipVerified,
- cnameVerified: initialCnameVerified,
- projectId,
+ verificationToken,
+ ownershipVerified,
+ cnameVerified,
}: DnsRecordTableProps) {
- const [secondsUntilCheck, setSecondsUntilCheck] = useState
(CHECK_INTERVAL_MS / 1000);
-
- // Poll for DNS status updates - only fetches this specific domain
- const {
- data: dnsStatus,
- dataUpdatedAt,
- isFetching,
- } = trpc.deploy.customDomain.checkDns.useQuery(
- { domain, projectId },
- {
- refetchInterval: CHECK_INTERVAL_MS,
- refetchIntervalInBackground: false,
- },
- );
-
- // Use live data if available, otherwise fall back to initial props
- const verificationToken = dnsStatus?.verificationToken ?? initialVerificationToken;
- const ownershipVerified = dnsStatus?.ownershipVerified ?? initialOwnershipVerified;
- const cnameVerified = dnsStatus?.cnameVerified ?? initialCnameVerified;
-
- useEffect(() => {
- const calculateSecondsRemaining = () => {
- if (!dataUpdatedAt) {
- return CHECK_INTERVAL_MS / 1000;
- }
- const nextCheckAt = dataUpdatedAt + CHECK_INTERVAL_MS;
- const remaining = Math.max(0, Math.ceil((nextCheckAt - Date.now()) / 1000));
- return remaining;
- };
-
- setSecondsUntilCheck(calculateSecondsRemaining());
-
- const interval = setInterval(() => {
- setSecondsUntilCheck(calculateSecondsRemaining());
- }, 1000);
-
- return () => clearInterval(interval);
- }, [dataUpdatedAt]);
-
const txtRecordName = `_unkey.${domain}`;
const txtRecordValue = `unkey-domain-verify=${verificationToken}`;
@@ -338,14 +256,6 @@ function DnsRecordTable({
-
- {/* Next check countdown */}
-
{/* Domain list */}
- {isLoading ? (
+ {isCustomDomainsLoading ? (
<>
>
) : (
- customDomains.map((domain) => (
-
- ))
+ customDomains.map((domain) =>
)
)}
{isAddingNew && (
{
- invalidate();
- cancelAdding();
- }}
+ onDismiss={cancelAdding}
/>
)}
- {customDomains.length === 0 && !isAddingNew && !isLoading && (
+ {customDomains.length === 0 && !isAddingNew && !isCustomDomainsLoading && (
0} />
)}
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/types.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/types.ts
index c669230e3b..21125e27bc 100644
--- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/types.ts
+++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/types.ts
@@ -1,19 +1 @@
-export type VerificationStatus = "pending" | "verifying" | "verified" | "failed";
-
-export type CustomDomain = {
- id: string;
- domain: string;
- workspaceId: string;
- projectId: string;
- environmentId: string;
- verificationStatus: VerificationStatus;
- verificationToken: string;
- ownershipVerified: boolean;
- cnameVerified: boolean;
- targetCname: string;
- checkAttempts: number;
- lastCheckedAt: number | null;
- verificationError: string | null;
- createdAt: number;
- updatedAt: number | null;
-};
+export type { CustomDomain, VerificationStatus } from "@/lib/collections/deploy/custom-domains";
diff --git a/web/apps/dashboard/lib/collections/deploy/custom-domains.ts b/web/apps/dashboard/lib/collections/deploy/custom-domains.ts
new file mode 100644
index 0000000000..01211fb482
--- /dev/null
+++ b/web/apps/dashboard/lib/collections/deploy/custom-domains.ts
@@ -0,0 +1,132 @@
+"use client";
+import { queryCollectionOptions } from "@tanstack/query-db-collection";
+import { createCollection } from "@tanstack/react-db";
+import { toast } from "@unkey/ui";
+import { z } from "zod";
+import { queryClient, trpcClient } from "../client";
+import { parseProjectIdFromWhere, validateProjectIdInQuery } from "./utils";
+
+const verificationStatusSchema = z.enum(["pending", "verifying", "verified", "failed"]);
+
+const schema = z.object({
+ id: z.string(),
+ domain: z.string(),
+ workspaceId: z.string(),
+ projectId: z.string(),
+ environmentId: z.string(),
+ verificationStatus: verificationStatusSchema,
+ verificationToken: z.string(),
+ ownershipVerified: z.boolean(),
+ cnameVerified: z.boolean(),
+ targetCname: z.string(),
+ checkAttempts: z.number(),
+ lastCheckedAt: z.number().nullable(),
+ verificationError: z.string().nullable(),
+ createdAt: z.number(),
+ updatedAt: z.number().nullable(),
+});
+
+export type CustomDomain = z.infer
;
+export type VerificationStatus = z.infer;
+
+/**
+ * Custom domains collection.
+ *
+ * IMPORTANT: All queries MUST filter by projectId:
+ * .where(({ customDomain }) => eq(customDomain.projectId, projectId))
+ */
+export const customDomains = createCollection(
+ queryCollectionOptions({
+ queryClient,
+ syncMode: "on-demand",
+ refetchInterval: 5000,
+ queryKey: (opts) => {
+ const projectId = parseProjectIdFromWhere(opts.where);
+ return projectId ? ["customDomains", projectId] : ["customDomains"];
+ },
+ retry: 3,
+ queryFn: async (ctx) => {
+ const options = ctx.meta?.loadSubsetOptions;
+
+ validateProjectIdInQuery(options?.where);
+ const projectId = parseProjectIdFromWhere(options?.where);
+
+ if (!projectId) {
+ throw new Error("Query must include eq(collection.projectId, projectId) constraint");
+ }
+
+ return trpcClient.deploy.customDomain.list.query({ projectId });
+ },
+ getKey: (item) => item.id,
+ id: "customDomains",
+ onInsert: async ({ transaction }) => {
+ const { changes } = transaction.mutations[0];
+
+ const addInput = z
+ .object({
+ projectId: z.string().min(1),
+ environmentId: z.string().min(1),
+ domain: z.string().min(1),
+ })
+ .parse({
+ projectId: changes.projectId,
+ environmentId: changes.environmentId,
+ domain: changes.domain,
+ });
+
+ const mutation = trpcClient.deploy.customDomain.add.mutate(addInput);
+
+ toast.promise(mutation, {
+ loading: "Adding domain...",
+ success: (data) => ({
+ message: "Domain added",
+ description: `Add a CNAME record pointing to ${data.targetCname}`,
+ }),
+ error: (err) => ({
+ message: "Failed to add domain",
+ description: err.message,
+ }),
+ });
+
+ await mutation;
+ },
+ onDelete: async ({ transaction }) => {
+ const original = transaction.mutations[0].original;
+
+ const deleteMutation = trpcClient.deploy.customDomain.delete.mutate({
+ domain: original.domain,
+ projectId: original.projectId,
+ });
+
+ toast.promise(deleteMutation, {
+ loading: "Deleting domain...",
+ success: "Domain deleted",
+ error: (err) => ({
+ message: "Failed to delete domain",
+ description: err.message,
+ }),
+ });
+
+ await deleteMutation;
+ },
+ }),
+);
+
+export async function retryDomainVerification({
+ domain,
+ projectId,
+}: { domain: string; projectId: string }): Promise {
+ const mutation = trpcClient.deploy.customDomain.retry.mutate({ domain, projectId });
+
+ toast.promise(mutation, {
+ loading: "Retrying verification...",
+ success: "Verification restarted",
+ error: (err) => ({
+ message: "Failed to retry verification",
+ description: err.message,
+ }),
+ });
+
+ await mutation;
+ await customDomains.utils.refetch();
+}
diff --git a/web/apps/dashboard/lib/collections/index.ts b/web/apps/dashboard/lib/collections/index.ts
index 7cff5300a2..b7e98dcae0 100644
--- a/web/apps/dashboard/lib/collections/index.ts
+++ b/web/apps/dashboard/lib/collections/index.ts
@@ -1,4 +1,5 @@
"use client";
+import { customDomains } from "./deploy/custom-domains";
import { deployments } from "./deploy/deployments";
import { domains } from "./deploy/domains";
import { environments } from "./deploy/environments";
@@ -7,6 +8,7 @@ import { ratelimitNamespaces } from "./ratelimit/namespaces";
import { ratelimitOverrides } from "./ratelimit/overrides";
// Export types
+export type { CustomDomain } from "./deploy/custom-domains";
export type { Deployment } from "./deploy/deployments";
export type { Domain } from "./deploy/domains";
export type { Project } from "./deploy/projects";
@@ -22,6 +24,7 @@ export const collection = {
environments,
domains,
deployments,
+ customDomains,
} as const;
export async function reset() {
diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/custom-domains/check-dns.ts b/web/apps/dashboard/lib/trpc/routers/deploy/custom-domains/check-dns.ts
deleted file mode 100644
index 781bfda41e..0000000000
--- a/web/apps/dashboard/lib/trpc/routers/deploy/custom-domains/check-dns.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { db } from "@/lib/db";
-import { ratelimit, withRatelimit, workspaceProcedure } from "@/lib/trpc/trpc";
-import { TRPCError } from "@trpc/server";
-import { z } from "zod";
-
-export const checkDns = workspaceProcedure
- .use(withRatelimit(ratelimit.read))
- .input(
- z.object({
- domain: z.string().min(1, "Domain is required"),
- projectId: z.string().min(1, "Project ID is required"),
- }),
- )
- .query(async ({ input, ctx }) => {
- // Verify project belongs to workspace
- const project = await db.query.projects.findFirst({
- where: (table, { eq, and }) =>
- and(eq(table.id, input.projectId), eq(table.workspaceId, ctx.workspace.id)),
- columns: {
- id: true,
- },
- });
-
- if (!project) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Project not found",
- });
- }
-
- // Get the domain record
- const domainRecord = await db.query.customDomains.findFirst({
- where: (table, { eq, and }) =>
- and(eq(table.domain, input.domain), eq(table.projectId, input.projectId)),
- columns: {
- id: true,
- domain: true,
- verificationToken: true,
- ownershipVerified: true,
- cnameVerified: true,
- targetCname: true,
- verificationStatus: true,
- },
- });
-
- if (!domainRecord) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Domain not found",
- });
- }
-
- // Return the current verification state from the database
- // The actual DNS checks happen in the backend worker
- return {
- domain: domainRecord.domain,
- verificationToken: domainRecord.verificationToken,
- ownershipVerified: domainRecord.ownershipVerified,
- cnameVerified: domainRecord.cnameVerified,
- targetCname: domainRecord.targetCname,
- verificationStatus: domainRecord.verificationStatus,
- };
- });
diff --git a/web/apps/dashboard/lib/trpc/routers/index.ts b/web/apps/dashboard/lib/trpc/routers/index.ts
index 5a5a533a0e..edcad46979 100644
--- a/web/apps/dashboard/lib/trpc/routers/index.ts
+++ b/web/apps/dashboard/lib/trpc/routers/index.ts
@@ -39,7 +39,6 @@ import { queryRoles } from "./authorization/roles/query";
import { upsertRole } from "./authorization/roles/upsert";
import { queryUsage } from "./billing/query-usage";
import { addCustomDomain } from "./deploy/custom-domains/add";
-import { checkDns } from "./deploy/custom-domains/check-dns";
import { deleteCustomDomain } from "./deploy/custom-domains/delete";
import { listCustomDomains } from "./deploy/custom-domains/list";
import { retryVerification } from "./deploy/custom-domains/retry";
@@ -416,7 +415,6 @@ export const router = t.router({
list: listCustomDomains,
delete: deleteCustomDomain,
retry: retryVerification,
- checkDns: checkDns,
}),
deployment: t.router({
list: listDeployments,