# aayushbharti.in

> A portfolio that ships content to humans, crawlers, and LLMs from one source

My portfolio and blog, written once and published as web pages, Markdown, RSS and an llms.txt feed for AI assistants.

- Type: Web App
- Role: Full-stack Developer & Designer
- Tech: nextjs, react, typescript, tailwindcss, postgresql, prisma, better_auth, mdx, zustand, zod, motion, posthog, resend, shadcn-ui
- Published: 2024-07-01
- Updated: 2026-10-04
- Live: https://aayushbharti.in
- URL: https://aayushbharti.in/projects/aayushbharti

**TL;DR:** aayushbharti.in is Aayush Bharti's portfolio and blog, built on the Next.js App Router with React 19, Tailwind CSS, PostgreSQL via Prisma, and better-auth for GitHub and Google sign-in. Every blog post and project in content/ renders as an HTML page and is also served as Markdown at a .md URL and in /llms.txt and /llms-full.txt; posts also go into the RSS feed. The hard parts were stripping JSX without breaking code samples, and keeping a WebGL globe and a shader background from running while off-screen.

## Highlights

- Markdown twins and llms.txt for AI crawlers
- Share image prerendered for every post
- Tailwind classes obfuscated at build

---
## One MDX file, four kinds of reader

The constraint I gave this site: I write one MDX file, and four kinds of reader get a version they can use from the same deploy. A browser gets the rendered page, an assistant that fetches `/blog/some-post.md` gets Markdown, a crawler gets JSON-LD and a sitemap, and a feed reader gets the full post in RSS. Nothing is copied by hand, so nothing drifts.

The rest supports that pipeline or the few interactive pages around it: OAuth and PostgreSQL for the guestbook, Resend for the contact form, PostHog to see what gets read.

---

![aayushbharti.in homepage hero: a pill navigation bar, the headline 'Complex web apps, from pixels to pipelines', a row of tilted photo prints, a hairline keyboard drawing with a Try Keythm callout, and the name Aayush Bharti](/projects/aayushbharti/screen1-f8c42f53.webp)

---

```mermaid
flowchart LR
    subgraph Readers
        B["Browser"]
        A["Assistant<br/>.md or Accept: text/markdown"]
        C["Crawler / feed reader"]
    end
    subgraph Edge["next.config.ts"]
        RW["Rewrites + redirects<br/>no middleware.ts"]
    end
    subgraph Content["Content layer"]
        MDX["content/*.mdx"] --> LIB["lib/content<br/>gray-matter, Zod, cache()"]
    end
    subgraph App["App Router"]
        P["Pages<br/>blog, projects, home"]
        MD["/api/md/*<br/>Markdown twins"]
        F["rss.xml, sitemap,<br/>llms.txt"]
        SA["Server actions<br/>contact, guestbook"]
        AU["/api/auth<br/>better-auth"]
        NP["/api/now-playing"]
    end
    B --> RW
    A --> RW
    C --> RW
    RW --> P
    RW --> MD
    RW --> F
    RW -->|"/ingest proxy"| PH["PostHog"]
    LIB --> P
    LIB --> MD
    LIB --> F
    SA --> DB[("PostgreSQL<br/>Prisma")]
    SA --> RS["Resend"]
    SA --> DC["Discord webhook"]
    AU --> DB
    AU --> OA["Google / GitHub OAuth"]
    NP --> LF["Last.fm"]
```

The content layer is MDX files on disk, parsed with gray-matter, validated by Zod, and wrapped in React `cache()` for per-request deduplication. Blog and project pages come from `generateStaticParams` with `dynamicParams = false`, so an unknown slug is a 404.

The application layer is the Next.js App Router on React 19, and the React Compiler is enabled for production builds. Server actions return a result object with a success flag instead of throwing. There is no `middleware.ts`: social redirects, the `.md` rewrites, and the PostHog proxy all live in `next.config.ts`. Environment variables are validated with `@t3-oss/env-nextjs`, with empty strings treated as undefined, so a missing secret fails the build.

