Zero-JS by default, interactive when needed
Every tutorial for building a blog starts the same way.
Install Astro. Add React. Add a theme toggle. Add a comment section. Add client-side search. Add a state management library because the theme toggle and search need to talk to each other.
Before you have written a single post, you have shipped forty kilobytes of JavaScript to display text.
I did not want that.
The default is already correct
Astro ships no JavaScript by default.
This is not a marketing line. It is literally what happens. You write a component, Astro renders it to HTML at build time, and the browser receives HTML. No hydration. No bundle. No islands. Just markup.
Most people treat this as a limitation they need to overcome. They add @astrojs/react before they have a single interactive element. They sprinkle client:load everywhere because that is what the examples show.
I tried the opposite. I started with nothing and only added JavaScript when I had a specific problem that HTML could not solve.
It turns out a blog is just documents. The browser has been good at rendering documents since 1993. It does not need help.
What I actually built
My site is nine blog posts, an about page, a projects page, and an archive. Here is the entire Astro config.
// astro.config.mjs
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
site: 'https://hasil.in',
integrations: [mdx(), sitemap()],
markdown: {
shikiConfig: { theme: 'github-dark-dimmed', wrap: true },
},
adapter: cloudflare(),
});
That is it. No React. No Vue. No islands. Just MDX for writing posts and a sitemap for search engines.
The content lives in Markdown files with frontmatter. Astro has a feature called Content Collections that validates the frontmatter with a Zod schema at build time.
// content.config.ts
import { glob } from 'astro/loaders';
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
excerpt: z.string(),
date: z.coerce.date(),
category: z.enum(['programming', 'stories', 'general', 'logs']),
tags: z.array(z.string()),
reading: z.number().int().positive(),
author: z.string().default('Hasil T'),
}),
});
export const collections = { blog };
This is the entire CMS. No database. No API. No client-side data fetching. Just files on disk that get validated and turned into HTML at build time.
Each blog post becomes a static page via getStaticPaths.
---
// src/pages/blog/[...slug].astro
import { getCollection, render } from 'astro:content';
import Callout from '../../components/Callout.astro';
import BlogPostLayout from '../../layouts/BlogPostLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((p) => ({
params: { slug: p.id },
props: { post: p },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<BlogPostLayout id={post.id} data={post.data}>
<Content components={{ Callout }} />
</BlogPostLayout>
Notice the Callout component passed to MDX. It is an Astro component, not a React component. It renders to static HTML. There is no client:load directive anywhere in this file. There is no JavaScript shipped for reading a blog post.
Where I actually added JS
I have three pieces of JavaScript on the entire site. That is it.
1. Email obfuscation
My about page has an email link. I do not want scrapers to get it. So I split the address into data attributes and reconstruct it with a tiny inline script.
<a id="contact-email" href="#" data-u={emailUser} data-d={emailDomain}>
<noscript>contact via github</noscript>
<span class="js-only">enable js to view</span>
</a>
<script is:inline>
(() => {
const a = document.getElementById('contact-email');
if (!a) return;
const u = a.dataset.u;
const d = a.dataset.d;
if (!u || !d) return;
a.href = `mailto:${u}@${d}`;
a.textContent = `${u}@${d}`;
})();
</script>
Ten lines. Progressive enhancement. If JavaScript is disabled, the user sees “contact via github.” If JavaScript is enabled, they see the actual address. No framework needed.
2. Mobile sidebar and status indicator
The top bar has a hamburger menu for small screens. It also shows whether I am probably online based on my timezone and business hours.
<script>
(function () {
const now = new Date();
const istOffset = 5.5 * 60;
const utcMs = now.getTime() + now.getTimezoneOffset() * 60000;
const ist = new Date(utcMs + istOffset * 60000);
const day = ist.getDay();
const hour = ist.getHours();
const isOnline = day >= 1 && day <= 5 && hour >= 9 && hour < 18;
const dot = document.getElementById('status-dot');
const label = document.getElementById('status-label');
if (isOnline) {
label.textContent = 'online';
} else {
dot.style.background = 'var(--fg-dim)';
dot.style.boxShadow = 'none';
label.textContent = 'offline';
}
})();
const toggles = document.querySelectorAll('[data-sidebar-toggle]');
const closers = document.querySelectorAll('[data-sidebar-close]');
const open = () => {
document.body.classList.add('sidebar-open');
toggles.forEach((t) => t.setAttribute('aria-expanded', 'true'));
};
const close = () => {
document.body.classList.remove('sidebar-open');
toggles.forEach((t) => t.setAttribute('aria-expanded', 'false'));
};
toggles.forEach((t) => t.addEventListener('click', open));
closers.forEach((c) => c.addEventListener('click', close));
document.addEventListener('click', (e) => {
if (e.target.matches('[data-sidebar-backdrop]')) close();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') close();
});
</script>
Forty lines of vanilla JavaScript. No state management library. No component framework. Just DOM APIs that have worked in every browser for fifteen years.
3. Analytics
I run Google Analytics in production. This is the largest JavaScript on the site, but it is completely optional. If the environment variable is not set, nothing ships.
---
// BaseLayout.astro
const gaMeasurementId = import.meta.env.PUBLIC_GA_MEASUREMENT_ID?.trim();
---
{
gaMeasurementId ? (
<>
<script async src={`https://www.googletagmanager.com/gtag/js?id=${gaMeasurementId}`}></script>
<script is:inline define:vars={{ gaMeasurementId }}>
window.dataLayer = window.dataLayer || [];
window.gtag = function gtag() {
window.dataLayer.push(arguments);
};
window.gtag('js', new Date());
window.gtag('config', gaMeasurementId, {
send_page_view: false,
anonymize_ip: true,
});
</script>
</>
) : null
}
{
gaMeasurementId
? <script>import { initAnalytics } from '../scripts/analytics'; initAnalytics();</script>
: null
}
The initAnalytics function handles scroll depth, link clicks, and page visibility. It is about two hundred and eighty lines of TypeScript. Only loads when analytics is configured.
Without analytics, the site ships less than one kilobyte of custom JavaScript total.
What I did not build
Here is a list of things I considered and rejected.
Search. Ctrl+F works on every page. For finding posts, I have tags and an archive. A client-side search index would add more JavaScript than all my content combined.
Comments. I have an email address. That is enough.
Theme toggle. Dark mode is hardcoded. I like it. If you do not, your browser can force light mode. That is a you problem.
React components. I have a Callout component for notes and warnings. It is an Astro component. It renders to HTML. It does not need hydration.
Sticky navigation. The sidebar scrolls with the page. It does not need to track scroll position in JavaScript. CSS handles it.
Reading progress bar. You know how long the post is. The scrollbar tells you where you are. We solved this problem in 1984.
Each of these omissions was a decision, not laziness. Every feature I did not add is a feature I do not have to maintain, debug, or ship to users.
Why I do not use Astro islands
Astro islands are a great feature. If you need a React date picker or an interactive chart, islands let you hydrate only that component while the rest of the page stays static.
I do not use them because I do not need them.
Not because I am trying to be pure. Not because I think JavaScript is bad. Because my site is a collection of documents, and documents do not need reactivity.
Astro islands solve the problem of “how do I add interactivity without ruining performance.” But most blog posts never reach the point where that problem exists. The default behavior, zero JavaScript, is already the right answer.
The mental model shift
The question most people ask is: “what framework should I use for my blog?”
The better question is: “what problem am I actually solving?”
If the answer is “displaying text with links and occasional images,” you do not need a framework. You need HTML. Astro gives you HTML by default, with an escape hatch for the rare moments you need more.
I spent ten years building backends. Most production incidents I have caused came from clever code that solved problems I did not have. The same thing happens on the frontend. We add complexity because the tools make it easy, not because the problem demands it.
Astro’s real power is not islands. It is the default. It assumes you do not need JavaScript until you prove otherwise.
That assumption is correct more often than you think.