Imported from motormetrics/motormetrics (
apps/web/AGENTS.md). Install upstream withnpx skills add motormetrics/motormetrics --skill web. Copyright stays with the author.
This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ (resolved from this file's directory; in monorepos the next package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by next dev — verify at node_modules/next/dist/server/lib/generate-agent-files.js. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
HeroUI React v3 — what you remember is wrong for this project. Look it up, never write it from memory.
The generated docs index that used to sit here was ~28k characters of filenames and has been removed; it duplicated what the MCP servers already serve on demand. Use, in order:
- The
heroui-proMCP server (list_components,get_component_docs,get_theme_variables). It covers both@heroui/react(OSS) and@heroui-pro/react(Pro). - If MCP is unreachable, regenerate a local snapshot:
npx heroui-cli@latest agents-md --react --output AGENTS.mdIt writes into this marker block and lands docs in./.heroui-docs/react(gitignored, absent on a fresh clone).
Web Application
Architecture Overview
Component Co-location Strategy
Route-specific components, actions, queries and utils live in plainly-named folders alongside their consuming route
(components/, actions/, queries/, utils/ — no underscore prefix). Folders without a page.tsx are not treated
as routes by the App Router.
Blog actions are mutations only (view incrementing, tag updates); blog reads live in lib/data/posts.ts.
Keep centralised when:
- Component used by 3+ different routes
- Part of the design system (HeroUI components, tokens in
src/app/globals.css) - Shared business logic (
queries/,lib/) or actions used across multiple routes - Generic utilities (
components/shared/)
Co-locate when: the component, action or utility is used by a single route or feature area.
Imports: always use the @web/* path alias, never relative paths, including for co-located code.
Data Architecture
Cache Components (Next.js 16): data-fetching queries use "use cache: remote" with cacheLife("max") and
granular cache tags (e.g. cars:month:2024-01, coe:period:12m). The custom "max" profile is defined in
next.config.ts. See the cache-components skill for implementation patterns.
Why remote, not plain "use cache": at request time, plain "use cache" stores entries in a per-instance
in-memory handler, which never persists across serverless invocations. Every request outside the static shell
(anything reading searchParams) re-ran every query against Neon. "use cache: remote" writes to the shared
Vercel Data Cache instead, while still prerendering into the static shell where possible.
Why "max" (30-day stale/revalidate, 1-year expire): data updates monthly, so this yields ~2 regenerations/month (1 automatic + 1 on-demand) versus ~30 with daily checks — roughly 15x less Vercel Fluid Compute. Do not shorten these without a reason; the cost is CPU, not staleness.
Revalidation gotcha: in Next.js 16 revalidateTag() requires a second argument naming the cache profile. Omitting
it does not give stale-while-revalidate semantics.
import {revalidateTag} from "next/cache";
revalidateTag("cars:month:2024-01", "max");
Trigger this when new monthly data arrives, so only affected caches regenerate.
Component Patterns
- UI: HeroUI v3 from
@heroui/react, using compound component patterns - Charts: HeroUI Pro charts and
KPIfrom their subpaths (@heroui-pro/react/area-chart,@heroui-pro/react/kpi, …); everything else from@heroui-pro/react— do not add local chart wrappers - Maps: HeroUI Pro
Mapfrom@heroui-pro/react/map. Turbopack cannot serve MapLibre's worker, soscripts/copy-maplibre-worker.mjscopies it and its shared chunk frommaplibre-gl/distinto the gitignoredpublic/maplibre/. Thepredevandprebuildhooks run it, andvercel-buildgoes throughpnpm buildso deploys are covered - Customisation: HeroUI v3 CSS variables and local web tokens, to match Singapore car market branding
Component Naming Conventions
Domain + role, in PascalCase (TrendChart, HeroPost, MetricCard); . notation for subparts
(HeroPost.Image). No Container/Wrapper/Component suffixes and no layout-describing names (LeftSidebar).
Files are kebab-case matching the component (TrendChart → trend-chart.tsx).
See the component-naming skill for the full checklist.
Animation Patterns
Note: This app uses
motion(the successor package);framer-motionhas been removed. HeroUI v3 does not require it — it is kept for custom page and chart animations.Always render elements from
motion/react-client:
import * as motion from "motion/react-client"— for everymotion.*element, in server and client components alike. A purely presentational file needs no"use client".import { useScroll } from "motion/react"/import type { Variants } from "motion/react"— only for hooks and types, whichmotion/react-clientdoes not export. A file using a hook still needs"use client". Never importmotionitself frommotion/react.Tests mock
motion/react-clientwith the element map at the top level ({ div: … }), not nested undermotion.
Shared variants live in src/config/animations.ts.
Guidelines:
- ✅ Use shared variants from
@web/config/animations(centralised) - ✅ Use
initial="hidden"/animate="visible"for page entrance animations - ✅ Use
whileInView="visible"withviewport={{ once: true }}for scroll-triggered animations - ❌ Avoid inline animation definitions
When to use CSS vs Motion:
| Use Case | Recommendation |
|---|---|
| Scroll-triggered reveals | Motion (whileInView) |
| Entrance animations | Motion (initial/animate) |
| Staggered lists | Motion (staggerChildren) |
| Hover states | CSS (Tailwind transition-*) |
| Infinite loops | CSS keyframes |
Typography System
Typography comes from HeroUI (@heroui/react). There is no local Typography module — the
one that used to live at @web/components/typography was deleted, along with its
Typography.H1–H4 / Typography.TextSm API. Only three subcomponents are in use:
Typography.Heading, Typography.Paragraph and Typography.Code.
Enforcement Rules:
- ✅ Use
Typography.Headingwith an explicitlevel(1–4) for headings - ✅ Use
Typography.Paragraphfor body copy;color="muted" size="sm"is the standard treatment for secondary/description text - ✅
Card.Headertitles areTypography.Heading level={4}, descriptions areTypography.Paragraph color="muted" size="sm" - ❌ Avoid raw heading tags (
<h1>–<h4>) outside of MDX content - ❌ There is no
Typography.H4,Typography.TextSm, or default export — those are v2-era - ⚠️ Exception: raw tags allowed only for MDX blog content and image overlay text
Card Header Pattern (standard for all cards):
import { Card, Typography } from "@heroui/react";
<Card.Header className="flex flex-col items-start gap-2">
<Typography.Heading level={4}>Card Title</Typography.Heading>
<Typography.Paragraph color="muted" size="sm">
Card description text
</Typography.Paragraph>
</Card.Header>
Page Title Conventions
Dashboard pages use professional, SEO-aligned H1 titles that match the <title> tag. The <title> is generated from
the Next.js metadata template in src/app/layout.tsx:
- Template (inner pages):
%s - MotorMetrics— the short brand suffix keeps titles within Google's ~60-character display limit. Do not add "(formerly SG Cars Trends)" to per-page titles; it gets truncated and pushes keywords out of the snippet. - Full brand
MotorMetrics (formerly SG Cars Trends)appears only where it has value:- Homepage
/and/about— viatitle.absolute(bypasses the template). - All
openGraph.title/twitter.title(social cards have no length penalty).
- Homepage
- Evergreen titles for "latest data" pages: pages backed by a stable URL that renders
the latest month (
/cars/registrations,/cars/fuel-types,/cars/vehicle-types,/cars/deregistrations) use a fixed, keyword-focused<title>with no month prefix. The month lives in the H1 and meta description instead. Theiralternates.canonicalandopenGraph.urlmust point to the clean path (no?month=) so all query-param variants consolidate to one indexed page. - Path segments (
[make],[type]) get their own distinct title + canonical; query params (?month,?compareA,?category) never create indexable variants. - Keep every indexed
<title>≤ 60 characters; content titles (/blog/[slug],/learn/[slug]) are left full rather than truncated.
Layout & Spacing Conventions
- ✅ Use
flex flex-col gap-*for vertical spacing in containers - ✅ Use
gap-*for both horizontal and vertical spacing in flex/grid layouts - ✅ Use
paddingfor internal component spacing - ✅ Prefer even gap values:
gap-2,gap-4,gap-6,gap-8 - ❌ Avoid
space-y-*utilities - ❌ Avoid
mt-*/margin-topfor spacing between sibling elements - ⚠️ Exception:
mt-*acceptable only for icon alignment with text (e.g.mt-1for small icons)
Rationale: gap-* is predictable in flex/grid layouts and avoids the margin-collapsing issues that space-y-*
and margin-top introduce.
Colour System
src/app/globals.css is the source of truth for the palette; DESIGN.md at the repo root
documents it and the HeroUI token bindings. If the two ever disagree, globals.css wins.
- Dark mode variables are in
src/app/globals.css. Use HeroUI surface tokens (bg-surface,bg-surface-secondary) for card/panel backgrounds, never hardcodedbg-white - ⚠️ Reserve
text-whitefor image overlays only - ❌ Never hardcode hex values in charts — use
var(--chart-N), index-based for per-bar colouring (fill={var(--chart-${index + 1})}). Single-highlight charts usevar(--chart-1)plusbg-default-200
OpenGraph Images
Share images come only from the opengraph-image.tsx / twitter-image.tsx file conventions; pages
never set openGraph.images or twitter.images (a page-level images key replaces the file-based
image, and a page's own openGraph block also discards any image inherited from a parent segment, so
every segment whose page sets openGraph carries its own pair). Cards live in src/lib/og/cards and
data in src/lib/og/data.ts; see the Next.js opengraph-image and twitter-image file conventions.
Constraints:
- Inline
styleobjects only (no CSS classes) - Flexbox supported, Grid not supported
- Font files must be loaded explicitly (
.ttf) - Server-side only (no React hooks)
Testing: visit /coe/results/opengraph-image directly, or use the Facebook/Twitter/LinkedIn social debuggers.
Testing Strategy
Test descriptions start with "should" (it("should render title and children"), not it("renders …")).
Environment Configuration
Web-specific variables (see root AGENTS.md for the cross-cutting ones):
BLOB_STORE_ID/BLOB_WEBHOOK_PUBLIC_KEY: Vercel Blob for car logos and hero images (via@motormetrics/logosand@motormetrics/ai). Auth is OIDC:@vercel/blobreadsVERCEL_OIDC_TOKENandBLOB_STORE_IDfrom the environment, so the legacyBLOB_READ_WRITE_TOKENis no longer used. Runvercel env pulllocallyNEXT_PUBLIC_FEATURE_FLAG_UNRELEASED: env switch for leftover unreleased UI (PQP renewal comparison). Preview/production gates for other surfaces use Vercel Flags insrc/flags.ts, not this public env varFLAGS/FLAGS_SECRET: Vercel Flags server SDK key and signing secret, used by the Flags SDK (src/flags.ts). Both are set per environment on Vercel; runvercel env pullto sync locally. Without them, flags fall back to theirdefaultValue(false). Toggle independently in preview vs production via the dashboard orvercel flags enable|disable --environment. Keys:advertise-page,blog-popular-posts. (advertise-navandblog-navwere removed: they were read at request time in the main layout, which pulled every page out of the static shell.)QSTASH_TOKEN/QSTASH_CURRENT_SIGNING_KEY/QSTASH_NEXT_SIGNING_KEY: Upstash QStash. The hourly (0 * * * *)ev-charging-liveschedule lives in QStash, notvercel.tscrons; it forwardsCRON_SECRETas the bearer token so the route needs no QStash-specific verificationVERCEL_ENV: social media redirects and production-only features activate only when this is"production"NEXT_PUBLIC_VERCEL_URL: client-side deployment URL, without thehttps://protocol.SITE_URLfalls back to it whenNEXT_PUBLIC_SITE_URLis unset
Vercel Related Projects: vercel.ts at the web app root references API project ID
prj_fyAvupEssH3LO4OQFDWplinVFlaI, resolved via withRelatedProject() in src/config/index.ts. This means preview
deployments reach the right API automatically, with no per-environment configuration. Falls back to
NEXT_PUBLIC_API_URL, then https://api.motormetrics.app.