Infrastructure is PostgreSQL through Prisma, better-auth for OAuth, Resend for email, PostHog behind a same-origin `/ingest` proxy, and Discord webhooks on contact and guestbook submissions. The Discord helper never throws, so a webhook timeout can't block a form.

Client state is one Zustand store (the sign-in modal) plus a ⌘K command palette that searches with Fuse.js.

```
app/
  page.tsx: homepage (hero, globe, projects, skills)
  blog/
    page.tsx: listing with tag filter
    [slug]/page.tsx: MDX post
    rss.xml/route.ts: RSS 2.0 feed
  projects/
    page.tsx: projects listing
    [slug]/page.tsx: MDX case study, TL;DR and FAQ
  guestbook/: auth-gated entries
  api/
    auth/[...all]: better-auth catch-all
    md/: Markdown twins (home, about, uses, blog, projects)
    og/: generated OG images
    csp-report/: CSP violation log
  llms.txt/ and llms-full.txt/: LLM indices
  robots.txt/: per-bot crawler rules
content/: blog, projects, experience (*.mdx)
lib/
  content/
    mdx.ts: loaders with gray-matter
    schema.ts: Zod frontmatter schemas
    collections.ts: cached getters
    mdx-to-markdown.ts: JSX to Markdown
    markdown-response.ts: headers for .md twins
  seo/
    json-ld.tsx: schema-dts generators
    metadata.ts: metadata factory
  search-submission/sitemap-diff.ts: IndexNow diff
  analytics/ai-referral.ts: AI referrer detection
  auth.ts, prisma.ts, posthog.ts, rate-limit.ts
actions/
  contact-form-actions.ts: Resend batch send
  guestbook-actions.ts: create, soft delete, webhooks
instrumentation-client.ts: deferred PostHog init
next.config.ts: rewrites, headers, CSP
```

---

## Markdown twins without breaking code samples

Each content page has a Markdown twin, reachable two ways: append `.md` to the URL (`/blog/some-post.md`, or `/index.md` for the homepage), or request the normal URL with `Accept: text/markdown`. Both are `beforeFiles` rewrites onto the same `/api/md/*` handlers; a browser's `Accept: text/html` always gets HTML.

The tricky part is turning MDX into Markdown. `mdxToMarkdown()` strips JSX with regular expressions, which goes wrong fast on a developer blog: a code sample containing `<Suspense />` looks exactly like a component to strip. So the stripper runs only on prose outside fenced code blocks and inline code spans. Components that carry content in their props (Section, Callout, Steps, Files, Checklist, Media) are expanded into real Markdown first, so a `<Section title="...">` becomes a heading instead of vanishing.

Twins are served as `text/markdown` with a canonical `Link` header pointing at the HTML page and `X-Robots-Tag: noindex`, so a twin never competes with its page in search. Twins, `/llms.txt` (an index) and `/llms-full.txt` (every body inline) cache for 1 hour in the browser and 6 at the edge.

The rest of the pipeline:

- Structured data. JSON-LD from typed `schema-dts` generators: Person and WebSite on the root layout, BlogPosting on posts, CreativeWork on projects, BreadcrumbList on nested routes, and FAQPage wherever frontmatter has `faqs`.
- TL;DR and FAQ. A frontmatter `overview` renders as a screen-reader-only TL;DR and appears in the Markdown twin and `/llms-full.txt`. `faqs` render as a numbered FAQ section after the body.
- Metadata. Titles are cut to 60 characters and descriptions to 155 at word boundaries; Twitter cards get 70 and 200.
- RSS. `/blog/rss.xml` carries full post bodies as HTML in `content:encoded`, with author and tag categories.
- Crawlers. `robots.txt` writes an explicit group for each of 16 AI user agents (GPTBot, ClaudeBot, PerplexityBot and others) with a `Content-Signal` line allowing training, search and AI input.
- IndexNow. A postbuild script diffs the new sitemap against the live one and submits only added or `lastmod`-changed URLs, and only on production deploys.
- AI referrals. When a visit lands from one of nine assistant domains (chatgpt.com, perplexity.ai, claude.ai and others), PostHog records one `ai_referral` event with the source and landing path.

