Hosting a Blog on a /blog Subdirectory When It's a Separate App

Jun 20, 2026·10 min read

Hosting a Blog on a /blog Subdirectory When It's a Separate App

Summarize this article

There is a recurring decision in any product with a content arm: the marketing site or app is one codebase, the blog is hundreds of markdown files, and the two want very different release cadences. The blog shouldn't trigger a full app rebuild for a typo fix, and the app shouldn't redeploy 400 articles to ship a pricing change. The clean answer is to split them into two deployments.

The catch is where the blog lives in the URL. If you care about organic traffic, that decision is not cosmetic — and once you pick the SEO-correct option, you inherit a set of reverse-proxy problems that no framework tutorial warns you about. We recently moved a blog of 435 articles out of a Next.js app into its own static deployment, served under /blog on the same domain. Here is what actually breaks, and how to fix each piece.

Why a subdirectory beats a subdomain

The first instinct is to put the blog on a subdomain: blog.example.com. It's the path of least resistance because there's no routing to share — DNS points the subdomain straight at the second deployment and you're done.

It's also the weaker choice for SEO. Search engines treat a subdomain as a largely separate site for the purpose of accumulating authority. Links, rankings, and trust signals that your blog earns do not fully flow back to the root domain, and vice versa. A subdirectory — example.com/blog — keeps everything under one host, so every article you publish compounds the authority of the same domain your product pages rank on. For a site whose blog exists specifically to pull top-of-funnel search traffic toward the product, that consolidation is the entire point.

So the requirement becomes: two independent deployments, one hostname, blog under /blog. That means the app deployment that owns the domain has to forward /blog traffic to the blog deployment. A reverse proxy. This is where it gets interesting.

The architecture: one domain, two deployments

The main app keeps the domain and adds a proxy rule. In a Next.js app, that's a rewrite that runs before its own routes:

async rewrites() {
  return {
    beforeFiles: [
      { source: "/blog", destination: `${BLOG_ORIGIN}/blog` },
      { source: "/blog/:path*", destination: `${BLOG_ORIGIN}/blog/:path*` },
    ],
  }
}

BLOG_ORIGIN is an environment variable pointing at the blog's own deployment URL. Every request to example.com/blog/anything is transparently fetched from the blog deployment and streamed back. The visitor's address bar never changes; crawlers see one domain.

That's the whole concept, and in a demo it works on the first try. In production it fails three times before it works — and two of those failures are silent.

Gotcha 1: your CSS 404s and the page renders unstyled

The blog we moved was built with a static site generator configured with a base path of /blog — the standard way to tell a generator "all my URLs live under this prefix." The generated HTML correctly references assets like /blog/_astro/index.css.

The problem: most static hosts deploy those files to the root of the blog deployment, not under a /blog/ folder. So the file physically lives at blog-deployment.app/_astro/index.css, but the HTML asks for /blog/_astro/index.css. On the blog's own origin that's a 404. Through the proxy it's also a 404. The page loads as raw unstyled HTML, and you stare at it wondering why a build that worked locally looks broken in production.

The fix is to make the blog deployment internally rewrite its own /blog prefix back to root, so it's consistent whether it's hit directly or through the proxy. On Vercel that's a few lines of vercel.json:

{
  "cleanUrls": true,
  "rewrites": [
    { "source": "/blog", "destination": "/" },
    { "source": "/blog/:path*", "destination": "/:path*" }
  ]
}

Now /blog/_astro/index.css resolves to the real file at /_astro/index.css on the blog deployment. The key insight: the base path controls URL generation (what the HTML references), not file layout (where the host serves them). You have to reconcile the two yourself.

Gotcha 2: the proxy gets a 401 instead of your blog

This one cost the most time because the failure looks like the proxy is misconfigured when it isn't.

Many hosting platforms enable deployment protection by default — an authentication wall in front of preview and sometimes production deployments. It's a good default for staging environments. It's a disaster for a reverse-proxy target, because the proxy is a server-to-server fetch with no user session. When the app deployment fetches BLOG_ORIGIN/blog/..., it doesn't get your blog — it gets the platform's login page, status 401, content type text/html. Your /blog route renders a login screen or a blank page.

You can confirm it in one command by hitting the blog deployment the way the proxy does, with no cookies:

curl -s -o /dev/null -w "%{http_code}" https://blog-deployment.app/blog
# 401  → protected, the proxy will fail

The fix is to disable deployment protection on the blog project. This feels wrong the first time — you're turning off a security feature — but a public blog is content you actively want crawled and served to everyone. There's nothing to protect. The automation-bypass token that platforms offer as an alternative doesn't help here, because a framework rewrite can't attach custom headers to the proxied request. Disable the wall; it's the correct call for public content.

