An SEO-optimized personal portfolio + MDX blog template — Next.js 16 · React 19 · TypeScript · Tailwind CSS v4
The personal site of Ahnaf An Nafee, plus a fork-ready starting point for developers, researchers, and engineers who want a fast, well-structured portfolio with batteries-included SEO, JSON-LD schema, AI-search optimization (llms.txt), and a content pipeline driven by MDX.
Use this template · Live demo · Research · Blog · Report bug
- Why this repo
- Features
- Tech stack
- Quick start
- Project structure
- Customizing the template
- Authoring content
- Scripts
- Deployment
- Testing & CI
- About the author
- License
Most personal-portfolio templates skimp on SEO, leave structured data half-implemented, and break the moment Google's AI Mode or Perplexity tries to crawl them. This one is the opposite: every page emits a single connected @graph of schema.org entities, the canonical Person is referenced by @id across the site, OG images are normalised to 1200×630, and an llms.txt manifest is regenerated on every build.
Use it as your portfolio. Fork it as a template. Read the source as a reference for what a SEO-complete Next.js 16 site looks like in 2026.
@graphJSON-LD on blog, portfolio, and research detail pages —BlogPosting/SoftwareSourceCode/ScholarlyArticle+WebPage+BreadcrumbList, all interconnected by@id. Research entries also emitPeriodical/CreativeWorkforisPartOf(conditioned on venue status), structuredPersonauthor nodes withaffiliationand ORCIDidentifier, and DOI / arXiv / ResearchGatePropertyValueidentifiers when present.- Canonical
Personentity (/#person) referenced from every author/publisher slot. Full sameAs/knowsAbout/credentials live only on/; everywhere else uses a slim reference. ProfilePageJSON-LD on the home page and/resume, withmainEntitydeduplicated via@id.CollectionPage+ItemListschema on topic archives.FAQPageandHowToMDX components for evergreen posts (<FAQ items={...}/>,<HowTo steps={...}/>).- Open Graph + Twitter cards at 1200×630 with explicit alt text and
image/pngMIME hints. Per-page metadata viagenerateMetadata. - Dynamic OG images at
/api/ogrendered with@vercel/og(terminal-themed, cache-busted by deploy SHA). og:see_alsofor related posts;hreflangalternates;<link rel="me">for IndieAuth/Mastodon identity.llms.txtauto-generated on every build with a per-post manifest (title, summary, topics, dates) so AI search engines see a curated map of the site.- Sitemap with frontmatter-driven
lastmod,<image:image>entries, and explicit AI-crawler allowlists inrobots.txt(GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, Bytespider, CCBot, …). - WebSite
SearchActionwired to/blog?q=(the search box honours the URL param so the schema is truthful).
- MDX for blog, portfolio, and research (no CMS, no database). Frontmatter parsed by
gray-matter. - Academic project pages at
/research/[slug]— title, structured authors + affiliations, venue, action-button row (Paper / Code / Video / arXiv / ResearchGate / Dataset / BibTeX), full-bleed teaser, abstract, MDX body, copy-to-clipboard BibTeX. Listing page groups entries by section (top-tier, conferences, journals, workshops, others). - Reading time + word count via
reading-time, fed into BlogPostingtimeRequired/wordCount. - Mermaid diagrams, KaTeX math, Prism syntax highlighting.
- Custom MDX components:
<TLDR>,<KeyPoints>,<FAQ>,<HowTo>,<ContentImage>(lightbox-enabled),<Mermaid>. - Related / adjacent post navigation at the bottom of every blog post.
- Topic archive pages auto-generated from frontmatter
topics. - RSS at
/rss.xml(summaries) and/rss-full.xml(full content) with<link rel="alternate">discovery in<head>.
- shadcn/ui (radix-nova style) as the single primitive layer. Components live under
src/components/ui/and are managed by the shadcn CLI (npx shadcn@latest add <name>); rerun with--diffto merge upstream updates without losing local edits. - Theme bridge in
src/styles/globals.css@theme inline— shadcn semantic tokens (--primary,--background,--foreground,--muted,--border,--ring) map onto the legacy blue-primary / neutral-theme palette in OKLCH so legacy classes (bg-primary-500,text-theme-700) and shadcn classes (bg-primary,text-foreground) render identically across light/dark. - Site composition components under
src/components/site/(Header, Footer, Nav, Hero, Searchbar, BackToTop, MobileNav, ThemeMenu, link/image wrappers, AppLayoutPage) — bespoke pieces that compose shadcn primitives + project state. - Single primitive ecosystem —
radix-ui(shadcn's base),lucide-react(icons inside shadcn),sonner(toasts),tw-animate-css(animations),class-variance-authority(variants),tailwind-merge(className merging). - Typography — Google Sans loaded from Google Fonts with a system-font fallback chain (
system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, …). Local Inter@font-faceis kept as a deeper fallback so the page never FOUCs.
- Core Web Vitals attribution for CLS, LCP, INP, FCP, TTFB via Vercel Speed Insights.
next/imagewith WebP/AVIF, 5 quality tiers, 1-yearminimumCacheTTL, responsivesizes.- Lazy-loaded Mermaid (~100KB),
react-image-lightbox, Giscus comments (deferred viaIntersectionObserveruntil the section enters the viewport). - Pageviews batched in a single API call instead of N+1 fetches.
- PWA via
@ducanh2912/next-pwa(the maintained next-pwa fork — Workbox 7, Next.js 16-compatible) with offline support; Strict CSP, HSTS, all OWASP-aligned security headers.
- TypeScript strict-mode,
@/*path alias. - ESLint flat config (
eslint.config.mjs, replacing the deprecatednext lint). - Prettier with
@ianvs/prettier-plugin-sort-imports(actively maintained successor to@trivago) +prettier-plugin-tailwindcss. - shadcn CLI for primitive sync —
npx shadcn@latest add,--diff,--dry-run. - Vitest suite (48 tests, ~1.7s) covering schema generators, sorters, content readers. Vite resolves
@/*paths via the nativeresolve.tsconfigPaths: true(no plugin). - JSON-LD validator (
yarn validate:json-ld) walks the build output and verifies every<script type="application/ld+json">block. - Alt-text auditor (
yarn audit:alt-text) flags weakaltattributes in MDX. - GitHub Actions CI: type-check + lint + test + build + JSON-LD validation on every push/PR.
- Yarn 4 via Corepack, with a
vercel.jsoninstallCommand(corepack enable && corepack prepare yarn@4.14.1 --activate && yarn install --immutable) so deploy hosts actually run Yarn 4 and honour the lockfile.
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router) + React 19 |
| Language | TypeScript |
| Styling | Tailwind CSS v4 (with @tailwindcss/typography) |
| Design system | shadcn/ui (radix-nova) on radix-ui |
| Icons | lucide-react (shadcn) + react-icons (legacy site components) |
| Toasts | sonner |
| Fonts | Google Sans (Google Fonts) + Inter (local @font-face fallback) |
| Content | MDX (next-mdx-remote/rsc) + gray-matter |
| Markdown plugins | remark-gfm, remark-math, rehype-prism-plus, rehype-slug, rehype-katex |
| Comments | Giscus (deferred) |
| Diagrams | Mermaid (lazy) |
| Math | KaTeX |
| Image CDN | ImageKit |
| Analytics | Vercel Analytics + Speed Insights |
| Logging | Axiom (next-axiom) |
| Sitemap | next-sitemap (custom config) |
| OG images | @vercel/og |
| PWA | @ducanh2912/next-pwa (Workbox 7) |
| Testing | Vitest + happy-dom + @testing-library/react |
| Lint | ESLint 9 (flat config) + eslint-config-next |
| Format | Prettier 3 + @ianvs/prettier-plugin-sort-imports |
| Package manager | Yarn 4 (Corepack) |
| Hosting | Vercel (primary) + static export for any CDN |
- Node.js 20 or newer (22+ recommended)
- Corepack enabled (
corepack enable) — pulls Yarn 4.14.1 automatically from thepackageManagerfield.
git clone https://github.com/ahnafnafee/ahnafnafee.dev.git
cd ahnafnafee.dev
corepack enable
yarn install
yarn dev # http://localhost:3000Create .env.development.local:
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
# Optional: cache-bust OG images per deploy
# NEXT_PUBLIC_OG_VERSION="dev"For production (Vercel auto-injects VERCEL_GIT_COMMIT_SHA, no extra config needed).
yarn build # Vercel-style build
yarn export # Static export to ./out (no API routes)
yarn start # Serve the production build locallysrc/
├── app/ # Next.js App Router
│ ├── layout.tsx # Root layout, WebSite + Navigation JSON-LD, viewport, themeColor
│ ├── page.tsx # Home (ProfilePage + Person)
│ ├── blog/
│ │ ├── page.tsx # Blog list
│ │ ├── [slug]/page.tsx # Blog detail (BlogPosting @graph)
│ │ └── topics/ # Topic index + per-topic archives
│ ├── portfolio/
│ │ ├── page.tsx # Portfolio list
│ │ └── [slug]/page.tsx # Portfolio detail (SoftwareSourceCode @graph)
│ ├── research/
│ │ ├── page.tsx # Research list (grouped into top-tier / conferences / journals / workshops / others)
│ │ └── [slug]/page.tsx # Research detail (ScholarlyArticle @graph)
│ ├── resume/page.tsx # Resume / ProfilePage
│ ├── api/ # OG image, pageviews, revalidate, content APIs (incl. /api/research)
│ ├── rss.xml/route.ts # RSS summary feed
│ └── rss-full.xml/route.ts # RSS full-content feed
├── components/ # UI + content components
│ ├── ui/ # shadcn/ui primitives (managed by `npx shadcn@latest add`)
│ ├── site/ # Bespoke composition components (Header, Footer, Nav, AppLayoutPage, Hero, Searchbar, BackToTop, …)
│ ├── content/mdx/ # MDX overrides (Pre, Code, ContentImage, TLDR, FAQ, HowTo, …)
│ ├── content/research/ # Research-page sections (HeadingResearch, ResearchOverview, ResearchNews, ResearchAreas, ResearchSections, ComingSoonImage, SectionHeading, …)
│ └── SEO/Breadcrumbs.tsx
├── data/
│ ├── blog/*.mdx # Blog posts (slug = filename)
│ ├── portfolio/*.mdx # Portfolio entries
│ └── research/*.mdx # Research entries (academic project pages)
├── libs/
│ ├── constants/site.ts # SITE_URL, SITE_NAME, PERSON_ID, etc. — single source of truth
│ ├── seo/ # personSchema, faqSchema, howToSchema
│ ├── metapage/ # generateOgImage helper
│ ├── sorters/ # getNewestBlog, getAdjacentPosts, etc.
│ └── intl/ # dateFormat, dateStringToISO
├── services/content/ # MDX readers (getContents, getContentBySlug, getContentHeaders)
└── types/index.d.ts # Frontmatter types for Blog, Portfolio, Research, Snippet
scripts/
├── generate-llms-txt.js # Builds public/llms.txt from MDX frontmatter (prebuild)
├── validate-json-ld.js # Walks .next/out, validates JSON-LD blocks
├── audit-alt-text.js # Reports weak alt attributes in MDX
├── export-prepare.js # Moves /api aside before static export
└── export-cleanup.js # Restores /api after static export
custom-next-sitemap.js # Sitemap + robots.txt generation (frontmatter-driven lastmod)
.github/workflows/ci.yml # Lint + type-check + test + build + JSON-LD validate
src/libs/constants/site.ts:
export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL?.trim() || 'https://your-domain.com'
export const SITE_NAME = 'Your Name'
export const SITE_DESCRIPTION = '…'
export const SITE_AUTHOR = {
name: 'Your Name',
email: 'you@example.com',
twitterHandle: '@your_handle',
githubUsername: 'your-github'
}
export const TWITTER_HANDLE = SITE_AUTHOR.twitterHandle
export const PROFILE_IMAGE = 'https://…/your-avatar.png'
export const PERSON_ID = `${SITE_URL}/#person`src/libs/seo/personSchema.ts — getPersonNode() returns the full Person body emitted on /. Update sameAs, jobTitle, worksFor, alumniOf, knowsAbout, hasCredential to match your background.
src/libs/constants/social.ts — array of { title, href } used by the home page and footer.
custom-next-sitemap.js — the policies array in robotsTxtOptions. Add or remove user-agents here. The committed list explicitly allows GPTBot, Claude-Web, ClaudeBot, anthropic-ai, OAI-SearchBot, Perplexity{Bot,User}, Google-Extended, Applebot-Extended, Bytespider, CCBot, cohere-ai, and meta-externalagent.
- Replace
public/static/avatar.png,public/static/logo.png,public/static/404.svg. - Update
public/manifest.json(name,short_name,theme_color, icons). - Replace ImageKit OG image URLs in page metadata (or repoint them at your own CDN).
- The dynamic OG renderer at
src/app/api/og/route.tsxis style-customisable.
SITE_URLconstant (above).custom-next-sitemap.jsdefaults to the canonical hostname; override at build time withSITE_URL=https://example.com yarn build.next.config.jsimages.remotePatterns— add your domain if you serve images from it.
Add posts to src/data/blog/<slug>.mdx, projects to src/data/portfolio/<slug>.mdx, and academic work to src/data/research/<slug>.mdx. The filename becomes the URL slug. See Authoring content.
.github/workflows/ci.yml runs on every push/PR. No secrets required for the default pipeline.
---
title: 'How I built a privacy-first OCR pipeline'
slug: 'local-llm-pdf-ocr' # optional — filename wins
summary: 'Pairing Surya layout detection with local VLMs and a Needleman-Wunsch aligner.'
featured: true # surfaces on the home page
author_name: 'Ahnaf An Nafee'
github_username: 'ahnafnafee'
published: '04/26/2026' # MM/DD/YYYY
updated: '05/02/2026' # optional, drives dateModified + sitemap lastmod
topics: ['OCR', 'LLM', 'Vision Language Models']
keywords: ['ocr', 'local llm', 'vlm', 'pdf']
related: ['mesh-decimation-benchmark'] # other slugs for "related posts"
thumbnail: 'https://ik.imagekit.io/.../cover.jpg' # optional, OG image fallback
---
## TL;DR
A 60–120 word summary at the top — front-loaded for AI search citations.
…---
title: 'Bookworm'
date: '05/01/2022'
updated: '02/01/2024' # optional
featured: true
category: 'software' # 'software' | 'game'
summary: 'Mobile-first website for tracking books you have read.'
image: 'https://ik.imagekit.io/.../bookworm_og.png'
stack: ['react', 'next.js', 'typescript', 'tailwindcss', 'supabase']
link:
github: 'https://github.com/you/bookworm'
live: 'https://bookworm-app.vercel.app'
------
title: 'Performance Analysis of 3D Mesh Simplification Algorithms'
summary: 'A short blurb for cards / OG / SEO description.'
abstract: >- # YAML folded scalar — single paragraph
The verbatim paper abstract (~150-300 words). Renders in a styled
card above the body on the detail page.
authors:
- name: 'Ahnaf An Nafee'
url: 'https://www.ahnafnafee.dev'
email: 'aannafee@gmu.edu'
affiliations: [1] # 1-based indices into the entry's affiliations array
corresponding: true
# equalContribution: true # OPTIONAL: → † on each flagged author + a "†Equal contribution" caption
# principalInvestigator: true # OPTIONAL: → ‡ on PI + a "‡Principal investigator" caption
affiliations:
- name: 'George Mason University'
location: 'Fairfax, Virginia, USA'
url: 'https://www.gmu.edu'
venue:
name: 'CS700 — Research Methodology in Computer Science, Course Project'
short: 'GMU CS700'
year: 2025
status: 'tech-report' # preprint | under-review | accepted | published | workshop | tech-report
published: '12/08/2025'
featured: true # surfaces on the home page
new: true # renders a "NEW" badge inline with the title on the listing card
comingSoon: true # OPTIONAL: renders a "Coming soon!" pastel placeholder in place of the listing thumbnail (for conditionally accepted / pre-publication entries that don't have a teaser yet)
section: 'others' # top-tier | conferences | journals | workshops | others
topics: ['3D Graphics', 'Mesh Simplification']
keywords: ['mesh decimation', 'QEM', 'vertex clustering']
thumbnail: 'https://ik.imagekit.io/.../mesh-decimation.jpg'
teaser: 'https://raw.githubusercontent.com/.../teaser.png' # high-res hero figure on detail page
teaserCaption: 'Visual comparison of decimation results across CAD and organic meshes.'
links:
paper: 'https://www.researchgate.net/publication/...'
code: 'https://github.com/...'
researchGate: 'https://www.researchgate.net/publication/...'
# arxiv / video / slides / dataset / supplementary / demo / project — all optional
identifiers:
# doi / arxivId / researchGateId — fed into ScholarlyArticle.identifier
researchGateId: '400103838'
bibtex: |
@misc{annafee2025meshsimplification,
author = {Ahnaf {An Nafee}},
title = {…},
year = {2025}
}
---The detail page renders the structured fields in the hero (status chip from venue.status, authors with affiliation superscripts, venue line, action-button row driven by links and bibtex). Authors matching SITE_AUTHOR.name are bolded. The bibtex field renders as a copy-to-clipboard code block anchored at #bibtex.
Author / affiliation superscripts are conditional — each marker only renders when it actually disambiguates something:
Flag on Author |
Glyph | Caption | Renders when |
|---|---|---|---|
corresponding: true |
* |
*Corresponding author | 2+ authors AND ≥1 corresponding |
equalContribution: true |
† |
†Equal contribution | 2+ authors AND ≥2 flagged equal |
principalInvestigator: true |
‡ |
‡Principal investigator | 2+ authors AND ≥1 PI |
affiliations: [n] |
¹ ² ³ |
(legend below) | 2+ affiliations on the entry |
Marker order on each author: *†‡ then numeric affiliation indices (academic convention). Single-author / single-affiliation entries skip everything — no orphan markers. When markers do show, each <sup> carries a native title tooltip (affiliation index sups resolve to the full affiliation name; symbol sups resolve to their caption). A combined caption line — e.g. *Corresponding author · †Equal contribution · ‡Principal investigator — auto-renders below the affiliation row.
The research listing page (/research) renders an Overview paragraph, a date-column News timeline, and a colored chip row of Research Areas above the section-grouped listings. Section headings across the page (Overview / News / Research Areas / Top-Tier Venues / Conferences / Journals / Workshops / Others) share a single <SectionHeading> component (text-lg md:text-xl, font-bold, text-black dark:text-white, bordered bottom).
<TLDR>Three sentences distilling the post — what LLMs cite first.</TLDR>
<KeyPoints items={['First', 'Second', 'Third']} />
<FAQ
items={[
{ q: 'How fast is it?', a: '~30 ms per page on a 4090.' },
{ q: 'Does it run offline?', a: 'Yes — no network calls in the default config.' }
]}
/>
<HowTo
name='Set up local OCR'
totalTime='PT15M'
steps={[
{ name: 'Install dependencies', text: 'pip install surya-ocr olmocr' },
{ name: 'Run the pipeline', text: 'python ocr.py path/to/file.pdf' }
]}
/>
<ContentImage src='https://ik.imagekit.io/.../diagram.png' alt='Architecture diagram' />graph LR
A[OCR] --> B[VLM] --> C[Aligner] --> D[Markdown]
| Command | What it does |
|---|---|
yarn dev |
Dev server with Turbopack |
yarn dev:webpack |
Dev server forced to webpack (fallback for Mermaid hot reload edge cases) |
yarn build |
Production build (also runs prebuild → generate-llms-txt.js and postbuild → sitemap) |
yarn export |
Static export to ./out — temporarily moves src/app/api/ aside so Next can output: 'export' |
yarn export:university |
Static export with a BASE_PATH (used for the GMU university mirror) |
yarn start |
Serve the production build on :5000 |
yarn lint / yarn lint:fix |
ESLint flat config |
yarn type-check |
tsc --noEmit |
yarn test / yarn test:watch / yarn test:coverage |
Vitest |
yarn validate:json-ld |
Walk built HTML, verify every JSON-LD block parses + has @context and @type/@graph |
yarn audit:alt-text |
Report weak alt attributes in MDX |
yarn analyze |
@next/bundle-analyzer build report |
yarn format |
Prettier on **/*.{js,jsx,ts,tsx,md,mdx,json} |
yarn commit |
Commitizen prompt (conventional commits) |
npx tsx indexing/sendIndexingRequest.ts |
Submit URLs to the Google Indexing API (requires indexing/service_account.json) |
Zero-config — Vercel detects Next.js and uses the installCommand from vercel.json (corepack enable && corepack prepare yarn@4.14.1 --activate && yarn install --immutable) so Yarn 4 actually runs (the explicit corepack prepare step is required because corepack enable alone doesn't reliably switch the active yarn binary on Vercel's image — without it, Vercel falls back to Yarn 1.22 and silently rewrites the lockfile). Set NEXT_PUBLIC_SITE_URL to your production domain in the Vercel project settings.
yarn export
# Serve ./out on any static host: GitHub Pages, S3, Netlify, Cloudflare Pages, …The export pipeline (scripts/export-prepare.js) temporarily moves API routes aside because Next.js cannot static-export a tree with handlers. scripts/export-cleanup.js restores them.
/api/revalidate?secret=<SECRET>&slug=/blog/<slug> — call this from a CMS webhook or gh actions to bust ISR for a single page. The secret is NEXT_PUBLIC_SECRET or SECRET_KEY.
yarn test runs the Vitest suite (48 tests across 8 files in ~1.7s):
- SEO library —
personSchema,faqSchema,howToSchema,generateOgImage(env stubbing for cache-busting fallbacks). - Sorters —
getNewestBlog,getAdjacentPosts. - i18n utils —
dateFormat,dateStringToISO. - Content readers (integration) —
getContentsandgetContentBySlugagainst the committed MDX.
CI (.github/workflows/ci.yml) runs on every push and PR:
checkout → corepack enable → setup-node 22 → yarn install --immutable
→ yarn type-check → yarn lint → yarn test → yarn build
→ yarn validate:json-ld → yarn audit:alt-text (warn-only)
Build artifacts are uploaded on failure for debugging.
I'm Ahnaf An Nafee, a PhD student at George Mason University's DCXR Lab (advised by Dr. Craig Yu). My research sits at the intersection of AI and 3D computer graphics — exploring how machine learning can transform how we create and interact with immersive digital worlds. Before grad school, I was the CTO of a tech startup.
- 🌐 Website — ahnafnafee.dev
- 📚 Google Scholar — u15DO0cAAAAJ
- 🔬 ORCID — 0009-0000-9363-4536
- 💼 LinkedIn — in/ahnafnafee
- 🐙 GitHub — @ahnafnafee
- 📧 Email — ahnafnafee@gmail.com
- AI for graphics — generative AI workflows, automated 3D modelling, UV mapping, NPR rendering.
- Immersive tech — human-computer interaction in VR/AR.
- Infrastructure — scalable cloud deployment for high-performance graphics.
- Languages — Python, C++, Go, TypeScript
- Graphics & game dev — Unity, Unreal Engine, OpenGL, GLSL
- AI/ML — PyTorch, TensorFlow, Computer Vision
- DevOps — Kubernetes, AWS, OpenShift, CI/CD
MIT — feel free to fork, customise, and ship your own portfolio. A link back is appreciated but not required.
If this template helps you ship your own portfolio, a star ⭐ goes a long way.