Portfolio
"The blog you are reading right now."
Table of Contents
What it is
A personal portfolio and engineering blog built with Astro, styled with a terminal/hacker aesthetic. It features a typing-animated homepage, an AI chat widget, dark/light theme toggle, Markdown/MDX blog posts with Mermaid diagram support, and multi-platform publishing (LinkedIn, Medium, copy 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 6 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 output: 'server' instead of static generation?
> The AI chat widget needs a server-side API route. SSR on Vercel gives us both.
The site uses output: 'server' in astro.config.mjs, deployed via @astrojs/vercel adapter. This enables the /api/chat endpoint (a server-side POST handler that proxies requests to OpenRouter) while still rendering all other pages as static content at build time.
The api/chat.ts route has export const prerender = false to opt out of static generation for that specific endpoint. Everything else is effectively static — Astro prerenders blog posts, the about page, and project pages at build time.
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 the terminal/hacker aesthetic?
> Distinctive identity. Every section is a "command" the visitor types.
The homepage animates a sequence of terminal commands: whoami, cat competencies.txt, ls /var/experience/ (listing senior_staff_engineer.sh, lead_engineer.sh, software_engineer.sh, achievements.sh), and ./download_cv.sh. Each command links to its corresponding anchor section on the about page. A typing animation types out whoami character-by-character (40ms per char), then the remaining sections fade in with staggered delays.
This is pure client-side JavaScript using setTimeout chains. No animation library needed. The CSS uses JetBrains Mono throughout (loaded from Google Fonts). The person-section is in src/components/Header.astro with the rest in src/pages/index.astro.
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)::rootdefines dark theme defaults.[data-theme="light"]overrides all variables. Key variables:--accent(#00e676 dark, #006600 light),--bg-color(#0f0f0f dark, not pure black),--surface-color(#1a1a1a dark),--text-color,--heading-color,--header-bg. - 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 (public/persona.md).
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
- Hero images use the post title as
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 empty
.mermaid-containerdivs - Dynamically imports Mermaid from CDN (
mermaid@10) - Renders each diagram with theme detection (dark/light)
- Listens for theme toggle clicks and re-renders all diagrams with the new theme
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 both Vercel Analytics and Firebase Analytics?
> Vercel for Web Vitals and performance. Firebase for user behavior and event tracking.
@vercel/analytics/astro tracks Core Web Vitals (LCP, FID, CLS) automatically. Firebase Analytics (loaded via CDN in BaseHead.astro) provides event-level tracking for user interactions. Both are lightweight and complementary — Vercel for performance monitoring, Firebase for product analytics.
Key Components
- BaseHead.astro — Global
<head>: meta tags, JSON-LD, theme script, Firebase init, font loading, analytics - Header.astro — Navigation (Home, Blog, Projects, About) + theme toggle + logo (24px, moved from hero image to nav). No social links (moved to footer for cleaner nav).
- 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 (6)
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
Tech Stack
- Framework: Astro 5 (SSR on Vercel)
- Content: Markdown + MDX via Astro Content Collections with Zod schema validation
- Styling: CSS custom properties + global.css (no Tailwind, no CSS-in-JS)
- Font: JetBrains Mono (Google Fonts) — monospace for the terminal aesthetic
- AI: OpenRouter API + Cohere North Mini Code (chat widget), with persona file loaded from public/
- Analytics: Vercel Analytics + Firebase Analytics (dual tracking)
- 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 (
public/persona.md) is a clean way to separate AI behavior from application code. - Dual analytics (Vercel + Firebase) covers both performance and user behavior without either being sufficient alone.
- 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.
- Dark theme needs softening — pure black
#000and pure green#00ff00are harsh for reading.#0f0f0fbg +#00e676accent is far more comfortable.