Skip to main content

Building kaushik.cv: the tech stack, choice by choice

Share:XLinkedInHN
Cover for Building kaushik.cv: the tech stack, choice by choice

The goal I set for kaushik.cv was narrow. I wanted to author posts in MDX in a folder, ship the site as one Next.js app on Vercel, have a per-post social card that used the post's own title and summary, and expose real feeds (RSS, Atom, JSON Feed) plus a sitemap that Google would actually consume. No headless CMS. No separate image service. No client-side markdown renderer.

The rest of this post walks the stack in the order the page renders in, from the MDX file on disk to the PNG that Twitter fetches.

The base

package.json pins next at 14.2.4 and react at ^18.3.1. Node engine is >=20. The app is a single Next.js 14 App Router project with output: 'standalone' set in next.config.mjs so the Vercel build produces a self-contained server bundle.

No CMS, no database. The whole "content system" is a content/ folder full of .mdx files. Each file has YAML frontmatter (title, publishedAt, summary, tags, optional image) followed by MDX prose. That's it.

Reading a post from disk

src/data/blog.ts has one function that matters: getPost(slug). It reads content/{slug}.mdx, splits the frontmatter with gray-matter, and runs the body through a unified pipeline:

remark-parse
  -> remark-gfm
  -> remark-rehype (allowDangerousHtml: true)
  -> rehype-raw
  -> rehype-slug
  -> rehype-pretty-code
  -> rehype-stringify

The output is HTML. rehype-pretty-code runs Shiki under the hood with the min-light and min-dark themes, and I turn keepBackground off so the code blocks inherit the site's prose styles. rehype-slug gives every heading an id, which is what the table-of-contents component keys off of. rehype-raw is there because I write raw HTML inside MDX for the few interactive playgrounds and want it to survive the pipeline.

The read time is computed inline as Math.max(1, Math.round(words / 220)). 220 words per minute is on the fast side, but it matches how I actually read.

Rendering a post

src/app/blog/[slug]/page.tsx is a server component. It calls getBlogPosts() for the sibling navigation (prev / next), fetches the specific post with getPost(slug), and drops the HTML string into a <article> via dangerouslySetInnerHTML. That word looks scary but the input is my own MDX, run through my own pipeline. The alternative (compiling MDX to a React component at request time) is heavier and gives me nothing I want.

The interactive bits (an HNSW playground, a CipherStack rotation demo, a latency slider) are wired in as next/dynamic imports with ssr: false. They only load on the post slugs that ask for them, so a plain post like this one costs zero client JS beyond the shared chrome.

Two things the page emits that matter later:

  1. A <link rel="preload" as="image" href={coverPreloadHref} fetchPriority="high" /> for the post's cover image. Next hoists it into <head>, so the browser fetches the LCP image in parallel with HTML parsing. This came out of a Lighthouse audit where the cover image was the LCP element on every post and the preload cut LCP by a few hundred ms.
  2. Two JSON-LD blocks. One is BlogPosting schema with the headline, dates, description, image (pointing at the dynamic OG route), and author. The other is a BreadcrumbList. Google's rich results tester eats both.

The per-post OG image (the interesting part)

This is the piece that took the most thought.

src/app/blog/[slug]/opengraph-image.tsx is Next 14's file-based OG convention. For every blog slug, Next auto-generates a 1200x630 PNG at /blog/{slug}/opengraph-image and auto-wires the URL into openGraph.images and twitter.images for the matching route. I don't have to set the metadata; Next reads the file-system route and connects the two.

The function does four things:

  1. Reads content/{slug}.mdx directly with fs.readFileSync. Uses gray-matter to parse only the frontmatter. It never runs the full MDX-to-HTML pipeline for this route, which would be pointless (the OG image needs the title and summary, not the body) and slow (Shiki has a startup cost).
  2. Reads public/kaushik.png off disk and base64-encodes it into a data URL. Satori (the renderer behind next/og) needs binary-backed images inline; it can't fetch from /kaushik.png at generation time.
  3. Loads Inter (400 and 700) and JetBrains Mono via a helper in src/lib/og-fonts.ts. Satori requires font buffers, not CSS @font-face.
  4. Returns an ImageResponse with a JSX layout: purple gradient background, two ambient radial glows, breadcrumb top-left, big title (auto-shrunk to 48px when the title is longer than 70 characters, 56px otherwise), one-line summary, headshot and byline bottom-left, /blog/{slug} in mono bottom-right.

