<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Toumi abderrahmane</title>
    <description>The latest articles on Forem by Toumi abderrahmane (@toumi_abderrahmane_f07d5b).</description>
    <link>https://forem.com/toumi_abderrahmane_f07d5b</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3630990%2F28e578fc-7426-4571-b6e4-a7b66df4e380.jpg</url>
      <title>Forem: Toumi abderrahmane</title>
      <link>https://forem.com/toumi_abderrahmane_f07d5b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/toumi_abderrahmane_f07d5b"/>
    <language>en</language>
    <item>
      <title>Building Custom Domain Management with Vercel API: The Good, The Bad, and The DNS Propagation</title>
      <dc:creator>Toumi abderrahmane</dc:creator>
      <pubDate>Thu, 01 Jan 2026 15:52:54 +0000</pubDate>
      <link>https://forem.com/toumi_abderrahmane_f07d5b/building-custom-domain-management-with-vercel-api-the-good-the-bad-and-the-dns-propagation-3fp7</link>
      <guid>https://forem.com/toumi_abderrahmane_f07d5b/building-custom-domain-management-with-vercel-api-the-good-the-bad-and-the-dns-propagation-3fp7</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjf6vfmoncvf4yk5kfwqa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjf6vfmoncvf4yk5kfwqa.png" alt="How I built a custom domain management system for WikiBeem using Vercel's API. Learn about DNS verification, SSL certificates, multi-tenant routing, and the edge cases that drove me crazy." width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Here's how I actually built it, what broke, and what I learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Vercel's API?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Vercel has an official SDK (&lt;code&gt;@vercel/sdk&lt;/code&gt;) 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Basic Flow
&lt;/h2&gt;

&lt;p&gt;When a user wants to add a custom domain to their site, here's what needs to happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User enters their domain (e.g., &lt;code&gt;docs.yourcompany.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;We add it to Vercel via API&lt;/li&gt;
&lt;li&gt;Vercel gives us DNS records to configure&lt;/li&gt;
&lt;li&gt;User configures DNS at their registrar&lt;/li&gt;
&lt;li&gt;We poll Vercel to check if DNS propagated&lt;/li&gt;
&lt;li&gt;Once verified, SSL certificate gets issued&lt;/li&gt;
&lt;li&gt;Site works on the custom domain&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Simple in theory. In practice? Not so much.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up the Vercel Client
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Vercel&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vercel/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VercelClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;vercel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Vercel&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;teamId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;

  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VERCEL_TOKEN&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VERCEL_PROJECT_ID&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;teamId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VERCEL_TEAM_ID&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Vercel credentials not configured. Custom domain features will not work.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vercel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Vercel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;bearerToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a Domain
&lt;/h2&gt;

&lt;p&gt;The first API call is straightforward — just tell Vercel you want to add a domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;addDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vercel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addProjectDomain&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;idOrName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;requestBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;teamId&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;teamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;teamId&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Try to get verification records from domain config first&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;domainConfig&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;domainConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;vercelClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDomainConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;domainConfig&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;verificationRecords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;domainConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Fallback to response verification if config fails&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vercelDomain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;verificationRecords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vercelDomain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This redundancy saved me when Vercel's API responses were inconsistent. Always have a fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS Verification: The Waiting Game
&lt;/h2&gt;

&lt;p&gt;This is where users get frustrated. They configure DNS records at their registrar, click "Verify," and... nothing happens. At least not immediately.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In the frontend, poll every 5 seconds&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pollDomainStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/domain?siteId=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;isVerified&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setPolling&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Stop polling when verified&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pollDomainStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Check again in 5 seconds&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also added a manual "Check Status" button because users don't want to wait passively. Give them control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SSL Certificate Race
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I track SSL status separately from verification status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;sslStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vercelDomain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verified&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;issued&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But honestly? Sometimes the status isn't accurate right away. Vercel's API might say &lt;code&gt;verified: true&lt;/code&gt; but the certificate isn't actually ready yet. So I added some buffer time and show a "SSL provisioning" state to users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-Tenant Routing: The Real Challenge
&lt;/h2&gt;

&lt;p&gt;This was the trickiest part. When someone visits &lt;code&gt;docs.yourcompany.com&lt;/code&gt;, how do we figure out which site to show?&lt;/p&gt;

