<?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>DEV Community: bi kai</title>
    <description>The latest articles on DEV Community by bi kai (@bi_kai_819e15a0421b0b9d6b).</description>
    <link>https://dev.to/bi_kai_819e15a0421b0b9d6b</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%2F3932582%2F227e38b4-cb6c-4f82-af65-76caadc7d19e.jpg</url>
      <title>DEV Community: bi kai</title>
      <link>https://dev.to/bi_kai_819e15a0421b0b9d6b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bi_kai_819e15a0421b0b9d6b"/>
    <language>en</language>
    <item>
      <title>How to Fix the ads.txt 500 Error on Next.js App Router with Vercel</title>
      <dc:creator>bi kai</dc:creator>
      <pubDate>Fri, 15 May 2026 07:22:07 +0000</pubDate>
      <link>https://dev.to/bi_kai_819e15a0421b0b9d6b/how-to-fix-the-adstxt-500-error-on-nextjs-app-router-with-vercel-5213</link>
      <guid>https://dev.to/bi_kai_819e15a0421b0b9d6b/how-to-fix-the-adstxt-500-error-on-nextjs-app-router-with-vercel-5213</guid>
      <description>&lt;p&gt;If you've ever tried to apply for Google AdSense with a Next.js App Router site deployed on Vercel, you may have run into a frustrating issue: visiting &lt;code&gt;your-domain.com/ads.txt&lt;/code&gt; returns a &lt;strong&gt;500 Internal Server Error&lt;/strong&gt;, even though the file exists in your &lt;code&gt;public/&lt;/code&gt; directory and works fine in local development.&lt;/p&gt;

&lt;p&gt;This post explains why it happens and shares the cleanest fix I found while preparing my own site for AdSense submission.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;The conventional way to serve &lt;code&gt;ads.txt&lt;/code&gt; is to drop it into the &lt;code&gt;public/&lt;/code&gt; directory.&lt;/p&gt;

&lt;p&gt;In a Pages Router project, this works without issue. But with the App Router on Vercel, you might see one of these symptoms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;your-domain.com/ads.txt&lt;/code&gt; returns &lt;strong&gt;500&lt;/strong&gt; in production&lt;/li&gt;
&lt;li&gt;The file works fine on &lt;code&gt;localhost:3000&lt;/code&gt; during &lt;code&gt;next dev&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;AdSense crawler reports the file as unreachable&lt;/li&gt;
&lt;li&gt;DevTools shows a non-zero response but empty content, or a hard server error&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is particularly annoying because AdSense &lt;strong&gt;requires&lt;/strong&gt; a properly served &lt;code&gt;ads.txt&lt;/code&gt; for site authorization, and the failure mode is silent — you only notice when AdSense flags your site weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Happens
&lt;/h2&gt;

&lt;p&gt;The root cause is a routing precedence quirk between App Router's matching system and Vercel's static file handling.&lt;/p&gt;

&lt;p&gt;In App Router, file-based routes are evaluated before static assets in &lt;code&gt;public/&lt;/code&gt; in certain edge cases. When &lt;code&gt;ads.txt&lt;/code&gt; is requested:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;App Router tries to match &lt;code&gt;/ads.txt&lt;/code&gt; against its route tree&lt;/li&gt;
&lt;li&gt;Because &lt;code&gt;.txt&lt;/code&gt; isn't a recognized App Router file extension, the matcher enters an undefined state&lt;/li&gt;
&lt;li&gt;On Vercel's edge runtime, this manifests as a 500 instead of falling through to the static file&lt;/li&gt;
&lt;li&gt;Locally, the dev server's looser matching often masks the issue&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The behavior is inconsistent enough that it slips past local testing and only shows up after deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Route Handler
&lt;/h2&gt;

