Portfolio Site Rebuild — Part 3: Features, Polish & Deployment
by Frank Doka
Portfolio Site Rebuild — Part 3: Features, Polish & Deployment
With the core built, the final phase was the features layer — search, content navigation, social sharing, analytics — plus content migration, accessibility hardening, SEO, and shipping to production.
Pagefind Search
Static sites don't have a search backend, but Pagefind solves this cleanly. It runs at build time, indexes every page into a compact static index, and ships a tiny JavaScript client that searches entirely in the browser — no server, no API calls.
The build script chains it after next build:
next build && npx pagefind --site outSince the site is a static export, next build writes the finished HTML to out/, and Pagefind indexes that directory directly — the search index lands in out/pagefind/ and ships alongside the rest of the static assets.
On the frontend, a search dialog opens with Ctrl/Cmd+K (or clicking the search button in the header). It loads the Pagefind library lazily on first open, debounces input at 200ms, and renders results with titles, excerpts, and links:
function SearchDialog({ open, onClose }) {
const pagefindRef = useRef(null)
useEffect(() => {
if (open && !pagefindRef.current) {
import('/pagefind/pagefind.js').then((pf) => {
pf.init()
pagefindRef.current = pf
})
}
}, [open])
async function handleSearch(query: string) {
const search = await pagefindRef.current.search(query)
const results = await Promise.all(search.results.map((r) => r.data()))
setResults(results)
}
// ...
}Adding data-pagefind-body to content wrappers ensures only article content gets indexed — no navigation, footer, or layout chrome cluttering the results.
The search index adds about 30KB to the build. For a site with 50+ pages, that's a good trade-off — users can find any content instantly without leaving the page. The 404 page also includes an inline search bar so users who land on a dead link can find what they were looking for without navigating away.
Table of Contents
Blog posts with 3 or more headings get an auto-generated table of contents in the sidebar. The component scans the DOM for h2 and h3 elements with IDs (generated by rehypeSlug), renders them as a sticky nav list, and highlights the active section using IntersectionObserver:
function TableOfContents() {
const [headings, setHeadings] = useState([])
const [activeId, setActiveId] = useState('')
useEffect(() => {
const elements = document.querySelectorAll('article h2[id], article h3[id]')
// Build heading list, set up IntersectionObserver for active tracking
const observer = new IntersectionObserver((entries) => {
const visible = entries.find((e) => e.isIntersecting)
if (visible) setActiveId(visible.target.id)
}, { rootMargin: '-80px 0px -60% 0px' })
elements.forEach((el) => observer.observe(el))
return () => observer.disconnect()
}, [])
if (headings.length < 3) return null
// Render sticky sidebar with heading links
}The TOC is hidden below the xl breakpoint to keep mobile layouts clean. H3 headings are indented under their parent H2 for visual hierarchy.
Reading Time
Each blog post shows an estimated reading time in the header. The calculation happens at build time — read the raw MDX file, count words, divide by 220 WPM:
export function getReadingTime(slug: string): number {
const filePath = path.join(process.cwd(), 'src/app/blog', slug, 'page.mdx')
const content = fs.readFileSync(filePath, 'utf-8')
const words = content.split(/\s+/).filter(Boolean).length
return Math.max(1, Math.round(words / 220))
}No client-side computation, no runtime overhead — just a number baked into the static HTML.
Dynamic OG Images
When someone shares a link on Twitter, LinkedIn, or Slack, it shows a rich preview card. Instead of creating these images manually for every page, next/og generates them at build time using JSX-to-image rendering:
export default async function Image({ params }) {
const posts = await loadPosts()
const post = posts.find((p) => p.href === `/blog/${params.slug}`)
return new ImageResponse(
<div style={{ /* dark gradient background, 1200x630 */ }}>
<div style={{ fontSize: 48, fontWeight: 700, color: 'white' }}>
{post.title}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{post.tags?.map((tag) => (
<span style={{ /* teal pill badge */ }}>{tag}</span>
))}
</div>
<div style={{ color: '#94a3b8' }}>frankdoka.com</div>
</div>
)
}Blog posts get a teal "Build Log" label with tag pills. Projects get an orange "Project" label with a green status badge for active projects. Every page gets a unique, on-brand social card without any manual image editing.
RSS Feed
An RSS 2.0 feed at /feed.xml serves all blog posts to feed readers. It's a Next.js route handler marked force-static, so it's rendered once at build time and emitted as a plain file in the static export:
export const dynamic = 'force-static'
export async function GET() {
const posts = (await loadPosts()).sort(/* by date descending */)
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Frank Doka - Build Log</title>
${posts.map((post) => `
<item>
<title>${escapeXml(post.title)}</title>
<link>https://frankdoka.com${post.href}</link>
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
<description>${escapeXml(post.description)}</description>
</item>
`).join('')}
</channel>
</rss>`
return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml' } })
}The feed includes full post content via content:encoded, not just descriptions — so readers can consume the entire article without leaving their feed reader. The <link rel="alternate" type="application/rss+xml"> tag in the root layout lets browsers auto-discover the feed.
View Transitions
The View Transitions API is a native browser feature that animates between page navigations. Next.js 15 supports it as an experimental flag:
// next.config.mjs
experimental: { viewTransition: true }The CSS is minimal — a fade-out on the old page and a fade-in on the new page, with prefers-reduced-motion respected:
@view-transition { navigation: auto; }
::view-transition-old(root) { animation: fade-out 150ms ease-in; }
::view-transition-new(root) { animation: fade-in 200ms ease-out; }
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) { animation: none; }
}No JavaScript animation library needed for page transitions. The browser handles everything natively.
Dark / Light Theme
The site supports a full dark/light mode toggle. Tailwind CSS v4 uses LightningCSS under the hood, which inlines var() references at build time — so traditional CSS-variable-only theming doesn't work at runtime. The solution is a hybrid approach:
CSS variables define all theme tokens — backgrounds, text colors, borders, card styles — with dark defaults and a [data-theme="light"] override block:
:root {
--theme-bg-page: #0a0a0a;
--theme-text-primary: #ffffff;
/* ... */
}
:root[data-theme="light"] {
--theme-bg-page: #f8f8f8;
--theme-text-primary: #1a1a1a;
/* ... */
}JavaScript-driven application — A ThemeProvider context reads the stored preference, sets data-theme on <html>, and applies inline styles to body and themed containers. This ensures the computed styles update at runtime even after LightningCSS has inlined the build-time values.
No flash of wrong theme — An inline <script> in <head> reads localStorage before React hydrates, setting the data-theme attribute and scheduling body styles for DOMContentLoaded. The user never sees the wrong theme, even on first paint.
Components use theme variables throughout: text-[var(--theme-text-primary)], bg-[var(--theme-bg-surface)], and a reusable .theme-card CSS class with hover states.
Copy-to-Clipboard Code Blocks
Every code block across the site has a hover-reveal copy button. A CodeBlock wrapper component replaces the default <pre> via MDXComponents:
export function CodeBlock({ children, ...props }) {
const preRef = useRef(null)
const [copied, setCopied] = useState(false)
async function handleCopy() {
const code = preRef.current?.textContent ?? ''
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="group relative">
<pre ref={preRef} {...props}>{children}</pre>
<button onClick={handleCopy} className="opacity-0 group-hover:opacity-100">
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
</div>
)
}MDX Callout Components
Reusable callout boxes for blog content — info, warning, danger, and tip variants. Each has a distinct border color, icon, and title:
<Callout type="tip" title="Pro tip">
Use `queueMicrotask` instead of direct `setState` in React 19 effects.
</Callout>PWA & Offline Support
A service worker at /sw.js provides offline capability and faster repeat visits:
- Cache-first for static assets (JS, CSS, images, fonts) — once cached, loads are instant
- Network-first for navigation — always tries for fresh content, falls back to cache if offline
- Precaching of core routes (homepage, about, blog, projects) on install
The web app manifest enables "Add to Home Screen" on mobile devices with standalone display mode.
Security Headers
A static export has no Node server to attach response headers, and Next's headers() config only applies when a server is in front of the app. On Cloudflare Pages the answer is a _headers file in public/ (which gets copied into the build output), where each rule is a path pattern followed by indented header lines:
/*
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' ...
Cloudflare applies these at the edge on every response. They prevent XSS, clickjacking, and unwanted API access without any external dependencies.
Content Migration
Projects
Each project got its own directory under src/app/projects/ with an MDX page and a custom SVG architecture diagram. The SVGs are hand-crafted — they look clean at any resolution and stay under 2KB each.
Blog Posts
Multi-part build log series for each project, documenting the process step by step. The BlogPost base object provides author info, so each post only declares its unique fields: title, date, icon, description, and tags.
Experience
Work experience lives in structured TypeScript data (src/data/experience.ts) rather than MDX. Each job has a slug, company, role, dates, description, and highlight items. Detail pages at /experience/[slug] render highlights with a left-border accent layout.
Accessibility
Tested with keyboard-only navigation and fixed several gaps:
Skip-to-main link — Visually hidden, appears on focus. Keyboard users jump straight to content:
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:m-4
focus:rounded-lg focus:bg-[var(--theme-text-primary)] focus:px-4
focus:py-2 focus:text-sm focus:font-semibold focus:text-[var(--theme-bg-page)]"
>
Skip to main content
</a>Focus-visible outlines — Every button, link, and social icon got focus-visible:outline styles. Keyboard focus is always visible without affecting mouse users.
External links — All external links got target="_blank" with rel="noopener noreferrer" for security.
Semantic HTML — <nav>, <main>, <article>, and <section> elements throughout so screen readers can navigate by landmark.
Search accessibility — The search dialog traps focus, supports ESC to close, and announces results to screen readers.
SEO
Structured Data
JSON-LD on the homepage for Google's knowledge panel:
const personSchema = {
'@context': 'https://schema.org',
'@type': 'Person',
name: 'Frank Doka',
jobTitle: 'Infrastructure Architect',
url: 'https://frankdoka.com',
sameAs: ['https://github.com/FrankDoka', 'https://www.linkedin.com/in/frank-doka-64951828b/']
}Dynamic Sitemap
A sitemap.ts file crawls all blog posts, projects, and experience pages at build time and generates a complete sitemap.xml with priorities. The lastModified dates use actual file modification times from the filesystem rather than hardcoded dates, so search engines always see accurate timestamps.
Page Metadata
Every page exports unique title and description metadata. The homepage uses an absolute title; inner pages use a template that appends the site name.
Analytics
Cloudflare Web Analytics tracks page views without cookies or personal data collection. It's a single script tag, conditionally loaded based on an environment variable:
{process.env.NEXT_PUBLIC_CF_ANALYTICS_TOKEN && (
<script
defer
src="https://static.cloudflareinsights.com/beacon.min.js"
data-cf-beacon={`{"token":"${process.env.NEXT_PUBLIC_CF_ANALYTICS_TOKEN}"}`}
/>
)}A dns-prefetch and preconnect hint for static.cloudflareinsights.com in <head> warms up the connection before the analytics script loads, shaving off connection setup time. No cookie banners needed. The token stays in environment config, not in source code.
Performance
FontAwesome Tree Shaking
The optimizePackageImports config in next.config.mjs tells the bundler to tree-shake at build time — only icons actually referenced in code make it into the bundle.
Static Generation
The entire site pre-renders at build time — 60+ static HTML pages deployed to the CDN edge. No server-side rendering, no client-side data fetching for content. Time to first byte is just CDN latency.
Image Strategy
- Project illustrations are SVGs — infinitely scalable, tiny file sizes
- Portrait photo uses Next.js
<Image>with responsivesizesandplaceholder="blur"for instant low-res previews while the full image loads. A static export has no image-optimization server, soimages.unoptimizedis set and the source images are pre-sized rather than transcoded on the fly - Below-the-fold images use
loading="lazy" @next/bundle-analyzeris available as a dev dependency — runANALYZE=true npm run buildto visualize the bundle and catch bloat early
Cloudflare Pages Deployment
Setup Steps
- Connect repo — Linked the GitHub repository to a new Cloudflare Pages project
- Static export — Set
output: 'export'innext.config.mjs. The whole site is content-driven (MDX) and needs no server runtime, so it builds to a folder of plain HTML/CSS/JS that Pages serves directly — no adapter, no Functions, nothing to break at runtime - Build config — Build command:
npm run build, output directory:out(set viapages_build_output_dirinwrangler.jsonc) - Custom domain — DNS was already on Cloudflare, so adding
frankdoka.comwas a single click. SSL provisioned automatically - Environment — Set
NODE_VERSION=24andNEXT_PUBLIC_CF_ANALYTICS_TOKENin build settings
CI/CD Flow
git push main → Cloudflare detects push → Build + Pagefind index → Deploy to edge
Pull request → Preview deployment at unique URL → Verify before merge
Every push to main deploys to production. Pull requests get isolated preview URLs for verification.
Testing
A Vitest suite covers the things most likely to silently break: component rendering (unit tests with Testing Library) and page-level composition (integration tests). Static assets like next/image, next/link, and Framer Motion are mocked so tests stay fast and deterministic. A dedicated mdx.test.ts exercises the loader and Zod validation so a malformed metadata export fails the test run, not production. The GitHub Actions workflow runs the suite and a full production build on every pull request.
SEO: Tag Page Noindex
Tag pages (/blog/tags/[tag]) are thin content — just a filtered list of posts. Having dozens of near-identical tag pages indexed dilutes the site's search presence. Adding robots: { index: false, follow: true } to the tag page metadata tells search engines to skip indexing these pages while still following the links to the actual blog posts:
export async function generateMetadata({ params }) {
return {
title: `${display} — Blog`,
robots: { index: false, follow: true },
}
}Results
| Metric | Before (S3/CloudFront) | After (Next.js/CF Pages) |
|---|---|---|
| Pages | 1 (static HTML) | 65+ (pre-rendered) |
| Content system | None | MDX with syntax highlighting |
| Search | None | Pagefind full-text search |
| Theme | Dark only | Dark/light toggle with persistence |
| Social previews | None | Dynamic OG images per page |
| Feed | None | RSS 2.0 with full content |
| Offline | None | Service worker PWA |
| Security | None | CSP + security headers |
| Validation | None | Zod schema checking |
| Deploy process | Terraform + GH Actions | Git push (auto-deploy) |
| Monthly cost | ~$0 (free tier) | $0 (free tier) |
| Image loading | None | Blur placeholders + responsive sizes |
| Bundle analysis | None | @next/bundle-analyzer |
| 404 recovery | Dead end | Inline Pagefind search |
| Testing | None | Vitest unit + integration suite |
| Add new content | Edit HTML | Drop in MDX file |
The site is live at frankdoka.com. Source is on GitHub.