# 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<string, { count: number; reset: number }>()

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 `<link rel="canonical">` 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 `<link>` 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 `<xhtml:link rel="alternate">` 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 `<link rel="canonical"`. If absent, it's missing.

---

### Pitfall 5: `og:image` Hardcoded to Absolute Domain String

**What goes wrong:** All 6 page files hardcode `'https://killiandalcin.fr/og-image.png'`. Project detail pages use the same generic image instead of `project.value?.image`. This means every page shares identical OG metadata, reducing click-through from social shares and weakening per-page SEO signals.

**Why it happens:** Quick shortcut during initial migration; `@nuxt/image` was not yet wired into the SEO composable.

**Consequences:** Social previews all look identical. Future domain changes require grep-and-replace across 6+ files. No opportunity for Hytale-specific OG imagery on the dedicated page.

**Prevention:** Centralise in `useSeoMeta` composable:
```typescript
const runtimeConfig = useRuntimeConfig()
const baseUrl = runtimeConfig.public.siteUrl ?? 'https://killiandalcin.fr'
// Pass image path, resolve to absolute URL inside the composable
```
For dynamic project pages, derive from `project.value.image` with a fallback to the global OG image.

---

### Pitfall 6: `@nuxt/image` — SSR Hydration Mismatch with `width`/`height` Props

**What goes wrong:** If `<NuxtImg>` or `<NuxtPicture>` 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 `<img>` 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 `<NuxtImg>`. 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 `<img>` 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 `<link rel="alternate" hreflang="fr" href="https://killiandalcin.fr/">` and `<link rel="alternate" hreflang="en" href="https://killiandalcin.fr/en/">` in `<head>` (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 <em>Hytale & Full Stack</em>" }
```
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)
