Skip to Content
Architecture

Architecture

Folder map

Everything lives under src/.

FolderResponsibility
app/App Router entry points and the HTML shell: layout.tsx (fonts, metadata, global styles), page.tsx (landing), and one folder per route (about, experience/[slug], skills/[skill], resume, api/resume-request). Also robots.ts, sitemap.ts and opengraph-image.tsx.
sections/Page-level narrative blocks of the landing page: Hero, About, Skills and Experience.
containers/Layout and behavior wrappers, such as the header, the footer and the floating navigation container.
partials/Cross-cutting layout primitives: Container, PageShell, HashScroll and ResumeDownload.
components/Reusable UI pieces: Button, Text, Cursor, FloatingNav and a few visual helpers.
content/The site’s content as data: experience.ts, skills.ts, about.ts, gallery.ts, links.ts and resume.ts.
config/Typed environment schemas (env/), navigation section IDs (navigation/), i18n resources (localization/), design tokens (styles/), site facts (site.ts) and per-page metadata (seo.ts).
lib/Logic that does not belong to a component. Today that is resume-request/, the request handling behind the resume download dialog.
modules/A small barrel of shared helpers, for example classNames.

How a page is composed

Content is data and pages are thin. A secondary page such as /experience/[slug] follows one pattern:

  1. The route file in src/app/ reads a content module from src/content/.
  2. It wraps everything in PageShell from src/partials/PageShell, which supplies the back link and the shared spacing.
  3. It composes the shell’s building blocks: PageHeader, PageBody, MetaList, PageSection, PageHighlights and PageAdjacentNav.

Dynamic routes only exist for slugs present in the content module. They set dynamicParams = false and build their paths with generateStaticParams, so an unknown slug is a real 404.

Anchors on the landing page and the floating navigation share section IDs from src/config/navigation/nav-sections.ts, so links and content cannot drift apart.

The resume request flow

The /resume page is built from the same content modules as the rest of the site. Its Download PDF button opens a full-page dialog (src/partials/ResumeDownload) that asks for an optional name and email.

Download PDF -> dialog opens (native <dialog>, focus trapped) |-- Download / Skip and download -> the browser downloads /resume.pdf immediately '-- if an email was entered -> POST /api/resume-request in the background

The route handler at src/app/api/resume-request/route.ts is a thin wrapper around handleResumeRequest in src/lib/resume-request/handle.ts. That function:

  • applies a best-effort per-IP rate limit,
  • validates the input with zod (schema.ts),
  • verifies the Cloudflare Turnstile token,
  • sends an owner notification through Resend, and, when an email address was given, a copy of the PDF to the visitor.

Nothing is stored, and a failure to send never affects the download: the handler returns 204 either way. All the keys are optional, so without them the page and the download still work and no email is sent.

Environment variables

Typed schemas live in src/config/env/client.ts and src/config/env/server.ts and use @t3-oss/env-nextjs. Setting SKIP_ENV_VALIDATION skips validation, which is handy for CI builds. NEXT_PUBLIC_APP_ENV=staging makes a deployment serve noindex metadata and a disallow-all robots.txt.

Last updated on