&lt;p&gt;Vercel handles the DNS routing and SSL, but the actual request routing is up to us. In Next.js middleware, I check the &lt;code&gt;host&lt;/code&gt; header to see if it's a custom domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;host&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;appUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_APP_URL&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mainDomain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appUrl&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;

  &lt;span class="c1"&gt;// Is this a custom domain?&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isCustomDomain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;mainDomain&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; 
                         &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; 
                         &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// Pass host to pages so they can look up the site&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Host&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in the page component, I look up the domain in the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Get host from header&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-host&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;

&lt;span class="c1"&gt;// Look up domain in database&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// Get the site&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;siteId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  URL Structure Differences
&lt;/h2&gt;

&lt;p&gt;Here's something I didn't think about initially: custom domains have a different URL structure than the default routing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Default route: &lt;code&gt;wikibeem.com/yoursite/docs/getting-started&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Custom domain: &lt;code&gt;docs.yourcompany.com/docs/getting-started&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the main domain, the first segment (&lt;code&gt;yoursite&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt;I had to refactor the routing logic to handle both cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isCustomDomain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Custom domain: domain identifies the site, no site slug in path&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="c1"&gt;// Document slug is everything after the domain&lt;/span&gt;
  &lt;span class="nx"&gt;fullDocSlug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;siteSlug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;docSlugArray&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Default route: siteSlug is first segment, rest is document path&lt;/span&gt;
  &lt;span class="nx"&gt;fullDocSlug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;docSlugArray&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This took longer than I'd like to admit to get right. Routing edge cases are sneaky.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error Handling: Expect Everything to Break
&lt;/h2&gt;

&lt;p&gt;Here are some errors I encountered in production:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Domain already exists&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS not configured&lt;/strong&gt;: User clicks verify before setting up DNS. Show them the records they need to add, don't fail silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Propagation timeout&lt;/strong&gt;: 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."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSL certificate failure&lt;/strong&gt;: Rare, but it happens. Vercel's certificate provisioning can fail. Check SSL status separately and show appropriate errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Race conditions&lt;/strong&gt;: User removes a domain while verification is in progress. Add proper cleanup and handle concurrent operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;If I were building this again, I'd:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add webhooks&lt;/strong&gt;: Vercel supports webhooks for domain events. Instead of polling, listen for verification events. Much cleaner.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Better status messages&lt;/strong&gt;: Be more specific about what's happening. "Checking DNS..." → "DNS verified, provisioning SSL..." → "SSL certificate issued, ready in 1-2 minutes."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Validation before API calls&lt;/strong&gt;: Validate domain format, check if it's already in use, verify ownership (where possible) before hitting Vercel's API.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Retry logic&lt;/strong&gt;: Add exponential backoff for failed API calls. Vercel's API can be flaky during peak times.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Testing&lt;/strong&gt;: Set up staging domains to test the entire flow end-to-end. DNS propagation makes testing annoying, but it's worth it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you're building something similar, my advice: start simple, handle errors gracefully, and prepare for DNS propagation delays. Your users will thank you.&lt;/p&gt;




&lt;p&gt;Want to see custom domains in action? &lt;a href="https://wikibeem.com" rel="noopener noreferrer"&gt;Check out WikiBeem&lt;/a&gt; — you can publish your ClickUp docs with your own domain in just a few clicks.&lt;/p&gt;

</description>
      <category>clickup</category>
      <category>vercel</category>
      <category>nextjs</category>
      <category>dns</category>
    </item>
    <item>
      <title>How I Built Wikibeem: Turning ClickUp Docs into Professional Documentation Sites</title>
      <dc:creator>Toumi abderrahmane</dc:creator>
      <pubDate>Thu, 25 Dec 2025 01:45:14 +0000</pubDate>
      <link>https://forem.com/toumi_abderrahmane_f07d5b/how-i-built-wikibeem-turning-clickup-docs-into-professional-documentation-sites-24md</link>
      <guid>https://forem.com/toumi_abderrahmane_f07d5b/how-i-built-wikibeem-turning-clickup-docs-into-professional-documentation-sites-24md</guid>
      <description>&lt;p&gt;&lt;em&gt;A solo dev journey from frustration to product launch&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Frustration That Started It All
&lt;/h2&gt;

&lt;p&gt;I love ClickUp. It's where my team lives — tasks, docs, wikis, everything connected.&lt;/p&gt;

&lt;p&gt;But every time I needed to share documentation with a client, I hit the same wall:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;doc.clickup.com/d/2kxuepwx-192&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That URL. That ugly, branded, unprofessional URL.&lt;/p&gt;

&lt;p&gt;Clients would ask: &lt;em&gt;"Why are we using ClickUp? Can you put this on your website?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I'd spend hours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exporting to PDF (formatting breaks)&lt;/li&gt;
&lt;li&gt;Copy-pasting to Notion (links break)&lt;/li&gt;
&lt;li&gt;Rebuilding in GitBook (context lost)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I found workarounds like Cloakist, but they just proxy the ClickUp page — clients could still click around and find internal stuff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then I went digging.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I searched Reddit, ClickUp forums, and feedback boards. Turns out I wasn't alone:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Shared doc.clickup.com with a prospect. They thought we use ClickUp internally and bailed."&lt;/em&gt; — r/clickup&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Want to share the onboarding section. Can't without exposing the entire task roadmap."&lt;/em&gt; — feedback.clickup.com&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Docs export breaks embeds, bullets vanish. Recreating in Notion costs 4 hours of work."&lt;/em&gt; — Agency PM on Reddit&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Hundreds of people had the same frustration. Agencies, SaaS teams, freelancers — all stuck with the same problem.&lt;/p&gt;

&lt;p&gt;One night, I thought: &lt;strong&gt;What if I could solve this for myself AND for them?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's how Wikibeem was born.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Tech Stack
&lt;/h2&gt;

&lt;p&gt;I built Wikibeem as a solo developer over several months. Here's what powers it:&lt;/p&gt;

&lt;h3&gt;
  
  
  Frontend
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; with App Router&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React 19&lt;/strong&gt; &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS 4&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Server and Client Components for optimal performance&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Backend
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js API Routes&lt;/strong&gt; — no separate backend needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; with &lt;strong&gt;Prisma ORM&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel&lt;/strong&gt; for hosting and edge functions&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Authentication &amp;amp; Payments
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NextAuth v5&lt;/strong&gt; (beta) — credentials + OAuth ready&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paddle&lt;/strong&gt; — for subscriptions (handles global taxes automatically)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The ClickUp Integration
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ClickUp API v3&lt;/strong&gt; — OAuth 2.0 flow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Axios&lt;/strong&gt; for API calls&lt;/li&gt;
&lt;li&gt;Custom sync engine that handles nested pages, wikis, and doc hierarchies&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Content Processing
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Marked&lt;/strong&gt; — Markdown to HTML conversion&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cheerio&lt;/strong&gt; — HTML manipulation and cleaning&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turndown&lt;/strong&gt; — HTML to Markdown when needed&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Search &amp;amp; SEO
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fuse.js&lt;/strong&gt; — client-side fuzzy search&lt;/li&gt;
&lt;li&gt;Custom SEO per site and per document&lt;/li&gt;
&lt;li&gt;Auto-generated sitemaps&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Domain Management
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vercel SDK&lt;/strong&gt; — programmatic custom domain setup&lt;/li&gt;
&lt;li&gt;Automatic SSL certificates&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Internationalization
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;next-intl&lt;/strong&gt; — 9 languages supported&lt;/li&gt;
&lt;li&gt;English, French, German, Spanish, Portuguese, Italian, Russian, Arabic, Chinese&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;Here's how Wikibeem works under the hood:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────┐     OAuth      ┌─────────────────┐
│   ClickUp API   │◄──────────────►│    Wikibeem     │
│   (Your Docs)   │                │    (Next.js)    │
└─────────────────┘                └────────┬────────┘
                                            │
                    ┌───────────────────────┼───────────────────────┐
                    │                       │                       │
              ┌─────▼─────┐          ┌──────▼──────┐         ┌──────▼──────┐
              │ PostgreSQL │          │   Vercel    │         │   Paddle    │
              │  (Prisma)  │          │  (Domains)  │         │ (Payments)  │
              └───────────┘          └─────────────┘         └─────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Data Model
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User
 └── Workspace (ClickUp connection)
      └── Site (your docs website)
           ├── Documents (synced from ClickUp)
           │    └── Children (nested pages)
           ├── Domain (custom domain)
           ├── Theme (colors, logo)
           └── SEO Settings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Hardest Part: Syncing ClickUp Docs
&lt;/h2&gt;

&lt;p&gt;ClickUp's API is... interesting. Docs can have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nested pages&lt;/li&gt;
&lt;li&gt;Wikis with their own structure&lt;/li&gt;
&lt;li&gt;Content in different formats (JSON blocks, Markdown, HTML)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My sync engine had to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fetch all docs&lt;/strong&gt; from a workspace&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recursively process pages&lt;/strong&gt; and their children&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convert content&lt;/strong&gt; to clean HTML&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a hierarchy&lt;/strong&gt; with parent-child relationships&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate unique slugs&lt;/strong&gt; for URLs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle updates&lt;/strong&gt; without creating duplicates&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trickiest bug? ClickUp sometimes returns a page that's actually a root doc. Took me days to figure out why I had duplicate content everywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The fix: Track root doc IDs and filter them out&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rootDocIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;// When processing pages, skip if it's actually a root doc&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rootDocIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="c1"&gt;// This page is a doc, not a child page&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Performance Optimization
&lt;/h3&gt;

&lt;p&gt;The first version was slow. Each document checked slug uniqueness with a database query.&lt;/p&gt;

&lt;p&gt;For 100 pages = 100+ database round trips. 💀&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;In-memory slug tracking&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existingSlugs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// Instead of: await prisma.document.findUnique(...)&lt;/span&gt;
&lt;span class="c1"&gt;// Now: existingSlugs.has(slug)&lt;/span&gt;

&lt;span class="nx"&gt;existingSlugs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newSlug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Track immediately&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sync time dropped from 30+ seconds to under 10.&lt;/p&gt;




&lt;h2&gt;
  
  
  Custom Domains: The Magic
&lt;/h2&gt;

&lt;p&gt;This was the feature I was most excited about.&lt;/p&gt;

&lt;p&gt;Using Vercel's SDK, I can programmatically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a domain to the project&lt;/li&gt;
&lt;li&gt;Return DNS records for the user to configure&lt;/li&gt;
&lt;li&gt;Verify domain ownership&lt;/li&gt;
&lt;li&gt;Auto-provision SSL certificates
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vercel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Vercel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VERCEL_TOKEN&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// Add domain&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;vercel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addProjectDomain&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;idOrName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requestBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;docs.yourcompany.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// Get verification records&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;vercel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domains&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDomainConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;docs.yourcompany.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user adds a CNAME record, clicks "Verify," and boom — their docs are live on their domain with HTTPS.&lt;/p&gt;




&lt;h2&gt;
  
  
  Things I'd Do Differently
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Write More Tests
&lt;/h3&gt;

&lt;p&gt;The sync logic is complex. I've caught bugs manually that tests would have caught faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Ship Faster
&lt;/h3&gt;

&lt;p&gt;I spent too long on "perfect" features. Should have launched with 50% of what I have now.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real-time sync&lt;/strong&gt; — webhook integration with ClickUp&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics&lt;/strong&gt; — see which docs people read&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Password-protected docs&lt;/strong&gt; — for private client portals&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More themes&lt;/strong&gt; &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API access&lt;/strong&gt; — for power users&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;Wikibeem is live at &lt;a href="https://wikibeem.com" rel="noopener noreferrer"&gt;wikibeem.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Connect your ClickUp workspace, sync your docs, add your domain.&lt;/p&gt;

&lt;p&gt;Under 5 minutes to a professional documentation site.&lt;/p&gt;

&lt;p&gt;I'm building this in public and genuinely want feedback. What features would make this useful for you? What's missing?&lt;/p&gt;

&lt;p&gt;DM me on &lt;a href="https://x.com/Abderrahmaneend" rel="noopener noreferrer"&gt;Twitter/X&lt;/a&gt; or &lt;a href="https://linkedin.com/in/toumi-abderahmane-132a7b151" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Let's build this together.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>saas</category>
      <category>nextjs</category>
      <category>prisma</category>
    </item>
  </channel>
</rss>
