Files
portfolio/.planning/research/PITFALLS.md
T
kayjaydee fdd7f39972 docs: complete project research
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:08:28 +02:00

14 KiB

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):

// 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:

// 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

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:
// composables/useSeoMeta.ts addition
const route = useRoute()
const { locale } = useI18n()
useHead({
  link: [{ rel: 'canonical', href: `https://killiandalcin.fr${route.path}` }]
})
  1. Set ogUrl in every useSeoMeta() call to the full canonical URL.
  2. 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:

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:

# 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:

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:

{ "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)