Skip to content
All skills

Architecture

Claude skill atlas-architecture · for Frontend, Backend, Release

The binding architecture of Atlas of Today's World (Next.js 16, React 19, Supabase, Vercel) - stack, folder layers, the feature file pattern, ESLint layer rules, data flow, caching and static/ISR rules, ADRs and the 10 non-negotiable rules. Use it before any change to code, the database (migrations, RLS, functions), sign-in and permissions, the admin, tests or CI, and when reviewing a PR.

How the Atlas code is put together and the rules every change keeps. The one principle: the database decides, the app displays. Permissions live in Postgres (RLS and SECURITY DEFINER functions); checks in the UI are only for the user experience.

Before you start

  • This is not the Next.js you know. Next 16 changed APIs and conventions. Read the relevant guide in node_modules/next/dist/docs/ before you write code (see AGENTS.md).
  • Architecture docs (Czech) live in docs/architecture/. The index is ARCHITEKTURA.md. The current state and technical debt are in docs/architecture/analyza-2026-10.md. Recipes (new table, new admin screen, new public page) are in docs/architecture/12-postupy-a-standardy.md.
  • Related skills: atlas-backend (Supabase, actions, migrations), atlas-security, atlas-dedup, atlas-frontend, atlas-performance, atlas-admin, atlas-testing-release.

Stack

  • Next.js ^16.4 App Router, React ^19.2 (React Compiler lint rules on), TypeScript ^5.9 strict, ESM.
  • Tailwind ^4.1 (@tailwindcss/postcss, tokens in src/app/globals.css @theme), cva + clsx + tailwind-merge (src/lib/cn.ts), lucide-react icons.
  • Supabase: Postgres 17 in Frankfurt, @supabase/ssr + supabase-js, RLS on every table, pg_cron for scheduled publishing and housekeeping.
  • MapLibre GL ^6 (globe in the (map) layout, never remounted), TipTap editor, Zod ^4, sanitize-html.
  • Vercel (Hobby, region fra1 in vercel.json), deployed only from GitHub Actions. Public GitHub repo Atlas-of-Today-s-World/atlas-of-todays-world.
  • Node 22 (.nvmrc). English only: src/messages/en.json, LOCALES = ["en"] (ADR-022).
  • src/proxy.ts is the Next 16 replacement of middleware.ts.

Folder layers

FolderHoldsMay import
src/appRouting, layouts, thin pages and route handlerseverything below
src/features/<domain>One domain each (accounts, auth, entries, geography, visuals...)components, lib, config, other features' non-component modules
src/componentsShared UI: ui/ primitives, atlas/ patterns, admin/, data-table/, portrait/, map/...lib, config, feature types/queries
src/libInfrastructure: Supabase clients, security, cache tags, i18n, db helpersconfig only
src/configConstants with no app imports: routes.ts, sections.ts, layout.ts, palette.tsnothing from features

A component that calls a domain's Server Action or renders a domain component belongs in features/<domain>/components/. A piece shared by two domains belongs in components/.

The feature file pattern

Inside src/features/<domain>/ use these names (not all are needed):

  • schema.ts - Zod input schemas, shared by form and action.
  • queries.ts - public reads. import "server-only", createPublicClient() inside unstable_cache with tags, usually wrapped in React cache().
  • editorial.ts - admin reads under the user's session (createServerClient()), never cached.
  • actions.ts - "use server" + import "server-only". Big domains split into *-actions.ts (entries/workflow-actions.ts, auth/email-actions.ts).
  • types.ts - client-safe types. constants.ts / model.ts - pure constants and logic. rows.ts - DB row to view-model mapping. components/ - the domain's UI.
  • Tests sit next to the code as *.test.ts.

