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.
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
| Layer | Technology |
|---|---|
| Framework | Nuxt 4 (compatibilityVersion: 4, app/ directory) |
| Language | TypeScript |
| Content | @nuxt/content — Markdown with Zod-typed frontmatter |
| i18n | @nuxtjs/i18n — pt-br (default) and en-us, prefix_except_default strategy |
| SEO | @nuxtjs/seo — schema.org, OG image, sitemap, robots |
| PWA | @vite-pwa/nuxt |
| State | Pinia |
| Styling | fui-content — SCSS with 5 swappable morphisms |
| Dev infra | Docker + Traefik, local HTTPS via mkcert |
| AI discoverability | nuxt-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:
| Field | Value | Responsibility |
|---|---|---|
code | pt-br / en-us | URL prefix — always lowercase (HTTP convention, RFC 3986) |
language | pt-BR / en-US | BCP 47 — HTML lang, hreflang, meta tags |
file | pt-BR.json / en-US.json | Filesystem 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.

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:
| Metric | Before | After | Reduction |
|---|---|---|---|
| Raw size | 1.28 MB | 143 KB | −89% |
| Gzip size | ~113 KB | ~21.6 KB | −81% |
| CSS classes | 14,508 | 464 | — |
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.2 —
role="combobox"belongs on the<input>itself, never on a wrapping<div>; the input must betype="text", sincetype="search"already carries an implicitsearchboxrole 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-labelledbynever points at a conditionally-absent element — if the target isv-if'd, the value falls back toundefinedwhen 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 isv-ifto omit the element, never generating a date "however."
Not every IBM finding is a real bug.
aria_keyboard_handler_existsfires because the tool can't see Vue's@keydown(only plain HTMLonkeydown) — 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.

The finalization checklist — five phases, always in this order
No new page (or significant refactor) is considered done without going through, in order:
- Lint + typecheck —
lint:fix,lint:css:fix,typecheck - SSG audit —
nuxt generate+ static preview, WAVE (visual) + IBM Equal Access (exported report) across pt-BR/en-US × light/dark - SSR audit —
nuxt build+nuxi preview, same toolset, extra attention to hydration and Schema.org in the initial HTML - Build logs — payloads over 50 KB get investigated, not ignored
- 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

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.
Mobile nav and global search
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/mdc→ Comark migration —@nuxtjs/mdcis in confirmed deprecation by its own maintaining team, in favor of Comark ("faster, AI-friendly, and no longer bound to Vue or Nuxt").@nuxt/contentstill 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 frommodules:over real documented bugs (a version conflict with@nuxt/devtools-kit, silenced RPC errors within the module itself), but kept inpackage.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.