- Nuxt 4 (SSR, Nitro server) with Vue 3 + TypeScript
- TailwindCSS v4 via
@tailwindcss/viteVite plugin (NOT PostCSS) - Vitest +
@nuxt/test-utilswith Playwright browser mode for testing - Prettier (formatting) + Nuxt flat ESLint via
@nuxt/eslint
cp .example.env .env # then edit .env
pnpm install # triggers nuxt prepare via postinstall- Node 24 required (
package.jsonengines) - pnpm ≥ 11.2.2 required (
packageManagerpins the expected version) - Git submodule at
app/assets/lib/rule-34-shared-resources— clone with--recursive - External API: the app calls a separate API service at
NUXT_PUBLIC_API_URL(defaulthttp://localhost:8081). The API codebase is at github.com/Rule-34/API.
| Command | What it does |
|---|---|
pnpm dev |
Dev server at localhost:8080 via HOST=127.0.0.1 |
pnpm build |
Production build into .output/ |
pnpm generate |
Static generation |
pnpm format |
Prettier write |
pnpm lint |
ESLint flat config |
pnpm typecheck |
nuxt typecheck |
pnpm test |
vitest run |
pnpm test:watch |
vitest watch |
pnpm check |
Strict local gate: format check, lint, typecheck, test typecheck, tests, build |
pnpm release |
commit-and-tag-version for versioning + changelog |
Single Nuxt app. Key directories:
| Dir | Purpose |
|---|---|
app/ |
Nuxt 4 app source (app.vue, router options, assets, components, composables, layouts, middleware, pages, plugins, app-local types) |
config/ |
Centralized root config (project.ts for branding/URLs, i18n.ts for locales) |
server/api/ |
Nitro server API routes |
server/middleware/ |
Nitro middleware |
server/plugins/ |
Nitro plugins |
app/assets/js/ |
Shared app JS utilities, DTOs, custom providers |
app/assets/lib/ |
Git submodule for shared resources |
i18n/locales/ |
i18n JSON files (en, ru, es, ja, pt, de, fr, zh, ko, id, tr, it, vi) |
app/components/ |
Vue components — auto-imported flat (pathPrefix: false, no folder prefix) |
test/ |
Page tests, server tests, mocks |
- Do not carry local dependency patches (
pnpm patch,patch-package,patchedDependencies) in this repo. For third-party bugs, prefer an upstream release, a supported configuration/workaround, or ignoring the specific Sentry issue when it is non-user-facing noise.
Components are registered without path prefix (nuxt.config.ts → components: [{ pathPrefix: false }]). Import
them as <DomainSelector> not <Input/DomainSelector>.
- Keep
HOST=127.0.0.1in local.env/.example.envforpnpm dev. On macOS,localhostcan resolve to::1first; Nuxt/Vite can then answer normal browser requests with a plain426 Upgrade Requiredfrom the IPv6 listener. - If Chrome shows only
Upgrade Required, verify withcurl -I http://localhost:8080/...,curl -I http://127.0.0.1:8080/..., andlsof -nP -iTCP:8080 -sTCP:LISTEN, then restart the dev server with the IPv4 host binding.
- Locales are defined in
config/i18n.ts(single source of truth). Retired prefixes (hi,fil,pl,th) live inremovedLocaleCodesand 301 to English viaserver/middleware/redirect-removed-locales.ts(server-only). - Non-default locales get URL prefixes (
prefix_except_default). Route rules innuxt.configare mirrored via themirroredRouteRules()helper so prefixed paths get the same caching/SSR rules. - Known bug:
canonicalQueriesin the i18n module config is a no-op in v10. A two-part workaround is required:- SSR:
server/plugins/fix-canonical-queries.tspatches the canonical<link>in rendered HTML. - CSR:
app/pages/posts/[domain]/index.vueusesuseHeadto re-apply the canonical after i18n overwrites it on hydration. See the removal checklist infix-canonical-queries.tsfor when upstream fixes this.
- SSR:
- Locale usage analytics (Matomo vs GSC) — do not conflate them when deciding whether to keep or retire a locale:
- Matomo (
app/plugins/040.matomo.client.tstracksto.fullPath): all visits to/{locale}/…regardless of channel (organic, direct, in-app language switch). BucketActions.getPageUrlsby URL prefix for share-of-traffic. - GSC Performance: organic search clicks/impressions only, attributed to the URL shown in the SERP.
- A market can have heavy GSC traffic (e.g. Russia) while most clicks land on unprefixed English URLs; Matomo
can still show high
/ru/share from UX navigation after arrival. Both can be true.
- Matomo (
- GSC locale filters (domain property
sc-domain:r34.app) — easy to get false zeros:- Use Performance → Add filter → Page → URLs containing
/ru/(no trailing*). - Do not use
+/ru/*or URL params likepage=*%2Fru%2F*— on this property they report 0 even whenPage: +/ru/shows real traffic (e.g./ru/~21k clicks / 90d vs/ru/*falsely 0). - Country → Pages is the right cross-check: filter by country (e.g. Russia), open Pages tab, scan for
/{locale}/URLs in the top list. - Insights/blog paths can overlap locale filters (e.g.
/insights/it/…vs/it/posts/…). Retired-locale 301s inserver/middleware/redirect-removed-locales.tsonly match root prefixes^/(code)(/|$), so/insights/{code}/is unaffected.
- Use Performance → Add filter → Page → URLs containing
- Locale portfolio discipline — each active locale is ongoing translation, QA, and SEO surface area. Do not expand
the locale list without GSC evidence of organic demand. Retire prefixes with negligible prefixed-URL GSC traffic via
removedLocaleCodes+ permanent 301 (e.g.hi/fil/pl/thwere <100 GSC clicks each per 90d whilees/ruare ~40k/27k). Keepitwhen blog paths (/insights/it/…) carry most of the signal even if/it/posts/is small.
Prefer Nitro/server for URL, SEO, and redirect behavior unless the feature genuinely needs client interactivity. Crawlers, bookmarks, and cold loads should get the correct response on the first HTTP round-trip — not after Vue Router or client middleware runs.
- Permanent redirects are server-only — use Nitro middleware +
sendRedirect(..., 301). Do not duplicate the same redirect inapp/middleware/*.global.ts; that ships client logic crawlers never need and can disagree after hydration. - Do not add client middleware “for parity” when the server already handles the case. Retired-locale redirects are
the reference pattern: one file,
server/middleware/redirect-removed-locales.ts, inlined and config-driven. - Head/meta without a hydration bug should run on the server (
import.meta.serverinapp/app.vue, Nitro plugins, SSRuseSeoMetain pages). Client re-application is only for known i18n overwrite cases (canonical workaround). - SEO equity lives on the wire — canonicals, 301 targets, and production
link rel="canonical"must be correct in SSR HTML. Client-only fixes are invisible to many crawlers and waste first-load JS.
- Static global tags (favicon, rating, monetization, color-scheme) can live in
nuxt.config.tshead.meta. - Dynamic global tags that need the request host (description, keywords, OG image) belong in
app/app.vueusinguseSeoMetainside anif (import.meta.server)guard —nuxt.config.tsruns too early to know the host. - OG image must be absolute: Open Graph requires absolute URLs. Build it dynamically with
useRequestURL().originon the server only (app/app.vue). i18n does not touchog:imageduring hydration. - Canonical URLs must point to production (
https://r34.app/…) even when served from clone domains. This is intentional for SEO — canonicals prevent duplicate content. Useproject.urls.productionfor canonicals. - Schema.org breadcrumb item URLs should stay local/locale-relative. Do not convert breadcrumb items to
project.urls.production; production-absolute URLs are for canonicals. - Page-specific tags (title, description) should use
useSeoMetain the page component.
- Custom scroll behavior: skips scroll-to-top when only the
pagequery param changes between same-route navigations. - Query filters intentionally use flat bracket keys (
filter[sort],filter[rating], etc.) with Vue Router's default query handling. Do not re-addqsfor nestedroute.query.filterobjects unless the URL contract changes;qsputs a measurable parser/stringifier cost on the first-load router path. - Legacy redirect:
server/middleware/redirect-to-posts.get.tsredirects/?domain=x&page=…&tags=…→/posts/x?page=…&tags=…(301).
A custom imgproxy provider is registered for <NuxtImg> (see nuxt.config.ts → image.providers). Images are
deliberately generated at 1x density only (webp format) to reduce bandwidth.
@nuxt/imagev2 supportspreload: { fetchPriority: 'high' }. Use the module API for image preload priority instead of patching rendered HTML in Nitro.PostMediauses imgproxy for SSR post images, including local development. Non-premium SPA navigations keep the direct image path; validate image delivery in an environment where imgproxy can resolve the source URL.- Gelbooru media is routed through Cloudflare Worker media proxies before imgproxy because Gelbooru rate-limits the VM
egress IP. Worker fetches must use clean upstream headers instead of forwarding inbound
CF-*,X-Forwarded-*,X-Real-IP, cookies, or authorization headers; leaked caller metadata can make imgproxy fetches return429.
- Do not add
provideHeadlessUseIdinapp/app.vuewhile the project uses Vue 3.5+ and@headlessui/vue1.7.23+; those versions use Vue's nativeuseIdand the Nuxt Headless UI workaround is only for older versions.
useLazyToast()lazy-loadsvue-sonnerand rendersClientToasteron first use. The first toast must wait forClientToasterto mount before callingtoast.*; a plainnextTick()can fire before<LazyClientToaster>has finished loading and silently drop the toast.
- When a premium prompt is triggered from inside a bottom sheet or dialog, let the sheet/dialog owner close the local UI, open the premium prompt, and restore the local UI after the prompt closes. Opening the premium prompt directly from a nested child can leave stacked Headless UI dialogs that require multiple close clicks.
- Server over client when equivalent — every global route middleware, duplicated redirect helper, and client-only SEO
shim is bundle + hydration cost on routes that never needed it. Default to Nitro middleware, server plugins, and SSR
head tags; reach for
app/middlewareonly when SPA navigation truly requires client-side routing behavior. - Prefer high-impact, measurable optimizations over small rewrites. Keep battle-tested dependencies unless replacing one has a clear, measured payoff.
- Preserve interaction-gated loading for post UI features: components, composables, and heavy dependencies that are only
needed after a user opens a menu/sheet/dialog should stay behind Nuxt
Lazy*components, dynamic imports, or similarly deferred boundaries instead of entering the first-load route chunk. - After substantial performance changes, verify with a production build, relevant tests, request traces, and Lighthouse against the built app before deciding the change is worth keeping.
- Production is behind Cloudflare, which Brotli-compresses HTML responses. Do not add app-level HTML compression unless a direct-origin deployment needs it and the change is verified with headers, byte sizes, warm TTFB, and Lighthouse.
- Keep the global TanStack Vue Query plugin unless a larger measured payoff appears. A route-scoped
QueryClientexperiment on 2026-05-17 saved only about 8 KB compressed on the homepage and did not move Lighthouse, while adding custom SSR hydration logic. - Keep
features.inlineStyles: falseunless new measurements justify revisiting it. Enabling it on 2026-05-17 doubled homepage HTML from about 51 KB to 106 KB, increased Lighthouse byte weight from 361 KiB to 406 KiB, and did not improve the performance score. - Keep
@formkit/auto-animateroute-scoped unless it is used broadly. The Nuxt module registers a global directive and puts the runtime in the first-load entry; localvAutoAnimateimports on the premium CSR pages saved about 3 KB gzip. - For URL validation/parsing, prefer
URL.canParse()orURL.parse()over constructortry/catch; useURL.parse()when the parsed URL object is needed, with aURL.canParse()fallback in browser code if compatibility matters. - Static
URL.parse()/URL.canParse()browser support comes from@teages/nuxt-legacycustomPolyfillsandapp/polyfills/url-static-methods.ts. Do not re-add@vitejs/plugin-legacyURL polyfill config for this path; it can emit extra legacy assets without loading the modern URL static-method polyfill before the app entry.
The service worker is intentionally disabled (selfDestroying: true). Do not add service worker logic.
Tailwind v4 uses CSS-based config (app/assets/css/main.css), NOT PostCSS. The tailwind.config.js remains only for
the @headlessui/tailwindcss plugin.
- Client: configured in
sentry.client.options.ts(replay, third-party error filter, deny URLs). - Server:
sentry.server.config.tsreads DSN from env vars directly (before Nuxt boots). - Source map uploads only happen in production Docker builds (needs
SENTRY_ORG,SENTRY_PROJECT,SENTRY_AUTH_TOKENbuild args).
- Tests use
@nuxt/test-utilswith Playwright insidedescribeblocks that callawait setup({ browser: true }). - Server-side API calls are mocked via a test-only Nitro plugin at
test/server-mocks/plugin.ts, injected throughnuxt.config.ts→$test.nitro.plugins. - In test mode,
$test.runtimeConfig.public.apiUrlis set to''so$fetch(baseURL: '')routes to the local Nitro test server. - Sentry is fully disabled in tests via
$test.sentry.enabled: falseinnuxt.config.ts. - Debug mode: import
debugBrowserOptionsfromtest/helper.tsfor headful playback with slowMo. - Plain Vitest suites that import app modules directly do not get Nuxt's runtime alias resolution; keep repository/pure modules importable through relative paths or import them directly from their app path in those suites.
@nuxt/test-utils$fetchhas no.raw— it is a path-resolving wrapper aroundofetch. For redirect status andLocationheaders, usefetchfrom@nuxt/test-utilswith{ redirect: 'manual' }(seetest/server/redirect-removed-locales.test.ts).- Locale-related tests should import
localeCodes,prefixedLocaleCodes, andremovedLocaleCodesfromconfig/i18n(orvi.mock('~~/config/i18n', () => import('../../config/i18n'))) instead of hardcoding locale lists. - For premium and PocketBase flows, use a real authenticated browser session for final investigation when possible. Unit tests can prove payload and repository behavior, but real-browser traces catch request bursts, realtime echo refreshes, auth redirects, and UI state changes that are easy to miss in isolated tests.
- Empty cloud state means "no user-authored cloud override"; do not seed PocketBase from local defaults during initial load. Only write premium cloud records after explicit user edits.
- PocketBase realtime subscriptions echo local writes. When debugging sync performance, inspect real network traces and separate write requests from realtime-triggered refreshes.
- Saved posts use the same premium cloud realtime runtime as tag collections, custom boorus, and the custom blocklist. Empty saved-post cloud state means there are no saved posts, unlike empty user-authored sync collections where local defaults can still apply.
- Premium auth transitions are reload-backed in the dashboard/sign-in flow, so premium sync state does not need
per-user owner scoping in
useState; rely on the page reload to clear memory state instead of tracking PocketBase user ids. - In
/premium/saved-posts, unsaving a post intentionally should not remove the row or prune cached infinite-query data. Keep the viewer stable so users do not lose scroll/progress; the save button can update immediately and the row can disappear on a later reload/refetch. - Use PocketBase batch writes for multi-record replacement/reorder operations. Reordering positioned records should not emit one HTTP write per changed row when the SDK batch API is available.
- VueUse
moveArrayElement()applies the array move onnextTick. For state that is immediately persisted, build the reordered array synchronously instead of reading it before VueUse has applied the move.
- Multi-stage: build stage uses
pnpm install --frozen-lockfile. Source map uploads needSENTRY_ORG,SENTRY_PROJECT, andSENTRY_AUTH_TOKEN; setSENTRY_UPLOAD_SOURCE_MAPS=falseto skip them. The production stage copies only.output/(nonode_modulesneeded — Nitro bundles everything). NITRO_PRESETbuild arg selects the deployment target.
Key settings: 120-char print width, no semicolons, single quotes, trailing commas removed, single attribute per line in Vue templates.