fdd7f39972
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
259 lines
14 KiB
Markdown
259 lines
14 KiB
Markdown
# 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)
|