Portfolio
"The blog you are reading right now."
Table of Contents
What it is
A personal portfolio and engineering blog built with Astro: a calm, editorial design with terminal accents (the ~ $ prompts, mono metadata, path-style breadcrumbs). It has an AI chat assistant, a ⌘K command palette, a light/dark theme in warm neutrals with a pastel-lavender accent, Markdown/MDX posts with theme-aware SVG and Mermaid diagrams, and sharing to LinkedIn or a copied link.
Deployed on Vercel at sreekeshokky.in.
Architecture
Architecture Decisions
Why Astro instead of Next.js, Hugo, or other SSG/SSR frameworks?
> Content-first architecture. Zero JS shipped by default. Perfect for a blog that reads like a terminal.
Astro's core philosophy is "islands architecture": ship zero JavaScript to the client by default, then hydrate interactive components individually. For a content-heavy portfolio with 11 blog posts, an about page, and project writeups, this means the entire site loads in under 100ms with minimal JS.
Next.js was considered but would add React hydration overhead for pages that are 99% static content. Hugo was considered but lacks the TypeScript-first DX and the content collection system that Astro provides. Astro hit the sweet spot: Markdown/MDX content collections with type-safe frontmatter, a component model for interactive islands, and first-class Vercel deployment.
> Tradeoff: Astro's component model is .astro files (not React), which has a learning curve. But for this use case, the simplicity outweighs the ecosystem size.
Why static output with a few server routes?
> Every page is content that only changes on deploy. Only the APIs need a server.
The site uses output: 'static' in astro.config.mjs with the @astrojs/vercel adapter. Every page is prerendered to HTML at build time and served from Vercel's CDN, so there are no cold starts or per-request rendering.
The three API routes (/api/chat, /api/like, /api/blogs) set export const prerender = false and run as serverless functions. An earlier version used output: 'server', which quietly server-rendered pages like Home and About on every request even though their content never changes per request.
Why Astro Content Collections for blog posts?
> Type-safe frontmatter with Zod validation. Co-located images. Automatic slug generation.
The content collection schema in src/content.config.ts defines:
z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: image().optional(),
tags: z.array(z.string()).optional(),
keywords: z.string().optional(),
)} z.coerce.date() automatically transforms string dates from frontmatter into Date objects. image().optional() provides type-safe image imports with Astro's built-in image optimization (via sharp). tags and keywords power per-post categorization, meta tags, and related post matching. The glob loader scans src/content/blog/ for .md and .mdx files.
Blog post images are co-located with their markdown files (e.g., src/content/blog/sdlc-that-loops-back/ contains both the .md file and its SVG/PNG images). This keeps related assets together and makes relative paths work correctly.
Why terminal accents instead of a full terminal theme?
> The terminal is the identity; the content is the point. So the terminal stays as an accent.
Earlier versions went all-in: every section was a "command", labels like MOD-01 and ACTIVE everywhere, and a long typing sequence on the homepage. It was distinctive but noisy. Now each page opens with one prompt line (~ $ whoami on the homepage, ls ~/blog, cat about.md and so on), breadcrumbs read like paths (~/blog/post-slug), and monospace is reserved for metadata: dates, tech stacks and paths.
The homepage types whoami once per session, then the headline, bio and numbers settle in, all within about a second. It's a small inline script with a sessionStorage flag, skipped for reduced motion. Everything else is Geist; JetBrains Mono carries the terminal touches.
How does the dark/light theme system work?
> CSS custom properties with a theme script that runs before paint. No flash of unstyled content.
The theme system is implemented in three parts:
- CSS Custom Properties (
src/styles/global.css)::rootholds the light palette and[data-theme="dark"]overrides it. Warm neutral grounds (#fafaf9light,#0e0d12dark) with one pastel-lavender accent. Because pastel is too pale for text on white, light mode splits it into--accent-fill(#d4c8fb, buttons and highlights) and--accent(#5b3fc0violet ink for links, 7.2:1 contrast); in dark mode both point at#c4b5fd. - Theme Script (
src/components/BaseHead.astro, lines 99-118): Runsis:inline(before any other scripts). CheckslocalStoragefirst, thenprefers-color-scheme, defaults to dark. Setsdata-themeon<html>before paint: no flash. - Toggle Button (
src/components/Header.astro): Sun/moon icons swap based on theme. Click handler saves tolocalStorage. CSS selectors:global([data-theme="dark"]) .moon-iconand:global([data-theme="light"]) .sun-iconhide the inactive icon.
Logo images also swap: .logo-dark shows in dark mode, .logo-light shows in light mode, controlled by the same [data-theme] selectors.
Why an AI chat widget on a portfolio site?
> Let visitors ask questions about Sreekesh instead of reading the entire site. Reduces friction.
The AiAssistant.astro component is a floating chat widget (bottom-right corner) that proxies messages to /api/chat, which calls OpenRouter's cohere/north-mini-code:free model with a persona file (src/data/persona.md, bundled into the function with a ?raw import).
The persona file contains Sreekesh's full professional background: identity, skills, experience, education, and communication style. The AI responds in first person as Sreekesh, staying within the scope of the persona.
Rate limiting is enforced at two levels:
- Client-side: 15 messages per hour, tracked in
localStorage. Shows time remaining when exceeded. - Server-side: 15 requests per hour per IP, tracked in-memory with probabilistic cleanup (5% chance per request).
Session persistence: Chat messages are stored in sessionStorage and restored on page load. The chat window open/close state is also persisted. Navigating between pages preserves the conversation.
> Tradeoff: Server-side rate limiting is per-instance (not global). On Vercel's serverless functions, each cold start resets the counter. This is acceptable for a low-traffic portfolio site.
What SEO features are built in?
> JSON-LD structured data (Person, BreadcrumbList, WebSite with SearchAction, BlogPosting, Article), OG/Twitter tags, per-post keywords, sitemap, RSS, canonical URLs.
BaseHead.astro provides site-wide SEO:
- JSON-LD Person schema with
sameAslinks to GitHub, LinkedIn, X - JSON-LD BreadcrumbList auto-generated from URL path
- JSON-LD WebSite schema with SearchAction
- Open Graph + Twitter Card meta tags with
twitter:siteandtwitter:creatorhandles - Canonical URL via
Astro.url - RSS feed via
@astrojs/rssat/rss.xml - Sitemap via
@astrojs/sitemapat/sitemap-index.xml - Per-page keywords via
keywordsprop (fallback to brand term defaults)
BlogPost.astro adds per-article JSON-LD BlogPosting schema with datePublished, dateModified, author info, and hero image. Blog posts also include tags and keywords in frontmatter, displayed as UI badges and used for <meta name="keywords">.
Additional SEO enhancements:
- Auto-generated Table of Contents from
##/###headings (collapsible<details>) - Related Posts section at bottom of each post matched by shared tags
- SVG hero banners are inlined and marked decorative, since the post title sits right above them; raster heroes keep descriptive
alttext
How do Mermaid diagrams work in blog posts?
> Dynamic client-side rendering with theme-aware re-rendering on toggle.
Blog posts use fenced code blocks with ```mermaid. The BlogPost.astro layout includes a client-side script that:
- Finds all
<pre data-language="mermaid">blocks - Replaces them with framed
.diagramfigures, the same frame the inlined SVG diagrams use - Dynamically imports Mermaid from CDN (
mermaid@10) - Renders with Mermaid's
basetheme, feeding it the site's--d-*diagram colour tokens read from CSS, so nodes, lines and notes match the lavender-and-neutral palette - Watches the
data-themeattribute and re-renders when it changes, so diagrams switch between light and dark with the rest of the page
This avoids bundling Mermaid (it's ~1.5MB) and only loads it when diagrams are present. The securityLevel: 'loose' setting allows click events in diagrams.
How does the reading progress bar work?
> Fixed position bar at the top, driven by scroll position. Pure CSS + JS, no library.
BlogPost.astro includes a .progress-bar-container with a .progress-bar child. A scroll listener calculates (scrollTop / scrollHeight) * 100 and sets the width as a percentage. The bar glows with box-shadow: 0 0 8px var(--accent) for the terminal aesthetic.
How does the share component work?
> LinkedIn share + Copy Link with client-side URL patching for accurate sharing.
The Share.astro component renders a dropdown with LinkedIn and Copy Link options. The LinkedIn URL is patched client-side (window.location.href) because Astro's Astro.url may not reflect the final deployed URL during SSR. Copy Link uses navigator.clipboard.writeText() with a "Copied!" confirmation.
Why Vercel Analytics only?
> One lightweight tracker beats two overlapping ones.
@vercel/analytics/astro covers page views and Core Web Vitals. Firebase Analytics used to run alongside it, loaded from a CDN module on every page, but for a portfolio the extra event-level data wasn't worth the added third-party script on every page load, so it was removed.
Key Components
- BaseHead.astro: Global
<head>: meta tags, JSON-LD, theme script, self-hosted fonts, analytics - Header.astro: Three sections (Work, Writing, About) from
src/data/nav.ts, a ⌘K search trigger and the theme toggle. Hides on scroll down and returns on scroll up; the active underline slides between items with a named view transition. - CommandPalette.astro: ⌘K / Ctrl+K /
/palette over every page, post, note and project plus actions (theme, copy email, resume, ask the assistant). The index is built at build time and inlined as JSON. - MobileNav.astro: Floating bottom tab bar on phones with the same three sections and search.
- SectionTabs.astro: Tabs joining the pages inside a section (Blog / Notes / Personal, About / Resume).
- BlogPost.astro: Shared layout for blog posts, about page, and project writeups. Includes reading progress bar, Mermaid rendering, BlogPosting JSON-LD, auto-generated Table of Contents, and Related Posts section.
- Footer.astro: Social links (LinkedIn, Medium, GitHub, Instagram). LinkedIn added after the social cleanup from header.
- AiAssistant.astro: Floating chat widget with session persistence, rate limiting, markdown rendering (marked + DOMPurify)
- Share.astro: Share dropdown (LinkedIn + Copy Link) with client-side URL patching
- FormattedDate.astro: Consistent date formatting across the site
Blog Posts (11)
Each post has tags and keywords in frontmatter. Tags appear as badges on listing cards and detail pages, and drive the Related Posts recommendation engine. Reading time is computed from word count (200 wpm). A collapsible Table of Contents is auto-generated from ##/### headings. Posts are registered automatically via Astro Content Collections: no manual list to maintain as the blog grows.
- All posts live in
src/content/blog/as Markdown or MDX - Frontmatter schema (Zod) validates all fields including tags and keywords
- Hero images are co-located with their post directory
- Blog listing at
/blogpulls from the collection automatically - Short-form
notes(music, tattoos, and other fragments) live insrc/content/notes/with their own schema
Tech Stack
- Framework: Astro 5 (static output on Vercel, serverless API routes)
- Content: Markdown + MDX via Astro Content Collections with Zod schema validation
- Styling: CSS custom properties + global.css (no Tailwind, no CSS-in-JS)
- Fonts: Geist and JetBrains Mono, self-hosted variable fonts
- AI: OpenRouter API + Cohere North Mini Code (chat widget), with a persona file bundled at build time
- Analytics: Vercel Analytics
- SEO: JSON-LD, OG tags, sitemap, RSS, canonical URLs
- Diagrams: Mermaid.js (dynamic CDN import, theme-aware)
- Security: DOMPurify for AI response sanitization, rate limiting (client + server)
- Image optimization: sharp (via Astro built-in)
What I learned
- Astro is the right framework for content-heavy sites. Zero JS by default is a superpower.
- CSS custom properties are sufficient for theming. No need for CSS-in-JS or Tailwind for a portfolio.
- An AI chat widget on a portfolio is a genuine UX improvement. Visitors ask questions instead of bouncing.
- The persona file pattern (
src/data/persona.md) is a clean way to separate AI behavior from application code. Import it with?raw: reading it frompublic/withfsfails on Vercel, becausepublic/is served from the CDN and is not bundled into the function. - Mermaid diagrams don't need to be bundled. Dynamic CDN import + theme-aware re-rendering is the right pattern.
- Moving the logo from hero image to header nav cleaned up the layout and made it feel more like a real site.
- Tags + keywords on blog posts unlock Related Posts and better SEO without any external service.
- A collapsible Table of Contents from markdown headings is trivial to generate server-side with a regex.
- The theme went through three eras: neon green on black, then electric blue on navy, then warm neutrals with a pastel-lavender accent. Each step removed decoration. Saturated colors and "system" labels read as a 2023 template; quieter grounds and one restrained accent let the writing and projects carry the page.