AO·dev
portfolioopen-sourcePersonallive
View as Markdown (opens in new tab)

ao.dev

The portfolio you're browsing right now — a reference boilerplate for the Nuxt 4 ecosystem, SSG-first, five design morphisms switchable in real time, full i18n, and a performance/accessibility audit documented step by step.

Company

Personal

Period

2026 – present

Role

Creator, Developer

This is the only case study in this portfolio that describes the portfolio itself. ao.dev was born with a dual intent: be my professional showcase and, at the same time, serve as a reference boilerplate for the Nuxt 4 ecosystem — a real project, shipped and never abandoned halfway, that anyone can read end to end and learn something genuine about SSG, i18n, swappable design systems, and performance auditing taken seriously.

Every visual component here comes from fui-content, the theming library I built in parallel. Every case study you've already read on this site — Leve Mix, Grupo Clamed, the Grupo 3C modules — was written, reviewed, and published through the exact same Nuxt Content this project uses. It's the project that closes the loop: the tool and the showcase for the tool are the same thing.

Under the hood

LayerTechnology
FrameworkNuxt 4 (compatibilityVersion: 4, app/ directory)
LanguageTypeScript
Content@nuxt/content — Markdown with Zod-typed frontmatter
i18n@nuxtjs/i18npt-br (default) and en-us, prefix_except_default strategy
SEO@nuxtjs/seo — schema.org, OG image, sitemap, robots
PWA@vite-pwa/nuxt
StatePinia
Stylingfui-content — SCSS with 5 swappable morphisms
Dev infraDocker + Traefik, local HTTPS via mkcert
AI discoverabilitynuxt-llms/llms.txt generated from real content

The project runs on nuxt generate (SSG) — every page's HTML is generated exactly once, at build time. That architectural decision, made early on, is the root of a good chunk of the project's most interesting technical decisions: anything that would normally be a "runtime decision" (breakpoint, theme, locale) has to be resolved without a server re-rendering behind it on every request.

i18n — two fields, two formats, one convention

The easiest thing to get wrong in multi-locale i18n is conflating two concepts that look like the same thing: the URL prefix and the HTML language tag. The project separates them explicitly:

FieldValueResponsibility
codept-br / en-usURL prefix — always lowercase (HTTP convention, RFC 3986)
languagept-BR / en-USBCP 47 — HTML lang, hreflang, meta tags
filept-BR.json / en-US.jsonFilesystem path — not a URL, stays uppercase

The rule exists because nuxt-link-checker (part of @nuxtjs/seo) reports no-uppercase-chars for any path containing an uppercase letter — switching code to lowercase kills the warning at the root, no skipInspections hack needed.

Collection names — derived algorithmically, never by ternary

With four collections per content type (blog_ptBR, blog_enUS, projects_ptBR, projects_enUS...), the naive pattern — locale.value === 'en-us' ? queryCollection('blog_enUS') : queryCollection('blog_ptBR') — doesn't scale: add a third language and it becomes an if/else chain scattered across dozens of files. The fix was a composable that derives the suffix algorithmically from code:

// app/composables/useLocaleCollection.ts
// pt-br → ptBR · en-us → enUS · es-mx → esMX
// (split on '-', parts after the hyphen become UPPERCASE, join with no separator)

const { blogCol, pathPrefix } = useLocaleCollection()
queryCollection(blogCol.value).all()

Adding a fifth locale becomes: extend a type union in a single file, define the collections in content.config.ts, create the content folder. Zero changes to listing composables or pages — no new ternary is ever born.

SSG-first — no layout decisions in JavaScript

Since HTML is generated exactly once, useViewport() for visual layout decisions is banned in this project: during SSR/SSG the hook uses a fallback value, not the visitor's real viewport — hydration then discovers the real viewport and swaps the layout out from under the user, guaranteeing CLS.

The practical rule:

  • Responsiveness always via CSS media query (fui-col-md-6, fui-col-lg-4) — the browser applies it after it already has the real viewport, no mismatch.
  • Item counts, sizes, and breakpoint-conditioned visibility never in a listing component's JS.
  • Fixed perPage (31, currently) instead of varying per viewport — anyone who wants fewer items per page uses the select, not automatic detection.
  • The one exception: purely client-side interaction (hover, focus, animation) can use JS normally — it doesn't affect the already-painted HTML.

