From ee18edb97dae762d6dabf4a77a23decb592c418a Mon Sep 17 00:00:00 2001 From: richiemcilroy Date: Wed, 5 Aug 2026 16:27:20 +0100 Subject: [PATCH] fix(web): always render DNS records in custom domain setup The custom domain verify step could render with no DNS records and spin indefinitely. This happened when Vercel's config response omitted recommended records (e.g. domains not on Vercel DNS) or when the status check silently failed, since the UI only renders records from recommendedCNAME/recommendedIPv4 and polling suppresses error toasts. - checkDomainStatus now falls back to Vercel's well-known targets (cname.vercel-dns.com for subdomains, 76.76.21.21 for apex) when the config response has no recommendations, and logs server-side failures instead of swallowing them silently. - VerifyStep renders a fallback record card when no recommended records are present, and shows an explicit retry message when the config could not be loaded at all. - updateDomain normalizes the stored domain (trim + lowercase). - remove-domain uses a shared removeDomain helper for the Vercel call. --- apps/web/actions/organization/domain-utils.ts | 61 +++++++++++++- .../web/actions/organization/remove-domain.ts | 15 +--- .../web/actions/organization/update-domain.ts | 10 ++- .../CustomDomainDialog/VerifyStep.tsx | 82 +++++++++++++++++++ 4 files changed, 148 insertions(+), 20 deletions(-) diff --git a/apps/web/actions/organization/domain-utils.ts b/apps/web/actions/organization/domain-utils.ts index 4a8fcb11e5b..c6df7634d1c 100644 --- a/apps/web/actions/organization/domain-utils.ts +++ b/apps/web/actions/organization/domain-utils.ts @@ -1,3 +1,20 @@ +import { parse } from "tldts"; + +const VERCEL_CNAME_TARGET = "cname.vercel-dns.com"; +const VERCEL_A_RECORD = "76.76.21.21"; + +export const isSubdomain = (raw: string): boolean => { + const input = + raw + .trim() + .replace(/^https?:\/\//i, "") + .split("/")[0] ?? ""; + if (!input) return false; + const host = (input.replace(/\.$/, "").split(":")[0] || "").toLowerCase(); + const { subdomain } = parse(host); + return Boolean(subdomain); +}; + export const getConfigResponse = async (domain: string) => { const response = await fetch( `https://api.vercel.com/v6/domains/${domain.toLowerCase()}/config?teamId=${ @@ -67,6 +84,22 @@ export const addDomain = async (domain: string) => { return response; }; +export const removeDomain = async (domain: string) => { + const response = await fetch( + `https://api.vercel.com/v9/projects/${ + process.env.VERCEL_PROJECT_ID + }/domains/${domain.toLowerCase()}?teamId=${process.env.VERCEL_TEAM_ID}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, + }, + }, + ).then((res) => res.json()); + + return response; +}; + export const getRequiredConfig = async (domain: string) => { // First try to get the records directly try { @@ -162,9 +195,28 @@ export const checkDomainStatus = async (domain: string) => { verified = verificationJson?.verified; } - // Get the current and required A records const currentAValues = configJson.aValues || []; - const requiredAValue = requiredConfigJson.aValues?.[0]; + const subdomain = isSubdomain(domain); + + // Vercel's recommendations are the source of truth, but the config + // endpoint can omit them (e.g. for domains not using Vercel DNS). Fall + // back to the well-known Vercel targets so the setup UI always has a + // record to display instead of rendering empty. + let recommendedCNAME: Array<{ rank: number; value: string }> = + configJson.recommendedCNAME || []; + let recommendedIPv4: Array<{ rank: number; value: string[] | string }> = + configJson.recommendedIPv4 || []; + + if (subdomain && recommendedCNAME.length === 0) { + recommendedCNAME = [{ rank: 0, value: VERCEL_CNAME_TARGET }]; + } + if (!subdomain && recommendedIPv4.length === 0) { + recommendedIPv4 = [{ rank: 0, value: [VERCEL_A_RECORD] }]; + } + + const requiredAValue = + requiredConfigJson.aValues?.[0] ?? + (!subdomain ? VERCEL_A_RECORD : undefined); return { verified, @@ -173,10 +225,13 @@ export const checkDomainStatus = async (domain: string) => { verification: domainJson?.verification || [], currentAValues, requiredAValue, + recommendedCNAME, + recommendedIPv4, }, status: domainJson, }; - } catch (_error) { + } catch (error) { + console.error("checkDomainStatus failed", { domain, error }); return { verified: false, error: "Failed to check domain status", diff --git a/apps/web/actions/organization/remove-domain.ts b/apps/web/actions/organization/remove-domain.ts index 5d1c051bcfa..6bc7ba186d4 100644 --- a/apps/web/actions/organization/remove-domain.ts +++ b/apps/web/actions/organization/remove-domain.ts @@ -7,6 +7,7 @@ import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requireOrganizationSettingsManager } from "./authorization"; +import { removeDomain } from "./domain-utils"; export async function removeOrganizationDomain( organizationId: Organisation.OrganisationId, @@ -28,19 +29,7 @@ export async function removeOrganizationDomain( try { if (organization.customDomain) { - await fetch( - `https://api.vercel.com/v9/projects/${ - process.env.VERCEL_PROJECT_ID - }/domains/${organization.customDomain.toLowerCase()}?teamId=${ - process.env.VERCEL_TEAM_ID - }`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, - }, - }, - ); + await removeDomain(organization.customDomain); } await db() diff --git a/apps/web/actions/organization/update-domain.ts b/apps/web/actions/organization/update-domain.ts index 7ca542c9c44..5cf1cf21d25 100644 --- a/apps/web/actions/organization/update-domain.ts +++ b/apps/web/actions/organization/update-domain.ts @@ -24,6 +24,8 @@ export async function updateDomain( throw new Error("User is not subscribed"); } + const normalizedDomain = domain.trim().toLowerCase(); + const [organization] = await db() .select() .from(organizations) @@ -37,7 +39,7 @@ export async function updateDomain( const existingDomain = await db() .select() .from(organizations) - .where(eq(organizations.customDomain, domain)) + .where(eq(organizations.customDomain, normalizedDomain)) .limit(1); if (existingDomain.length > 0 && existingDomain[0]?.id !== organizationId) { @@ -45,7 +47,7 @@ export async function updateDomain( } try { - const addDomainResponse = await addDomain(domain); + const addDomainResponse = await addDomain(normalizedDomain); if (addDomainResponse.error) { throw new Error(addDomainResponse.error.message); @@ -54,12 +56,12 @@ export async function updateDomain( await db() .update(organizations) .set({ - customDomain: domain, + customDomain: normalizedDomain, domainVerified: null, }) .where(eq(organizations.id, organizationId)); - const status = await checkDomainStatus(domain); + const status = await checkDomainStatus(normalizedDomain); if (status.verified) { await db() diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/VerifyStep.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/VerifyStep.tsx index 5c8eb5d35fa..f559bb4d190 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/VerifyStep.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/VerifyStep.tsx @@ -99,6 +99,14 @@ const VerifyStep = ({ hasRecommendedCNAME && !cnameConfigured && isSubdomain(domain); const showTXTRecord = hasTXTVerification && !isVerified; + const showFallbackRecords = + !showTXTRecord && + !showARecord && + !showCNAMERecord && + !aRecordConfigured && + !cnameConfigured; + const fallbackIsSubdomain = isSubdomain(domain); + const handleCopy = async (text: string, fieldId: string) => { try { await navigator.clipboard.writeText(text); @@ -146,6 +154,13 @@ const VerifyStep = ({
+ ) : !isVerified && !domainConfig ? ( +
+

+ We couldn't load the DNS configuration for this domain. Use Check + Status to try again. +

+
) : ( !isVerified && domainConfig && ( @@ -432,6 +447,73 @@ const VerifyStep = ({ )} + + {/* Fallback when Vercel returned no recommendations */} + {showFallbackRecords && ( +
+
+

+ {fallbackIsSubdomain + ? "CNAME Record Configuration" + : "A Record Configuration"} +

+

+ Add this record to your domain: +

+
+
+
+
+
Type
+
+ {fallbackIsSubdomain ? "CNAME" : "A"} +
+
+
+
Name
+
+ + {fallbackIsSubdomain ? domain.split(".")[0] : "@"} + +
+
+
+
+ Value +
+
+
+ + {fallbackIsSubdomain + ? "cname.vercel-dns.com" + : "76.76.21.21"} + + +
+
+
+
+
+
+ )} ) )}