When I started building WikiBeem, custom domains seemed straightforward. Just add a domain to Vercel, show the user some DNS records, and boom done. Right?
Nope. Turns out there's a whole world of edge cases, timing issues, and DNS propagation delays that'll make you question your life choices at 2 AM.
Here's how I actually built it, what broke, and what I learned.
Why Vercel's API?
I'm hosting WikiBeem on Vercel, so using their domain management API made sense. It handles SSL certificates automatically, manages DNS routing, and integrates directly with their infrastructure. The alternative would've been building everything from scratch with AWS Route 53 or Cloudflare — way more work for basically the same result.
Vercel has an official SDK (@vercel/sdk) that wraps their API, which made the integration cleaner. But the documentation? Let's just say it took some trial and error to figure out what actually works in production.
The Basic Flow
When a user wants to add a custom domain to their site, here's what needs to happen:
- User enters their domain (e.g.,
docs.yourcompany.com) - We add it to Vercel via API
- Vercel gives us DNS records to configure
- User configures DNS at their registrar
- We poll Vercel to check if DNS propagated
- Once verified, SSL certificate gets issued
- Site works on the custom domain
Simple in theory. In practice? Not so much.
Setting Up the Vercel Client
First thing I did was create a wrapper around Vercel's SDK. This gave me a single place to handle errors and make sure credentials are configured properly.
import { Vercel } from '@vercel/sdk'
export class VercelClient {
private vercel: Vercel
private projectId: string
private teamId?: string
constructor() {
const token = process.env.VERCEL_TOKEN || ''
this.projectId = process.env.VERCEL_PROJECT_ID || ''
this.teamId = process.env.VERCEL_TEAM_ID
if (!token || !this.projectId) {
console.warn('Vercel credentials not configured. Custom domain features will not work.')
}
this.vercel = new Vercel({
bearerToken: token,
})
}
}
One thing I learned the hard way: always check if credentials exist before trying to use them. Missing env vars can cause cryptic errors that are annoying to debug.
Adding a Domain
The first API call is straightforward — just tell Vercel you want to add a domain:
async addDomain(domain: string) {
const response = await this.vercel.projects.addProjectDomain({
idOrName: this.projectId,
requestBody: {
name: domain,
},
...(this.teamId && { teamId: this.teamId }),
})
return response
}
But here's where things get interesting. Vercel returns verification records, but they're not always in the same place. Sometimes they're in the response object directly, sometimes you need to fetch the domain config separately. I ended up checking both:
// Try to get verification records from domain config first
let domainConfig
try {
domainConfig = await vercelClient.getDomainConfig(domain)
if (domainConfig?.verification) {
verificationRecords = domainConfig.verification.map(v => ({
type: v.type,
name: v.domain || domain,
value: v.value,
}))
}
} catch (e) {
// Fallback to response verification if config fails
if (vercelDomain.verification) {
verificationRecords = vercelDomain.verification.map(v => ({
type: v.type,
name: v.domain || domain,
value: v.value,
}))
}
}
This redundancy saved me when Vercel's API responses were inconsistent. Always have a fallback.
DNS Verification: The Waiting Game
This is where users get frustrated. They configure DNS records at their registrar, click "Verify," and... nothing happens. At least not immediately.
DNS propagation can take anywhere from a few minutes to 48 hours. For subdomains, it's usually faster (5-30 minutes). For apex domains, it can be much longer.
I built a polling mechanism that checks verification status every few seconds. But there's a balance — poll too often and you're hammering Vercel's API. Poll too rarely and users think it's broken.
// In the frontend, poll every 5 seconds
const pollDomainStatus = async () => {
const response = await fetch(`/api/domain?siteId=${siteId}`)
const data = await response.json()
if (data.domain?.isVerified) {
setPolling(false) // Stop polling when verified
return
}
setTimeout(pollDomainStatus, 5000) // Check again in 5 seconds
}
I also added a manual "Check Status" button because users don't want to wait passively. Give them control.
The SSL Certificate Race
Once DNS is verified, Vercel automatically provisions an SSL certificate. But there's another delay here — certificates can take a few minutes to issue even after DNS is verified.
I track SSL status separately from verification status:
sslStatus = vercelDomain.verified ? 'issued' : 'pending'
But honestly? Sometimes the status isn't accurate right away. Vercel's API might say verified: true but the certificate isn't actually ready yet. So I added some buffer time and show a "SSL provisioning" state to users.
Multi-Tenant Routing: The Real Challenge
This was the trickiest part. When someone visits docs.yourcompany.com, how do we figure out which site to show?
Vercel handles the DNS routing and SSL, but the actual request routing is up to us. In Next.js middleware, I check the host header to see if it's a custom domain:
export default function middleware(request: NextRequest) {
const host = request.headers.get('host') || ''
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const mainDomain = new URL(appUrl).hostname
// Is this a custom domain?
const isCustomDomain = host !== mainDomain &&
!host.startsWith('localhost') &&
!host.startsWith('127.0.0.1')
// Pass host to pages so they can look up the site
const response = NextResponse.next()
response.headers.set('X-Host', host)
return response
}
Then in the page component, I look up the domain in the database:
// Get host from header
const host = headers().get('x-host') || ''
// Look up domain in database
const domain = await prisma.domain.findUnique({
where: { domain: host },
select: { siteId: true },
})
// Get the site
const site = await prisma.site.findUnique({
where: { id: domain.siteId },
})
One edge case I hit: what if someone visits a custom domain that doesn't exist in our database? Or if DNS is configured but the domain isn't verified yet? I added proper error handling for both cases — show a 404 or a "domain not configured" message.
URL Structure Differences
Here's something I didn't think about initially: custom domains have a different URL structure than the default routing.
- Default route:
wikibeem.com/yoursite/docs/getting-started - Custom domain:
docs.yourcompany.com/docs/getting-started
On the main domain, the first segment (yoursite) is the site slug. On a custom domain, there's no site slug in the path — the domain itself identifies the site, so the document path starts right away.
I had to refactor the routing logic to handle both cases:
if (isCustomDomain) {
// Custom domain: domain identifies the site, no site slug in path
const domain = await prisma.domain.findUnique({
where: { domain: host },
})
// Document slug is everything after the domain
fullDocSlug = [siteSlug, ...docSlugArray].join('/')
} else {
// Default route: siteSlug is first segment, rest is document path
fullDocSlug = docSlugArray.join('/')
}
This took longer than I'd like to admit to get right. Routing edge cases are sneaky.
Error Handling: Expect Everything to Break
Here are some errors I encountered in production:
Domain already exists: User tries to add a domain that's already in use by another Vercel project. Handle this gracefully — tell them it's taken, don't just throw a 500 error.
DNS not configured: User clicks verify before setting up DNS. Show them the records they need to add, don't fail silently.
Propagation timeout: DNS takes longer than expected. After a few minutes of polling, show a message like "DNS propagation can take up to 48 hours. Check back later or verify your DNS settings."
SSL certificate failure: Rare, but it happens. Vercel's certificate provisioning can fail. Check SSL status separately and show appropriate errors.
Race conditions: User removes a domain while verification is in progress. Add proper cleanup and handle concurrent operations.
What I'd Do Differently
If I were building this again, I'd:
Add webhooks: Vercel supports webhooks for domain events. Instead of polling, listen for verification events. Much cleaner.
Better status messages: Be more specific about what's happening. "Checking DNS..." → "DNS verified, provisioning SSL..." → "SSL certificate issued, ready in 1-2 minutes."
Validation before API calls: Validate domain format, check if it's already in use, verify ownership (where possible) before hitting Vercel's API.
Retry logic: Add exponential backoff for failed API calls. Vercel's API can be flaky during peak times.
Testing: Set up staging domains to test the entire flow end-to-end. DNS propagation makes testing annoying, but it's worth it.
The Result
After all this work, custom domains work reliably. Users can add their domain, configure DNS, and within a few minutes their site is live on their custom domain with SSL.
The UX could still be better — I'm planning to add webhook support and better status messages. But for now, it works, and users are happy.
If you're building something similar, my advice: start simple, handle errors gracefully, and prepare for DNS propagation delays. Your users will thank you.
Want to see custom domains in action? Check out WikiBeem — you can publish your ClickUp docs with your own domain in just a few clicks.

Top comments (0)