Client components import types from features/*/types.ts and lib/action-state.ts, never from schema.ts or a server-only module. tests/unit/client-bundle.test.ts fails if zod or @supabase/ssr reaches a client bundle.

ESLint guards (eslint.config.mjs)

These are errors, not warnings (npm run lint uses --max-warnings=0). Tests are exempt.

  • lib/** and config/** must not import @/features/**.
  • components/** must not import @/features/*/*actions or @/features/*/components/**.
  • features/X must not import @/features/Y/components/** (generated per feature).
  • @supabase/supabase-js only in src/lib/supabase/** (type imports allowed).
  • dangerouslySetInnerHTML only in components/atlas/SafeHtml.tsx, components/JsonLd.tsx, components/PrePaintScript.tsx.
  • No hand-built public URLs (`/country/${slug}`): use routes.*. No "/admin..." literals: use adminRoutes.* (a type such as PageProps<"/admin/..."> is fine).
  • No fetch in src/components/** or features/**/components/**: use apiFetch().
  • An exception goes through the allowing() helper with a comment that says why. Today there are two: lib/i18n/request.ts imports ui-texts/queries, and components/MenuDialog.tsx (the header's mobile menu) renders NewsletterForm. Do not add a third without an ADR.

Data flow

  • Read (public): page (Server Component) → features/x/queries.ts → createPublicClient() (anon key, RLS) inside unstable_cache({ tags, revalidate }).
  • Read (admin): admin page → features/x/editorial.ts → createServerClient() (session, RLS).
  • Write: form → Server Action (formAction / idAction) → session client → RLS or an RPC → updateTag(tags.x) + revalidatePath(adminRoutes.x...). Details: atlas-backend.
  • Content state (planned → draft → pending → published) changes only through RPCs, never a direct update.
  • Client to server: a Server Action, or apiFetch() to our own /api/* route. The account menu loads GET /api/me only when it opens.
  • Props: global PageProps<"/[locale]/..."> / LayoutProps<"/[locale]">, generated by next typegen (part of npm run typecheck).

Caching, static and ISR

  • Every public page is static or ISR. Never force-dynamic on public content.
  • Public code never reads cookies() or headers() - not in a page, a shared layout, generateMetadata or a helper they call. Texts come from getT() / getMessagesFor(), settings from getSiteSettings(); both are cached without cookies.
  • ISR comes from unstable_cache({ tags, revalidate: PUBLIC_REVALIDATE_SECONDS }) (src/lib/cache/tags.ts, 3600 s). Route handlers (feeds, sitemaps, llms.txt) use the literal export const revalidate = 3600. Never raise this interval: scheduled publishing (publish_due_entries, pg_cron) relies on it to reach the pages.
  • Cache tags exist once, in src/lib/cache/tags.ts (tags.*, entryPageTags, entryChangedTags). Do not invent tag strings in features.
  • Writes call updateTag(tag) (read-your-own-writes, Server Actions only). revalidateTag is not used. Do not introduce "use cache" piecemeal; moving to it is a separate, planned change.
  • Slug pages use generateStaticParams + export const dynamicParams = true (with false, Next returns 404 after an updateTag). Slug layouts call guardedLayout(exists, routes.x) from features/redirects/guard.ts so a redirect or a real 404 happens before streaming.
  • force-dynamic is only for: the admin layout, (account) pages, preview/[token], membership/checkout, [locale]/[...path], api/* and auth/*.
  • No access is HTTP 200, not forbidden(). experimental.authInterrupts / forbidden() once made every page dynamic (PR #189). Render <NoAccess /> (components/admin/NoAccess).
  • CI guard: scripts/check-static-routes.mjs (job "Build, smoke a e2e") fails when home, about, topics, countries, portraits, news or view pages stop being prerendered. If it fails, find the dynamic API you added; do not edit the list.

The 10 non-negotiable rules

  1. The DB decides, the app displays. Authorization is RLS or a SECURITY DEFINER function.
  2. Right key for the job: public reads = anon (lib/supabase/public.ts), user actions = session (lib/supabase/server.ts), service key only for narrow server tasks (lib/supabase/service.ts). No secret ever gets a NEXT_PUBLIC_ prefix.
  3. Identity on the server is supabase.auth.getUser(), never getSession() alone.
  4. Every mutation is a Server Action built with formAction / idAction; content state changes only through RPCs; then updateTag + revalidatePath.
  5. Zod at every boundary, with limits equal to the DB CHECK constraints.
  6. HTML only through the one allowlist (lib/security/sanitize.ts) at save and at render (<SafeHtml>). URLs through safeUrl(), redirects through safeRedirect().
  7. Public pages are static/ISR and never read cookies or headers. Admin is force-dynamic
    • privateMetadata() (noindex).
  8. CSP stays strict: session pages get a per-request nonce, static pages keep the shared policy, never 'unsafe-eval' in production. A new external origin = lib/security/csp.ts + its test (atlas-security).
  9. Rate limits live in Postgres (rate_limits), never in process memory.
  10. No filesystem writes at runtime (Vercel is read-only); files go to Supabase Storage.

Working agreements

  • One task = one branch = one PR. Deploys only through the pipeline (atlas-testing-release).
  • Code comments, test names, commit messages and admin UI text are English. Public text comes from src/messages/en.json and Admin → UI texts.
  • The repo is public. Never commit anything in docs/ except docs/architecture/, secrets, tokens or descriptions of unfixed vulnerabilities.
  • Owner decisions that stay: the coin and spark animations, the "Support the Atlas" menu item, the hero alt texts, Stripe in test mode, no extra editorial approval gates (anyone invited into the team can edit content), readers self-register as reader with no admin access.

Recording decisions

  • A new architectural decision is a new row in docs/architecture/10-rozhodnuti-adr.md (next number after ADR-028). Never rewrite an old row; mark it "superseded by ADR-0xx".
  • A deviation from these rules needs a note in the PR and an ADR row.
  • Changed conventions: update this skill and the matching chapter in docs/architecture/ in the same PR. Task status goes to PLAN-REALIZACE.md.
  • Private findings (security, audits) stay in the gitignored docs/; refer to them only by ID.

Checklist

  • [ ] New code sits in the right layer and file (queries / editorial / actions / types).
  • [ ] npm run lint passes with no new allowing() exception.
  • [ ] No cookies()/headers() in a public path; the static-route check passes.
  • [ ] Every write ends in updateTag with a tag from lib/cache/tags.ts.
  • [ ] Client components import no schema or server-only module.
  • [ ] New decision recorded as an ADR row; docs and skills updated if a convention changed.

Anti-patterns

  • Checking permissions only in a page or layout: actions are separate public endpoints.
  • Reading the session in the root or [locale] layout "just for the header": every page goes dynamic.
  • force-dynamic to "fix" stale data: use the right tag and updateTag instead.
  • A lib/ helper that imports a feature: move the shared part to lib/ or config/.
  • Raising PUBLIC_REVALIDATE_SECONDS: scheduled posts would appear late.

Sources

  • Next.js: Data security - https://nextjs.org/docs/app/guides/data-security
  • Next.js: Revalidating (updateTag, revalidateTag) - https://nextjs.org/docs/app/getting-started/revalidating
  • Next.js: Content Security Policy - https://nextjs.org/docs/app/guides/content-security-policy
  • The Twelve-Factor App: Config - https://12factor.net/config
  • Martin Fowler: Feature Toggles - https://martinfowler.com/articles/feature-toggles.html

This site uses only the cookies it needs to work — no advertising or tracking cookies. Visits are counted without cookies. Privacy