export const runtime = "nodejs" and export const dynamic = "force-static" mean the images are generated at build time, not per-request. generateStaticParams returns the local slugs (external posts keep their own source image), and the resulting PNGs go into the build output like any other static asset.

The reason this works in production, and not only in dev, is one line in next.config.mjs:

experimental: {
  outputFileTracingIncludes: {
    '/blog/[slug]/opengraph-image': ['./content/**/*', './public/kaushik.png'],
    // ...
  },
}

Vercel's file tracer statically analyzes each serverless function to figure out which files to bundle. It cannot see through readFileSync(join(process.cwd(), 'content', ...)), so without an explicit include, the deployed function throws ENOENT when it tries to open the MDX file. I hit this exact failure the first time I shipped the OG route. The outputFileTracingIncludes hint tells the tracer: "these routes also need these paths."

There is a fifth trick worth calling out. Satori has no line-clamp, so long titles wrap into ugly shapes. I clamp titles to 120 characters and summaries to 180 characters with a one-line clamp() helper that appends an ellipsis. It's not clever. It doesn't need to be.

Feeds: three formats, three route handlers

I generate RSS 2.0, Atom, and JSON Feed 1.1 side by side. Each is a Next.js Route Handler in a folder named after its filename: src/app/feed.xml/route.ts, src/app/atom.xml/route.ts, src/app/feed.json/route.ts. Each GET calls getBlogPosts() (which reads local MDX and external cross-posts, dedups by title key, and sorts by date desc), templates the appropriate format, and returns a Response with Cache-Control: public, max-age=3600, s-maxage=3600.

Two decisions inside feed.xml/route.ts were not obvious:

The feed declares a stylesheet via <?xml-stylesheet type="text/xsl" href="/feed.xsl"?>. That means when a human opens the URL in a browser, they see a rendered page instead of raw XML. public/feed.xsl is the transform.

The Content-Type is application/xml, not application/rss+xml. Chromium refuses to apply an XSLT stylesheet when the response is application/rss+xml with X-Content-Type-Options: nosniff (which the site sets globally in next.config.mjs). Feed readers detect RSS from the <rss> root element, not the MIME. So the browser gets a pretty page, and Feedly still sees a valid feed.

The JSON Feed route is the simplest of the three: it builds a plain object and JSON.stringifys it with application/feed+json. The Atom route computes <updated> as the max publishedAt across all posts, which is what the spec asks for.

Sitemap

src/app/sitemap.xml/route.ts is another Route Handler with export const revalidate = 3600. It builds a urlset with entries for /, /blog, /uses, /now, every local blog post, and every tag page. Each blog entry carries an <image:image> child pointing at that post's OG image route:

<image:image>
  <image:loc>https://www.kaushik.cv/blog/{slug}/opengraph-image</image:loc>
</image:image>

That's what tells Google Image Search which visual to associate with which post. Without it, Search Console shows the posts indexed but no image thumbnail.

Analytics and headers

@vercel/analytics and @vercel/speed-insights are wired in the root layout. They give me real-user Web Vitals per route, which is how I catch regressions faster than a synthetic Lighthouse run would.

next.config.mjs sets a batch of security headers on every response. Two of them are load-bearing:

  • X-CF-Rocket-Loader: off disables Cloudflare's Rocket Loader on the origin response. Rocket Loader wraps every <script> in a loader that adds around a second of bootup time. Not every Cloudflare plan honors the header, so I also have a Page Rule set to "Rocket Loader: Off" for kaushik.cv/*.
  • A report-only CSP. It doesn't block anything; browsers just log violations to /api/csp-report. This lets me tune the policy from real reports before flipping it to enforce.

What I'd change at 10x scale

The MDX pipeline is HTML-in, HTML-out. Every request for a blog page reads the file off disk and runs it through Shiki. Next caches this within a single build, so it isn't a hot-path cost, but at hundreds of posts the build time starts to add up (Shiki dominates). At 10x the current post count I'd move the MDX-to-HTML step into a contentlayer-style build script that emits a .json per post at next build and skip the runtime pipeline entirely. Same input, same output, just precomputed. Everything else on the page (the OG route, the feeds, the sitemap) is already static-generated and would carry over unchanged.

Cite as: Saravanan, K. (2026). Building kaushik.cv: the tech stack, choice by choice. Kaushik Saravanan. https://www.kaushik.cv/blog/building-kaushik-cv-tech-stack