---

## Pausing a WebGL globe at 200px off-screen

The expensive parts are a cobe WebGL globe, a shader background, and an autoplaying Embla carousel. None of them run off-screen.

- Globe. `useInView` with a 200px margin. Out of view, the `requestAnimationFrame` loop stops scheduling frames entirely; scrolling back restarts it.
- Shader background. Lazy-loaded with `ssr: false`, rendered after a 400ms delay so text and images paint first, and set to `speed={0}` outside a 200px margin. The shader stays compiled but does no work.
- Testimonial carousel. Autoplay toggles on `useInView` with a `-200px` margin.
- CSS loops. `usePauseOffscreen()` sets a `data-offscreen` attribute 200px outside the viewport, and one global rule pauses every infinite CSS animation under it.

`usePerformanceMode()` checks for 4 or fewer CPU cores, a viewport 768px or narrower, and `prefers-reduced-motion`. When any match, the rotating images and spinning text stop, the GitHub stats card renders without animation, and on mobile the globe drops vertical drag.

Loading is deferred too. PostHog is a 448KB chunk, so `instrumentation-client.ts` imports it on the first click, keypress or `touchend` (scroll is excluded because parsing mid-scroll janks the scroll), with a 15-second fallback for readers who never interact. Decorative client components (globe, shader, connections bento, tech-icon cloud) load through `next/dynamic` with `ssr: false`.

Two findings changed how the page loads:

- `inlineCss` is off. The stylesheet is 304KB raw, and Next emitted it three times per document (once as `<style>`, twice inside the RSC payload). Homepage HTML was 1,347KB raw / 206KB gzip with inlining on, against 406KB / 65KB with it off. Net first load including the external CSS is 112KB against 206KB.
- No Motion entrances above the fold. A Motion `initial` state renders on the server as `opacity: 0`, which keeps the element out of LCP until the bundle hydrates. The hero uses CSS keyframes instead. Next 16's `priority` prop also stopped setting `fetchpriority="high"`, so the hero photo passes it explicitly.

The React Compiler handles memoization in production builds, but 65 manual `useMemo`/`useCallback` calls remain across 23 files, such as the globe's window pointer handlers and the command palette's Fuse.js index.

---

## A guestbook write in one server action

Auth is better-auth with GitHub and Google, a Prisma adapter on PostgreSQL, and one catch-all at `/api/auth/[...all]`; the `nextCookies()` plugin handles App Router cookie edge cases.

A guestbook post: sign in, write a message (Zod, 5 to 100 characters), pass a rate limit of 3 per 5 minutes, then one server action creates the Prisma record, sends a confirmation email through Resend, fires the Discord webhook, and revalidates `/guestbook`. Only the author can delete an entry, with a 5-second undo toast. Deletes are soft (`published: false`), and `onDelete: SetNull` keeps entries if the account is removed.

The contact form follows the same shape: Zod validation, 3 submissions per 15 minutes per IP, then `resend.batch.send()` sends the owner notification (with IP, country and city from Vercel headers) and the sender's thank-you in one API call. The OAuth flow was the easy part; sessions across server components and client hooks took the time.

---

## Trade-offs