Morphisms — the FOUC that SSG creates on its own

The fui-content library was designed to install one morphism per project — the selectors it emits are flat, unprefixed (.fui-btn {}, not .fui-glass .fui-btn {}). ao.dev deliberately breaks that premise: it loads all five themes as separate CSS files and swaps via a <link id="fui-theme">, to give visitors the real-time morphism switcher. That creates two problems the library, on its own, would never need to solve.

Problem 1 — SSG FOUC

useCookie has no request context during prerender: the generated HTML ships with no morphism class and no theme <link>. Vue hydrates, useMorphism reads the visitor's cookie, adds the class and the link — and in that window the user sees a flash of completely unstyled theme.

Problem 2 — bleed between themes

Without scoping selectors, .fui-btn {} from one loaded theme leaks into the whole document, even when a different morphism is active.

The fix — PostCSS scoping + a blocking script

scripts/build-themes.ts runs a PostCSS pass after every SCSS compile, prefixing every selector with html.{theme}:

/* before (emitted by the library, designed for single-theme use) */
:root { --fui-color-primary: #7c3aed; }
.fui-btn { padding: var(--fui-space-3); }

/* after PostCSS scoping, per theme */
html.skeu { --fui-color-primary: #7c3aed; }
html.skeu .fui-btn { padding: var(--fui-space-3); }

And in the <head>, an inline script with tagPriority: 'critical' runs before any paint: it reads the cookie via regex, validates it against the known morphism list, adds html.{morphism} via classList.add (idempotent — on SSR the class is already there), and injects a <link rel="preload" as="style"> that converts to stylesheet on onload. On SSR the server already renders the correct class — zero FOUC, automatically. On SSG, with no cookie in the HTML, the script takes over on the client, before the first paint.

Lighthouse tests with no cookies — the script is a no-op, no theme CSS loads, zero impact on Performance.

The real bug — @layer order on a cold reload

This was the hardest bug to diagnose in the entire project. Switching morphism worked perfectly client-side — setMorphism(), no reload. But an actual reload with the cookie already set caused visual bleed: border-radius, padding, gap, font-size, and box-shadow swapping values inconsistently, element by element.

Root cause: each generated theme file (public/themes/{skeu,glass,neu,clay}.css) only declared loose @layer fui.components {} / @layer fui.utilities {} blocks — only the base bundle declared the full canonical order (@layer fui.layout, fui.base, fui.components, fui.utilities;). In a real network race (the inline script injects the morphism's <link> very early), if the browser parsed the theme <link> before the base bundle, the global layer registration order came out wrong — and the base's raw reset started winning over the morphism's utilities and components.

The fix: prefix every generated theme file with the same order declaration as the base, before any selector. It no longer matters which file the browser parses first — the correct order is established up front by whichever one loads, and the second identical declaration is a no-op. It eliminates the network race structurally, with no dependency on <link> timing.

Hydration — when the SEO module itself caused the mismatch

Another bug that only ever showed up in SSG, never in dev: Hydration completed but contains mismatches., always in the same spot — a text node inside a <style> in the body.

The culprit was nuxt-seo-utils (bundled via @nuxtjs/seo), which has minify: true by default. The Nitro HTML-minification plugin compresses every inline <style> during prerender — including the <style> Shiki/MDC injects into the body carrying syntax-highlighting CSS vars. Result: the server's HTML had minified CSS, the Nuxt payload had unminified CSS (coming straight from @nuxt/content's SQLite), and the client re-rendered MDCRenderer using the payload — different CSS, guaranteed mismatch.

// nuxt.config.ts
seo: {
  minify: { build: false }, // only disables body minification on prerender —
},                          // runtime (head tags via unhead) stays on and doesn't cause mismatches

Debugging this class of mismatch in production (SSG) has a little-known Vue 3.4+ flag that enables detailed messages even in a production build:

vite: {
  define: {
    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: JSON.stringify(true),
  },
}

Performance audit — real numbers, not guesswork

A pre-deploy audit on the home page, running Lighthouse seriously (not just once, accepting whatever number came back), surfaced a series of systemic issues — things that apply to any new page, not just the one that was tested.

Compression the production preset didn't do on its own

compressPublicAssets is false by default in Nitro. Without it, the node-server preset (what runs in SSR production) serves _nuxt/*.css/*.js/fonts with no gzip or brotli — we measured the exact same CSS file 15× larger in SSR (1.28 MB) than on a generic static server (83 KB) serving the same build.

nitro: {
  compressPublicAssets: { gzip: true, brotli: true },
},

Global component fetches only on interaction, never on mount

CommandPaletteComponent (⌘K search) stays mounted in app.vue on every page, even closed. It called useAsyncData(..., { server: false, lazy: true }) — but lazy: true only defers the render, the fetch still fires immediately after hydration regardless. That instantiated @nuxt/content's search engine (client-side SQLite-WASM) on every page, even when a visitor never opened search: we measured 1–2.4s of unattributed scripting at bootup and 239–404 KiB of unused JS.

The fix: immediate: false on useAsyncData, with execute() fired manually only when the user actually opens search.

Command Palette (⌘K) open, with project, post, and tag suggestions pulled from real content before you even start typing
Command Palette (⌘K) open, with project, post, and tag suggestions pulled from real content before you even start typing

Purging unused CSS

The fui-content library is utility-first — it generates a fixed set of classes at compile time, with no notion of which .vue components in this project actually use what. @fullhuman/postcss-purgecss, configured in nuxt.config.ts (never in the library itself), fixes that as a build optimization equivalent to JS tree-shaking:

MetricBeforeAfterReduction
Raw size1.28 MB143 KB−89%
Gzip size~113 KB~21.6 KB−81%
CSS classes14,508464

The real risk of automated purging is a dynamically-assembled class — `fui-tag-${tech.color}` never appears as a complete string in the source, so the scanner would miss it and strip the class. Before enabling it, a sweep for template-literal interpolation prefixed with fui- identified the few real cases, which went into a regex safelist (/^fui-tag-/). The result was validated with a rebuild + screenshot + computed-styles check across three pages before calling it done — never just "the build passed with no errors."

LCP priority per page

The LCP element changes per page — on the home it's the first morphism showcase card, elsewhere it might be a cover image. Only the actual LCP element gets fetchpriority="high" plus preload; applying it to several images dilutes priority and helps nothing.

<NuxtImg
  :preload="isLcp ? { fetchPriority: 'high' } : undefined"
  :fetchpriority="isLcp ? 'high' : undefined"
  ...
/>

queryCollection — payload is a budget, not a detail

The body field of a type: 'page' collection is the full Markdown AST — 30 to 200 KB per document. A listing query with no .select() includes every post's body, multiplied per SSG route.

// WRONG — every post's body (~770 KB per route)
queryCollection(blogCol.value).order('date', 'DESC').all()

// RIGHT — frontmatter only (~5–20 KB)
queryCollection(blogCol.value)
  .select('path', 'title', 'shortTitle', 'description', 'date', 'readTime', 'tags', 'cover')
  .order('date', 'DESC')
  .all()

Two rules followed from this: indexes in content.config.ts for every column used in .where()/.order() (mandatory on databases like Cloudflare D1, where every row read is billed); and useAsyncData keys always delimited with :, never -, whenever they combine a literal prefix with a dynamic slug — useAsyncData's cache is global to the app, and blog-${slug} collides structurally between a post "prev-foo" and another post "foo" whose own key also becomes blog-prev-foo-.... With : as the delimiter, the collision is impossible, because content slugs never contain :.

Accessibility — what IBM Equal Access taught

Real audit cycles with the IBM Equal Access Checker corrected patterns that looked right until they were actually tested:

  • ARIA combobox is ARIA 1.2role="combobox" belongs on the <input> itself, never on a wrapping <div>; the input must be type="text", since type="search" already carries an implicit searchbox role and rejects the override.
  • <p> with bold text inside a link triggers a false-heading heuristic — IBM interprets <p class="fui-fw-bold"> inside an <a> as a possibly malformed heading. Swapping in <span class="fui-d-block"> keeps the exact same look without tripping the rule, because a span is inline.
  • aria-labelledby never points at a conditionally-absent element — if the target is v-if'd, the value falls back to undefined when that element isn't in the DOM.
  • new Date() as a template fallback is a guaranteed hydration mismatch — the server's timestamp never matches the client's; the fix is v-if to omit the element, never generating a date "however."

Not every IBM finding is a real bug. aria_keyboard_handler_exists fires because the tool can't see Vue's @keydown (only plain HTML onkeydown) — a documented false positive, not something to "fix" blindly.

AI discoverability — /llms.txt, generated, never hardcoded

The site exposes /llms.txt and /llms-full.txt (a community standard) via nuxt-llms — the free alternative to Nuxt SEO's paid PRO equivalent. Every section of the file is generated from a real collection (blog_ptBR, projects_enUS...), never from a static template — the file can never go stale because there's no hand-written version to grow old.

The same library exposes the raw Markdown of every content page via /raw/<path>.md — that's the "View as Markdown" link at the top of every blog post and case study, including this very one you're reading now.

Case study header with the "View as Markdown" link visible, exposing the real content via /raw/<path>.md
Case study header with the "View as Markdown" link visible, exposing the real content via /raw/<path>.md

The finalization checklist — five phases, always in this order

No new page (or significant refactor) is considered done without going through, in order:

  1. Lint + typechecklint:fix, lint:css:fix, typecheck
  2. SSG auditnuxt generate + static preview, WAVE (visual) + IBM Equal Access (exported report) across pt-BR/en-US × light/dark
  3. SSR auditnuxt build + nuxi preview, same toolset, extra attention to hydration and Schema.org in the initial HTML
  4. Build logs — payloads over 50 KB get investigated, not ignored
  5. Lighthouse — the best possible score across Performance, Accessibility, Best Practices, and SEO, with LCP, INP, and CLS inside the Core Web Vitals recommended thresholds, across both dimensions (desktop/mobile) × both themes (light/dark)

Lighthouse's default throttlingMethod: simulate has some run-to-run variance — the exact same page, with no code change at all, can come out slightly different from one run to the next, depending on machine load at test time. Every page gets pushed for the highest score achievable; the real acceptance bar is always whatever the most recent run under those conditions comes back with.

Only once all five steps pass does a page get committed. The rule exists because on this project, Core Web Vitals aren't a bonus — they're a requirement, at the same level as lint or typecheck.

The projects listing — the design system at scale

Grid project listing, with category filter and several cards using the same card component
Grid project listing, with category filter and several cards using the same card component

This is the most honest page to see fui-content working at scale: dozens of cards, all the exact same component (ProjectCardGridComponent), each pulling cover from every project's real frontmatter, with search, category filtering, and live counts — all listing logic lives in composables (useProjectsListing), zero duplication between the grid and list views.

Off desktop, primary navigation becomes fixed bottom tabs (Home, Projects, Search, Blog, Contact) plus a hamburger menu for the rest of the links — never decided by useViewport() (same SSG-first rule already described), always plain CSS reacting to the device's real viewport. That's a lot easier to feel live than in a resized desktop screenshot — if you're reading this on a laptop, it's worth opening ao.dev.br straight on your phone to see the real tab bar, on a real device.

What's still missing

Two fronts remain deliberately unresolved:

  • @nuxtjs/mdcComark migration@nuxtjs/mdc is in confirmed deprecation by its own maintaining team, in favor of Comark ("faster, AI-friendly, and no longer bound to Vue or Nuxt"). @nuxt/content still depends on the old version; the real migration only arrives via a dependency update, with an official breaking-changes guide — installing Comark manually before that lands makes no sense.
  • @nuxt/a11y — removed from modules: over real documented bugs (a version conflict with @nuxt/devtools-kit, silenced RPC errors within the module itself), but kept in package.json — re-enabling it is its own dedicated investigation cycle, not a last-minute tweak right before deploy.

This case study closes an odd loop to write: it's the only project in this portfolio whose "client" is myself, whose "deploy" hasn't happened yet at the moment I'm writing this, and whose quality audit applies to the very text you're reading. The first blog post and the deploy to ao.dev.br come right after this commit.