Why this blog lives at /blog and not on a subdomain
I wanted somewhere to write up the things that took real debugging to figure out. Mostly Power Platform and SharePoint work, where the answer is usually buried in a five-year-old forum thread.
I already had a portfolio: a Vite + React SPA with shadcn/ui, a dark mode toggle, and
a serverless chat endpoint. The obvious move was to add a /blog route to it. I didn’t, and the
reasons turned out to be more interesting than I expected.
Why not just add a route to the React app
The portfolio is a client-rendered SPA. Its index.html has exactly one <title>, “No Code No
Life”, and no meta description, no Open Graph tags, no canonical link, no sitemap. Every route
serves that same shell and React fills in the rest.
That’s completely fine for a portfolio. Nobody arrives at a portfolio via search; they arrive because you sent them the link.
Blog posts are the opposite. Nobody browses their way to a post about SharePoint person fields. They arrive having typed the problem into Google at 4pm with a broken form in the other tab. The entire value of the page is that a search engine understood what it was about, which means per-post titles, per-post descriptions, per-post canonical URLs, and HTML that says what it says before any JavaScript runs.
I could have retrofitted that: react-helmet for the meta tags, a prerender plugin for the static
HTML. But at that point I’d be reimplementing, badly, what a static site generator does by default.
So: separate Astro project, and the interesting question became where to put it.
Subdirectory or subdomain
Two options: blog.example.com or example.com/blog.
The subdomain is dramatically easier. Point a CNAME at the new deployment and you’re done, with no coordination between the two projects at all.
On the SEO question I want to be careful, because it’s one people overstate in both directions. Google’s own line has consistently been that they treat subdomains and subdirectories roughly equivalently, and I have no basis to contradict them. What I’d say instead is narrower: a subdomain is a separate host, and separate hosts are one more thing that can be treated separately, by search engines, by analytics, by anything that keys on origin. A subdirectory removes that entire question. When one option needs a paragraph of hedging and the other doesn’t, I’ll take the one that doesn’t.
But the argument that actually settled it wasn’t about search at all.
The same-origin argument
example.com and example.com/blog are the same origin: same scheme, same host, same port. So
they share localStorage.
blog.example.com would be a different origin. Same registrable domain, different origin, and
localStorage is partitioned by origin, not by domain.
That matters because the portfolio already had a theme toggle. Its ThemeProvider writes
light/dark/system to localStorage under the key vite-ui-theme and puts the matching class on
<html>. If the blog is same-origin, it can read that exact key, and a reader’s theme choice
carries between a React app and an Astro site with no shared code, no cookie, no query parameter, no
coordination whatsoever.
In the blog, that’s an inline script in <head>, before anything paints:
(() => {
const KEY = 'vite-ui-theme';
let stored;
try {
stored = localStorage.getItem(KEY);
} catch {
// Private mode / storage blocked: fall through to the default.
}
const theme = stored || 'dark';
const resolved =
theme === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
: theme;
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(resolved);
root.dataset.theme = theme;
})();
Inline and synchronous, not a module. A deferred script would run after first paint and you’d get a white flash before it went dark.
Two details I got wrong first time, both from reading my own portfolio’s code too casually:
The default isn’t system. The portfolio mounts <ThemeProvider defaultTheme="dark">, so an
unset key means dark. I defaulted to system, which meant a first-time reader on a light-mode
laptop saw a dark portfolio and a blinding white blog.
It adds a class rather than toggling one. ThemeProvider does
root.classList.remove('light','dark') then adds the resolved value. I was doing
classList.toggle('dark', isDark), which never sets light. Same rendered result today, but any CSS
either side later writes against html.light would silently not match.
Worth checking the current code rather than whatever’s in your local checkout. Mine was three commits behind, and the header had been substantially rewritten in the meantime.
Making the subdirectory work
Two Vercel projects. The blog sets its base path:
// astro.config.mjs
export default defineConfig({
site: 'https://example.com',
base: '/blog',
trailingSlash: 'never',
});
and the portfolio forwards that path to it:
{
"rewrites": [
{ "source": "/blog", "destination": "https://my-blog.vercel.app/blog" },
{ "source": "/blog/:path*", "destination": "https://my-blog.vercel.app/blog/:path*" }
]
}
A rewrite, not a redirect, so the URL stays example.com/blog/... in the address bar and in the index,
which is the whole point. Both rules are needed: the first matches the index exactly, the second
everything under it.
One prerequisite that’s easy to miss: this only works if the hostname you pick actually serves your
app. A custom domain that meta-refreshes to something.vercel.app, a very easy place to end up
when you’re getting a domain working in a hurry, puts the platform URL in the address bar, accrues
every bit of authority to a hostname you’re renting, and leaves a /blog rewrite nothing to attach
to. Either point DNS at the host properly, or use the platform hostname deliberately and canonicalise
to it. The half-measure is the only genuinely bad option.
I’m doing the latter for now, deliberately. The most valuable property a URL has is still resolving in three years, so until I’ve decided whether I’m keeping a domain long-term, I’d rather build on a hostname I’m sure about and 301 later than on one I might drop.
Things the Astro blog template doesn’t warn you about
The official --template blog starter is good. Its sample posts are also all about 200 words long,
which hides a few things that only show up on a real one. Mine is 3,300 words and 22,000 pixels tall.
Setting base breaks the page layout. The template ships src/pages/blog/[...slug].astro. With
base: '/blog', that’s /blog/blog/my-post. Move the routes up to src/pages/ so the base supplies
the prefix.
Wide tables drag the whole page sideways. Ten reference tables, each 540–700px, in a 375px
viewport. <pre> already scrolls itself; tables don’t. On mobile:
@media (max-width: 720px) {
table { display: block; max-width: 100%; overflow-x: auto; }
td code, th code { white-space: nowrap; }
}
The nowrap matters. Without it the table shrinks to fit by breaking words mid-identifier, and
groupName renders as grou pNam e. A readable identifier you scroll to beats a mangled one in
place.
The h1 is 3.052em. On a phone, a long title fills the entire screen before you see a word of
the post.
Dates render a day early. Frontmatter pubDate: '2026-08-19' parses as UTC midnight; format it
in local time anywhere west of UTC and you get August 18. timeZone: 'UTC' in the
toLocaleDateString options.
Draft support isn’t included. It’s four places, not one: filter the index, filter the RSS feed,
filter the sitemap, and emit <meta name="robots" content="noindex, nofollow">. Leave the page
building so you can still preview it at its real URL. Miss the sitemap and you’re actively
advertising a URL you’ve told Google to ignore.
The sitemap emits the index twice. With trailingSlash: 'never', Astro generates both /blog and
/blog/, and they normalise to one URL after the sitemap’s filter runs, so deduping with a Set
needs to strip the trailing slash first, or it sees two distinct strings and keeps both.
Astro 7 needs Node 22. It refuses to build on 20, and nvm’s npm shim resolves whatever node is
on PATH, so pointing a script at ~/.nvm/versions/node/v22.x/bin/npm doesn’t help. Point it at
the node binary directly, or nvm use first.
Was it worth it
For a portfolio, no. A subdomain would have been fine and taken an afternoon less.
For content meant to be found by people with a specific problem, the subdirectory is the right call, and the theme carrying seamlessly between two unrelated frameworks is a nicer result than I expected from a decision I made for unrelated reasons. That happens more often than it should.