Skip to content

aayushbharti.in

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

Built

Q3 2024

Updated

Q4 2026

Tech Stack

Next.js
React
TypeScript
Tailwind CSS
PostgreSQL
Prisma ORM
Better Auth
MDX
Zustand
Zod
Motion.dev
PostHog
Resend
Shadcn UI

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.

01

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.

02

Three layers and no middleware

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.

page.tsx: homepage (hero, globe, projects, skills)

guestbook/: auth-gated entries

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)

instrumentation-client.ts: deferred PostHog init
next.config.ts: rewrites, headers, CSP

03

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.

04

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.

05

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.

06

Trade-offs

ChoseOverWhyCost
MDX files in gitA headless CMSNo API latency, no vendor lock-in, no content/code drift; Zod validates at buildNon-technical editors can't contribute (fine for a personal site)
Regex mdxToMarkdown()Compiling MDX to an ASTNo MDX compiler in the .md routes; one file of string transformsEvery content-bearing component needs its own expansion rule, or its copy disappears from the twin
inlineCss offInlined CSSHomepage HTML 406KB instead of 1,347KB; cached on every later navigationOne extra same-origin round trip on first load
better-authNextAuthIts Prisma adapter worked with my existing schema, and nextCookies() covers App Router cookie handlingIt has to be listed in transpilePackages
Result objects from server actionsThrown errorsCallers choose the UI response; failures are part of the typeEvery call site has to handle the failure branch
In-memory sliding-window rate limitA shared storeNo extra serviceLimits are per server instance and reset on a cold start

07

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.

08

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.

09

FAQ

Q.01What 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.

Q.02How 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.

Q.03How 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.

Q.04How 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.

Q.05Why 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.

Q.06How 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.