DEV Community

Cover image for A Next.js 16 portfolio you edit in one file, with accessibility and video behaviour under test
Mahmoud Farouk
Mahmoud Farouk

Posted on

A Next.js 16 portfolio you edit in one file, with accessibility and video behaviour under test

A developer portfolio is a small project that tends to rot. The copy lives in ten components, the video autoplays on a phone with data saver on, the contact form validates in two places that slowly disagree, and nobody has run an accessibility check since launch.

I built a portfolio template (Next.js 16 App Router, React 19, Tailwind CSS v4, TypeScript) to avoid exactly that. This post is about the five design decisions that made it easy to keep correct, with the real code behind each.

The portfolio template: hero section with headline, call to action and portrait placeholder

1. All content in one typed file

Every word, link, project and SEO value lives in lib/content.ts. Components import from it and contain no copy. To personalise the site you edit that one file.

The point is not only convenience. A single typed source of truth can be tested. The unit tests check things a reviewer would otherwise check by eye:

  • there are exactly three services, and each one is selectable in the contact form;
  • the navigation links match the section ids the page uses;
  • every image path in the content points at a file that really exists in public/.
const publicFile = (p: string) => path.join(process.cwd(), "public", p);

// ...inside a test
expect(existsSync(publicFile(file)), file).toBe(true);
Enter fullscreen mode Exit fullscreen mode

A broken image path fails a unit test instead of showing a broken image in production.

2. A demo video that costs nothing until you need it

Demo videos are heavy, and autoplaying them is rude. The ScrollVideo component is a small state machine documented in its own comment:

  1. Before scroll: poster only, no source, nothing downloaded.
  2. Within 200px of the viewport: the sources are attached.
  3. At least 55% visible: plays muted and looped. Below that: pauses.

It uses two IntersectionObservers, one with a rootMargin to attach sources early and one with thresholds to start and stop playback:

useEffect(() => {
  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const connection = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection;
  autoplayAllowed.current = !reducedMotion && !connection?.saveData;

  const nearViewport = new IntersectionObserver(
    (entries) => { if (entries.some((e) => e.isIntersecting)) { setAttached(true); nearViewport.disconnect(); } },
    { rootMargin: "200px 0px" },
  );
  // a second observer with threshold [0, 0.55, 1] starts and pauses playback
}, [play]);
Enter fullscreen mode Exit fullscreen mode

Three details I care about:

  • People who asked for less motion get less motion. With prefers-reduced-motion or data saver on, nothing autoplays. The poster stays and an explicit play button is available.
  • There is always a visible pause button (WCAG 2.2.2), with the project name in screen-reader-only text, and a manual pause is remembered so scrolling does not restart it.
  • The state is observable. The wrapper exposes data-state="idle | playing | paused", which makes the behaviour testable instead of a matter of faith.

3. One validation function for the browser and the server

The contact form calls validateContact in the browser, and the API route calls the same function again on the server. There is no second set of rules to drift out of sync.

/** Validates the contact payload. `company` is a honeypot: humans never see it. */
export function validateContact(input: unknown): ContactResult {
  // ...
  if (text(raw.company) !== "") return { ok: false, errors: {}, spam: true };
  // name, email pattern, service in the allowed list, message length 40..4000
}
Enter fullscreen mode Exit fullscreen mode

The route treats the honeypot differently from a normal error:

// Pretend success to bots so they do not learn the honeypot exists.
if (result.spam) return NextResponse.json({ ok: true });
Enter fullscreen mode Exit fullscreen mode

Delivery goes through Resend when three environment variables are set. When they are not, the route answers 503 with a clear message and the UI falls back to a direct email link, so a half-configured deployment degrades into something usable instead of a dead form.

4. Analytics events defined in one place

Custom events are a classic source of drift: cta_click in one file, ctaClick in another. Here every event is a member of one union type:

export type AnalyticsEvent =
  | { name: "cta_click"; props: { id: "start_project" | "resume"; location: string } }
  | { name: "service_interest"; props: { service: string } }
  | { name: "video_play"; props: { project: string; trigger: "scroll" | "button" } }
  | { name: "form_submit"; props: { result: "success" | "error" | "invalid" } }
  | { name: "outbound_click"; props: { target: OutboundTarget } };

export function trackEvent(event: AnalyticsEvent) {
  try { track(event.name, event.props); } catch { /* Analytics must never break the page. */ }
}
Enter fullscreen mode Exit fullscreen mode

TypeScript now rejects a misspelled event name or a missing property, and the try/catch means an analytics failure can never break a page.

5. Accessibility is a test, not a hope

The end-to-end suite runs axe against the page, including WCAG 2.2 AA rules, after scrolling through the whole page so lazy content is present, and again with the mobile menu open. Serious and critical violations fail the build:

const results = await new AxeBuilder({ page })
  .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
  .analyze();
expect(blocking(results.violations).map((v) => `${v.id}: ...`)).toEqual([]);
Enter fullscreen mode Exit fullscreen mode

Two further checks are plain DOM assertions: every image has an alt attribute and every form control has a label. A separate reduced-motion test group checks the video behaviour from section 2, and CI runs lint, typecheck, unit tests and the browser suite on every pull request and on pushes to main.

Automated tools find only part of the accessibility problems, so this is a floor and not a certificate. But a floor that runs on every commit beats a manual audit that happened once.

What I would tell my past self

  • Put the content in one typed file and write tests against it.
  • Treat video as a progressive enhancement with a visible off switch.
  • Share validation between client and server, and give spam a different path.
  • Define analytics events as a union type.
  • Run axe in CI from day one.

Talk to me

I build full-stack products and take freelance projects: mahmoud-farouk-portfolio.vercel.app.

What would you add to the checklist for a portfolio that has to stay correct for years?

Top comments (0)