Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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:
- Delete
public/sitemap.xmlimmediately. - Let
@nuxtjs/sitemapown the/sitemap.xmlroute entirely. - Verify
nuxt.config.tshassitemap: { autoLastmod: true, xsl: false }(or equivalent) and thatsite.urlis set — it already is (https://killiandalcin.fr). - Confirm hreflang alternates are generated by checking
/__sitemap__/en-US.xmlstyle 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
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:
- Add
canonicalviauseHeadin 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}` }]
})
- Set
ogUrlin everyuseSeoMeta()call to the full canonical URL. - Verify
@nuxtjs/sitemapemits<xhtml:link rel="alternate">hreflang entries — it does this automatically wheni18nmodule is detected, but only ifpublic/sitemap.xmlis 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
widthandheighton<NuxtImg>. For unknown dimensions, useaspect-ratioCSS as fallback. - Set
placeholderprop for low-quality placeholders while loading. - Use
sizesprop for responsive images rather than relying on CSS alone. - Avoid using
@nuxt/imagefor 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
detectBrowserLanguageentirely and let users switch language manually via the toggle. The cookie is already persisted viacookieKey: '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/i18nv9 documentation onstrategy: 'prefix_except_default'— MEDIUM@nuxtjs/sitemapv6 behaviour withpublic/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)