&lt;p&gt;Instead of relying on &lt;code&gt;public/&lt;/code&gt;, define &lt;code&gt;ads.txt&lt;/code&gt; as an App Router Route Handler at &lt;code&gt;app/ads.txt/route.ts&lt;/code&gt; with this content:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&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;publisherId&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;ADSENSE_PUBLISHER_ID&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// ads.txt requires the "pub-" form without the "ca-" prefix&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleanId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;publisherId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^ca-/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&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;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cleanId&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`google.com, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cleanId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, DIRECT, f08c47fec0942fa0`&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&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;Content-Type&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;text/plain&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;Cache-Control&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;public, max-age=0, must-revalidate&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="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;A few details worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The handler reads your publisher ID from an environment variable, so the same code works across staging and production.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ca-pub-XXXXXXXXXXXXXXXX&lt;/code&gt; is the format AdSense gives you, but &lt;code&gt;ads.txt&lt;/code&gt; wants &lt;code&gt;pub-XXXXXXXXXXXXXXXX&lt;/code&gt; — the regex strips the &lt;code&gt;ca-&lt;/code&gt; prefix automatically.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Cache-Control: must-revalidate&lt;/code&gt; ensures AdSense always sees the latest content if you ever rotate IDs.&lt;/li&gt;
&lt;li&gt;When &lt;code&gt;ADSENSE_PUBLISHER_ID&lt;/code&gt; is not set, the route returns an empty 200 response instead of crashing — useful during AdSense pre-approval when you don't have a publisher ID yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Setting the Environment Variable on Vercel
&lt;/h2&gt;

&lt;p&gt;Once your AdSense application is approved and you have your publisher ID:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to your Vercel project, then Settings, then Environment Variables&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;ADSENSE_PUBLISHER_ID&lt;/code&gt; with value &lt;code&gt;ca-pub-XXXXXXXXXXXXXXXX&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Apply to Production (and optionally Preview/Development)&lt;/li&gt;
&lt;li&gt;Trigger a redeploy — environment variables don't apply retroactively to existing deployments&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Verifying It Works
&lt;/h2&gt;

&lt;p&gt;After deployment, verify the response headers with curl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://your-domain.com/ads.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;HTTP/2 200&lt;/code&gt; and &lt;code&gt;content-type: text/plain&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then check the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://your-domain.com/ads.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expected output is &lt;code&gt;google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For additional validation, paste your domain into the &lt;a href="https://adstxt.guru/" rel="noopener noreferrer"&gt;adstxt.guru validator&lt;/a&gt; — it parses the file the same way AdSense does and catches formatting issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes to Avoid
&lt;/h2&gt;

&lt;p&gt;A few things I tripped over during my own setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't leave the &lt;code&gt;ca-&lt;/code&gt; prefix in the output.&lt;/strong&gt; The format &lt;code&gt;google.com, ca-pub-XXX, DIRECT, ...&lt;/code&gt; is invalid. AdSense's verification will fail silently. Always strip it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't forget to redeploy after changing the environment variable.&lt;/strong&gt; Vercel does not apply env var changes to existing deployments — only new builds pick them up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't test only on localhost.&lt;/strong&gt; This entire bug is invisible in &lt;code&gt;next dev&lt;/code&gt;. Always verify the deployed URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't add multiple files.&lt;/strong&gt; If you have both &lt;code&gt;public/ads.txt&lt;/code&gt; and &lt;code&gt;app/ads.txt/route.ts&lt;/code&gt;, the behavior is undefined. Pick one — the Route Handler is more reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Notes
&lt;/h2&gt;

&lt;p&gt;I ran into this exact issue while preparing my own side project for AdSense — a free browser-based harmonium for Indian classical music practice at &lt;a href="https://playharmonium.com" rel="noopener noreferrer"&gt;playharmonium.com&lt;/a&gt;. The site uses Next.js App Router on Vercel, and the &lt;code&gt;ads.txt&lt;/code&gt; 500 error was the last thing blocking the application. The Route Handler approach above is what I ended up shipping, and the file has been served reliably since.&lt;/p&gt;

&lt;p&gt;If you're going through AdSense setup yourself and hit the same wall, I hope this saves you a few hours of head-scratching. Drop a comment if you've found other quirks worth documenting.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>vercel</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