Gotcha 3: analytics silently dies, and the dashboard lies by omission

This is the gotcha that no one writes about, because it produces zero errors in the obvious places. The site works, pages load, everything looks fine — and the analytics dashboard quietly shows nothing for the blog from the moment you split it out.

Here's the mechanism. When you enable a platform's first-party web analytics, it often serves the tracking script from a randomized, project-specific path to evade ad blockers — something like /f39247db503f5115/script.js instead of a predictable /insights/script.js. That obfuscated path is generated per project and lives at the root of that project's deployment.

Now combine that with the proxy. The blog page is served under example.com/blog/..., but the analytics script tag points at example.com/f39247db503f5115/script.js — a root path, not under /blog. Your proxy only forwards /blog/*. So that request hits the main app, which has no such file, returns its 404 page as text/html, and the browser refuses to execute it. No script, no beacon, no data. The only visible symptom is in the browser console:

Refused to execute script from '.../f39247db503f5115/script.js'
because its MIME type ('text/html') is not executable

Even the beacon endpoint is affected: the pageview ping goes to a second hashed root path (/f39247db503f5115/view) that 404s identically.

The fix is to force the analytics client onto the standard, predictable paths instead of the obfuscated ones. Most analytics SDKs expose overrides for the script source and the event endpoints; setting them explicitly wins over the injected ad-blocker-bypass config:

<Analytics
  scriptSrc="/_vercel/insights/script.js"
  endpoint="/_vercel/insights"
  viewEndpoint="/_vercel/insights/view"
  eventEndpoint="/_vercel/insights/event"
/>

Those standard paths resolve on the apex domain to the main project, so blog pageviews land back in the same analytics property as the app — exactly where they were before the split. After the change, the network panel shows the pageview POST returning 200 and the console is clean. The trade-off is that you lose the ad-blocker evasion, but a portion of recorded traffic beats none of it.

There's a useful contrast here: a third-party analytics tool configured with an absolute host (e.g. a fixed https://... ingest URL) was completely unaffected by all of this. It never cared which origin served the page or how the proxy was wired, because its beacon target was a full URL, not a relative path. Relative analytics paths are the thing that breaks behind a proxy. If you run two tools, the absolute-host one is your safety net.

The smaller stuff that's easy to miss

Three more items bit us in minor ways:

Two sitemaps, and crawlers must fetch them from the apex. After the split, the app's sitemap should drop all blog URLs and the blog publishes its own — in our case 510 URLs across posts, category hubs, and paginated pages. List both in robots.txt, and submit the blog's sitemap to Search Console using its apex URL (example.com/blog/sitemap-index.xml), never the blog deployment's raw URL — the <loc> entries must match the hostname you're indexing or the sitemap gets flagged as cross-domain.

lastmod is not automatic. Several sitemap integrations omit the lastmod tag unless you explicitly populate it. We had to read each post's updated/date frontmatter at build and inject it per URL. It's worth doing: lastmod is a real crawl-scheduling hint, but only if it reflects genuine content changes rather than the build timestamp on every page — otherwise the signal gets ignored.

Redirects belong on the app, not the blog. Canonical cleanups like /blog/page/1 → /blog should live in the app's redirect config, because that's the layer that owns the apex routing and runs redirects before the proxy. We verified the live result returns a 308 to the canonical URL. Putting the redirect on the blog deployment is fragile, since its base-path handling mangles the rule.

Favicons and other root assets. Anything referenced with a root-absolute path (/favicon.svg, /site.webmanifest) is served by the app, not the proxied blog, because those paths aren't under /blog. Either let the app serve them or copy them into the blog so it's self-contained. We did the latter so the blog renders correctly at its own origin too.

When to just use a subdomain instead

The honest counterpoint: every one of these problems disappears on a subdomain, because the blog is served on its own hostname with no proxy in the path. Assets resolve, analytics attaches to the blog's own property, deployment protection is irrelevant. If the blog's content is not a meaningful organic-traffic play — internal changelogs, release notes, docs that rank fine on their own — a subdomain is the lower-maintenance choice and you should take it.

The subdirectory is worth the proxy complexity in exactly one situation: when the blog exists to funnel search authority into the same domain as the product, and the volume of content makes that authority compounding matter. That was true for us at 435 articles and climbing. If it's true for you, budget for the three gotchas above — they are entirely solvable, but only one of them announces itself, and the silent one is the one that costs you a month of missing analytics before you notice.

Summarize this article

Splitting an app and worried about breaking SEO or analytics?

We build and untangle multi-deployment web architectures for SaaS and content teams — subdirectory blogs, reverse proxies, and the SEO and analytics plumbing that keeps them whole.