| Chose | Over | Why | Cost |
|---|---|---|---|
| MDX files in git | A headless CMS | No API latency, no vendor lock-in, no content/code drift; Zod validates at build | Non-technical editors can't contribute (fine for a personal site) |
| Regex `mdxToMarkdown()` | Compiling MDX to an AST | No MDX compiler in the `.md` routes; one file of string transforms | Every content-bearing component needs its own expansion rule, or its copy disappears from the twin |
| `inlineCss` off | Inlined CSS | Homepage HTML 406KB instead of 1,347KB; cached on every later navigation | One extra same-origin round trip on first load |
| better-auth | NextAuth | Its Prisma adapter worked with my existing schema, and `nextCookies()` covers App Router cookie handling | It has to be listed in `transpilePackages` |
| Result objects from server actions | Thrown errors | Callers choose the UI response; failures are part of the type | Every call site has to handle the failure branch |
| In-memory sliding-window rate limit | A shared store | No extra service | Limits are per server instance and reset on a cold start |

---

## Results

- Homepage HTML dropped from 1,347KB raw / 206KB gzip to 406KB / 65KB by turning `inlineCss` off; first load including CSS went from 206KB to 112KB.
- Routing PostHog through `/ingest` doubled data collection overnight.
- PostHog's 448KB chunk loads on the first interaction (or after 15 seconds), not during page load.
- All 8 published projects and 4 published posts have a Markdown twin and an `/llms-full.txt` entry, and every post has a full-content RSS item, all built from the same files.
- Production deploys ping IndexNow with changed URLs only.

---

## What I'd change

The full Content Security Policy has been report-only since 2026-07-25, on what was meant to be a one-week observation window. Next step: read the `/api/csp-report` logs and promote it to the enforcing header.

The rate limiter is a `Map` in module scope. On serverless that means each instance keeps its own count, so the 3-per-window limit is per instance, not per visitor. A shared store would fix that.

I'd also audit the 65 manual `useMemo`/`useCallback` calls. Some are load-bearing (stable references for window listeners and cobe), but others may duplicate what the compiler already does. The compiler is only enabled for production builds (`reactCompiler: isProd`), so dev runs without it, and the audit has to be checked against a production build.

---

## Frequently asked questions

### What is aayushbharti.in built with?

aayushbharti.in is built with the Next.js App Router, React 19 with the React Compiler, TypeScript and Tailwind CSS, using MDX files for content, PostgreSQL with Prisma for the database, better-auth for GitHub and Google OAuth, Resend for email and PostHog for analytics.

### How does aayushbharti.in publish one MDX file to multiple formats?

Each blog post on aayushbharti.in publishes to four formats: the rendered HTML page, a plain Markdown version at a .md URL, a full-content entry in the RSS 2.0 feed at /blog/rss.xml, and the LLM text indices at /llms.txt and /llms-full.txt. Projects get the first, second and fourth. All of them come from the same MDX files in the same deploy.

### How does aayushbharti.in make its content readable by AI models?

Every content page on aayushbharti.in has a .md counterpart, such as /blog/some-post.md, served through Next.js rewrites to /api/md/* routes that strip JSX and return text/markdown. /llms.txt is a structured index, /llms-full.txt inlines every post and project body, and every page emits JSON-LD built with schema-dts.

### How does aayushbharti.in keep analytics working with ad blockers?

aayushbharti.in routes all PostHog /ingest/* traffic through its own domain with a first-party reverse proxy, so ad blockers that block third-party domains don't drop events. The proxy doubled data collection overnight.

### Why does aayushbharti.in use MDX instead of a headless CMS?

On aayushbharti.in, MDX files on disk mean no API latency, no vendor lock-in and no content/code drift, with git for versioning and Zod for schema validation at build time. The trade-off, that non-technical editors can't contribute, doesn't matter for a personal site.

### How does aayushbharti.in keep its 3D globe and shader animations fast?

Every GPU-heavy component on aayushbharti.in, including the cobe WebGL globe, the shader background and the Embla testimonial carousel, is gated by IntersectionObserver and only animates while visible. A usePerformanceMode() hook also scales back animation-heavy components on devices with 4 or fewer CPU cores, viewports 768px or narrower, or prefers-reduced-motion.
