# Domain Pitfalls **Domain:** Nuxt 4 SSR portfolio — Hytale plugin developer **Researched:** 2026-04-10 **Confidence:** MEDIUM (training knowledge Aug 2025 + direct codebase inspection; no live web search available) --- ## Critical Pitfalls ### Pitfall 1: Static `public/sitemap.xml` Wins Over `@nuxtjs/sitemap` **What goes wrong:** Nitro serves files in `public/` as static assets at their exact path. Because `public/sitemap.xml` exists, every request to `/sitemap.xml` returns the hand-written XML from 2025 — the `@nuxtjs/sitemap` dynamic handler is never reached. Google therefore crawls a stale sitemap that: (a) lists `/formation` which has no matching page, (b) omits `/en/*` hreflang variants entirely, (c) never reflects new projects or the upcoming `/hytale` page. **Why it happens:** `public/` files are resolved before Nuxt server routes. The module registers a `/_sitemap.xml` route or hooks into `/sitemap.xml` via a Nitro route handler, but a physical file at the same path in `public/` short-circuits the handler. **Consequences:** New pages are never indexed. Googlebot sees phantom URLs that 404. The installed module is wasted. **Prevention:** 1. Delete `public/sitemap.xml` immediately. 2. Let `@nuxtjs/sitemap` own the `/sitemap.xml` route entirely. 3. Verify `nuxt.config.ts` has `sitemap: { autoLastmod: true, xsl: false }` (or equivalent) and that `site.url` is set — it already is (`https://killiandalcin.fr`). 4. Confirm hreflang alternates are generated by checking `/__sitemap__/en-US.xml` style URLs that the module emits per locale. **Detection:** `curl https://killiandalcin.fr/sitemap.xml` — if the response contains `lastmod>2025-07-07` the static file is still winning. --- ### Pitfall 2: No Rate Limiting on `/api/contact` — Email Flooding Risk **What goes wrong:** `server/api/contact.post.ts` opens a nodemailer transporter and calls `sendMail` on every POST with no throttle. A script sending 1 000 requests/minute will fill the inbox, potentially exhaust SMTP sending quota (most providers cap at 500 emails/day on cheap plans), and could get the sending IP blacklisted. **Why it happens:** Nuxt/Nitro has no built-in rate-limiting middleware. The current handler only validates field content, not request frequency. **Consequences:** SMTP quota exhaustion → legitimate contacts bounce. IP reputation damage. Potential cost overrun if using a paid SMTP tier. **Prevention (zero paid services):** Option A — In-memory map in a Nitro server plugin (simplest, resets on restart): ```typescript // server/plugins/rate-limit.ts const ipMap = new Map() export default defineNitroPlugin((nitro) => { nitro.hooks.hook('request', (event) => { if (!event.path.startsWith('/api/contact')) return const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown' const now = Date.now() const window = 60_000 // 1 minute const limit = 3 const entry = ipMap.get(ip) if (!entry || entry.reset < now) { ipMap.set(ip, { count: 1, reset: now + window }) return } entry.count++ if (entry.count > limit) { throw createError({ statusCode: 429, message: 'Too many requests' }) } }) }) ``` Option B — `unstorage` with a file/Redis driver so the counter survives restarts (better for production). Option C — Cloudflare free tier in front of the server: rate-limit rule on `/api/contact` at the edge, zero code changes. **Detection warning signs:** SMTP provider sending a quota-exceeded bounce, or inbox flooded with identical submissions. --- ### Pitfall 3: Weak Server-Side Email Validation **What goes wrong:** Line 12 of `contact.post.ts` uses `email.includes('@')` — `notanemail@` and `a@` both pass. The client uses Zod's `z.string().email()` but an attacker bypasses the browser entirely and posts directly to the API. **Why it happens:** Quick guard written as a placeholder; client-side Zod validation gives false confidence. **Consequences:** Malformed `from` headers in outgoing email; some SMTP servers reject the mail silently; the `to` address could theoretically be injected via header manipulation if the email value lands in a `Reply-To` without sanitisation. **Prevention:** Share a Zod schema between client and server: ```typescript // shared/schemas/contact.ts import { z } from 'zod' export const contactSchema = z.object({ name: z.string().min(2).max(100), email: z.string().email().max(200), message: z.string().min(10).max(5000), }) ``` Then in `contact.post.ts`: `const body = await readValidatedBody(event, contactSchema.parse)`. Nuxt's `readValidatedBody` throws a 422 automatically on schema failure. **Detection:** `curl -X POST /api/contact -d '{"name":"x","email":"notanemail","message":"test message here"}'` — if it sends an email, validation is broken. --- ## Moderate Pitfalls ### Pitfall 4: Missing `ogUrl` and `` with `prefix_except_default` **What goes wrong:** With `strategy: 'prefix_except_default'` and `defaultLocale: 'fr'`, the homepage is served at both `/` (French) and `/en/` (English). Without canonical tags, Googlebot sees two different URLs for similar content. Without `ogUrl`, Open Graph shares to Facebook/LinkedIn pick up a relative or wrong URL. **Why it happens:** `useSeoMeta()` calls in every page omit `ogUrl`. `@nuxtjs/i18n` does NOT automatically inject canonical `` tags — that is the responsibility of `@nuxtjs/sitemap` (via `xhtml:link` in the sitemap) and of the page-level `useHead()`. **Consequences:** Google may index `/en/` as a duplicate, split PageRank, or ignore one version entirely. og:url being wrong means link preview cards show the wrong shareable URL. **Prevention:** 1. Add `canonical` via `useHead` in a shared layout or composable: ```typescript // composables/useSeoMeta.ts addition const route = useRoute() const { locale } = useI18n() useHead({ link: [{ rel: 'canonical', href: `https://killiandalcin.fr${route.path}` }] }) ``` 2. Set `ogUrl` in every `useSeoMeta()` call to the full canonical URL. 3. Verify `@nuxtjs/sitemap` emits `` hreflang entries — it does this automatically when `i18n` module is detected, but only if `public/sitemap.xml` is not overriding it (see Pitfall 1). **Detection:** Inspect rendered HTML source — search for `` or `` is used without explicit `width` and `height` attributes, the server renders the image with dimensions derived from provider metadata (or skips them), while the client may recalculate. This produces a hydration mismatch warning and CLS (Cumulative Layout Shift) — a Core Web Vitals penalty. **Why it happens:** `@nuxt/image` with the default `ipx` provider calculates dimensions lazily. Without `width`/`height`, the `` tag emitted server-side has no size attributes, the browser does not reserve space, and content shifts when the image loads. **Consequences:** CLS score above 0.1 → Google ranking penalty. Vue hydration mismatch console warnings in production. **Prevention:** - Always provide `width` and `height` on ``. For unknown dimensions, use `aspect-ratio` CSS as fallback. - Set `placeholder` prop for low-quality placeholders while loading. - Use `sizes` prop for responsive images rather than relying on CSS alone. - Avoid using `@nuxt/image` for images where dimensions are genuinely unknown at render time (e.g. user-uploaded content) — use a standard `` with explicit CSS aspect-ratio instead. --- ### Pitfall 7: Docker Dockerfile Uses `npm ci` After Migration to pnpm **What goes wrong:** `Dockerfile` stage 1 runs `COPY package*.json ./` followed by `npm ci`. The project has migrated to pnpm (`pnpm-lock.yaml` exists). `npm ci` will install from `package-lock.json` (the old lockfile), which may diverge from the actual dependencies resolved by pnpm. If `package-lock.json` is stale or deleted, `npm ci` fails entirely. **Why it happens:** The Dockerfile was not updated when the package manager was switched. **Consequences:** Production Docker image may run different dependency versions than local dev. Build could fail silently with outdated transitive deps. Both lockfiles coexisting in the repo is a CI/CD footgun. **Prevention:** ```dockerfile # Stage 1: Build FROM node:22-alpine AS builder RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY . . RUN pnpm run build ``` Delete `package-lock.json` from the repo to remove ambiguity. Add `.npmrc` with `engine-strict=true` if desired. **Detection:** `docker build` succeeding but runtime behaviour differing from `pnpm dev` — check `node_modules` versions inside the container. --- ### Pitfall 8: `detectBrowserLanguage` with `redirectOn: 'root'` Causes Double Redirect for Crawlers **What goes wrong:** The i18n config has: ```ts detectBrowserLanguage: { useCookie: true, cookieKey: 'i18n_redirected', redirectOn: 'root', } ``` Googlebot has no cookies. On every crawl of `/`, Googlebot gets a 302 redirect to `/en/` (its apparent locale) then must re-crawl. This adds a redirect hop to every French-default page discovery and can cause Googlebot to incorrectly associate English content with the root URL. **Why it happens:** `detectBrowserLanguage` is designed for user experience, not crawlers. Crawlers do not send `Accept-Language` reliably, and never persist the redirect cookie. **Consequences:** Root URL (`/`) may be indexed in English by Googlebot depending on how the redirect resolves. Crawl budget wasted on redirects. **Prevention:** - Set `redirectOn: 'no prefix'` instead of `'root'` — only redirect when the user hits a non-prefixed URL that is ambiguous. - Or disable `detectBrowserLanguage` entirely and let users switch language manually via the toggle. The cookie is already persisted via `cookieKey: 'i18n_redirected'` so repeat visits respect the choice. - Add `` and `` in `` (or rely on sitemap hreflang if Pitfall 1 is fixed). --- ## Minor Pitfalls ### Pitfall 9: `aggregateRating.reviewCount: '50'` vs `testimonials.totalReviews: 10` **What goes wrong:** `app/data/site.ts` claims `reviewCount: '50'` in JSON-LD structured data; `app/data/testimonials.ts` has `totalReviews: 10`. Google's rich results validator and structured data guidelines penalise exaggerated or inconsistent `aggregateRating` claims. **Prevention:** Align both values. If the portfolio has 10 real reviews, set `reviewCount: '10'`. Inflated counts can result in the rich result being demoted or flagged. --- ### Pitfall 10: `HeroSection` `.split(' ').slice(-2)` Gradient Logic Breaks with FR **What goes wrong:** The gradient is applied to the last 2 words of the title, extracted by splitting on spaces. French translations may have different word counts (e.g. "Développeur Full Stack Freelance" = 4 words vs "Full Stack Developer" = 3 words). The last 2 words in French may not be the visually intended words. **Prevention:** Use a translation key that wraps the emphasised portion in a span rather than computing it dynamically: ```json { "hero.title": "Développeur Hytale & Full Stack" } ``` Then render with `v-html` (sanitised) or split the key into `hero.title.prefix` / `hero.title.emphasis`. --- ### Pitfall 11: External CDN Avatars (`ui-avatars.com`) Break in Offline/Restricted Environments **What goes wrong:** All testimonial avatars make external HTTP requests to `https://ui-avatars.com/api/...` on every SSR render. If the CDN is unavailable (rate-limited, down, or blocked in certain regions) the SSR render stalls waiting for a timeout. **Prevention:** Generate the avatars once (or locally) and serve from `public/images/avatars/`. Alternatively generate SVG initials inline — zero external dependency. --- ## Phase-Specific Warnings | Phase Topic | Likely Pitfall | Mitigation | |---|---|---| | Fixing sitemap | Static file silently wins | Delete `public/sitemap.xml` first; verify with curl | | Adding `/hytale` page | Sitemap won't update until static file removed | Same — Pitfall 1 | | Rate limiting contact API | In-memory map resets on container restart | Use file/Redis unstorage driver or Cloudflare rule | | Canonical links | i18n prefix_except_default creates `/` + `/en/` duplicates | Add canonical in layout composable | | Docker deploy | npm ci vs pnpm-lock.yaml mismatch | Switch Dockerfile to `pnpm install --frozen-lockfile` | | Dynamic OG images for projects | Generic fallback masks per-project imagery | Centralise OG URL logic in composable with dynamic fallback | | Browser language detection | Googlebot cookie-less redirect loop | Switch `redirectOn` to `'no prefix'` or disable | | Structured data for SEO | Mismatched `reviewCount` claim | Align JSON-LD to actual `totalReviews` value | --- ## Sources - Direct codebase inspection: `server/api/contact.post.ts`, `nuxt.config.ts`, `Dockerfile`, `public/sitemap.xml`, `.planning/codebase/CONCERNS.md` - Nuxt 4 documentation (training knowledge, cut-off August 2025) — confidence MEDIUM - `@nuxtjs/i18n` v9 documentation on `strategy: 'prefix_except_default'` — MEDIUM - `@nuxtjs/sitemap` v6 behaviour with `public/` static files — MEDIUM (verified by Nitro static file resolution order) - Google Search Central structured data guidelines — MEDIUM - Core Web Vitals CLS guidelines for image sizing — HIGH (well-established, unchanged)