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.
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-dtsgenerators: Person and WebSite on the root layout, BlogPosting on posts, CreativeWork on projects, BreadcrumbList on nested routes, and FAQPage wherever frontmatter hasfaqs. - TL;DR and FAQ. A frontmatter
overviewrenders as a screen-reader-only TL;DR and appears in the Markdown twin and/llms-full.txt.faqsrender 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.xmlcarries full post bodies as HTML incontent:encoded, with author and tag categories. - Crawlers.
robots.txtwrites an explicit group for each of 16 AI user agents (GPTBot, ClaudeBot, PerplexityBot and others) with aContent-Signalline 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_referralevent 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.
useInViewwith a 200px margin. Out of view, therequestAnimationFrameloop 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 tospeed={0}outside a 200px margin. The shader stays compiled but does no work. - Testimonial carousel. Autoplay toggles on
useInViewwith a-200pxmargin. - CSS loops.
usePauseOffscreen()sets adata-offscreenattribute 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:
inlineCssis 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
initialstate renders on the server asopacity: 0, which keeps the element out of LCP until the bundle hydrates. The hero uses CSS keyframes instead. Next 16'spriorityprop also stopped settingfetchpriority="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
| 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 |
07
Results
- Homepage HTML dropped from 1,347KB raw / 206KB gzip to 406KB / 65KB by turning
inlineCssoff; first load including CSS went from 206KB to 112KB. - Routing PostHog through
/ingestdoubled 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.txtentry, 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.
