- Framework: Next.js 16 (App Router, Turbopack)
- Language: TypeScript 5.7+ (strict mode)
- React: 19
- Styling: Tailwind CSS v4
- Package Manager: pnpm
- Deployment: Vercel
pnpm checkThis runs pnpm typecheck && pnpm lint && pnpm build which must all pass with zero errors and zero warnings.
pnpm typecheckrunstsc --noEmitpnpm lintrunseslint . --max-warnings 0pnpm buildrunsnext build— catches CSS parsing, module resolution, and runtime build errors that typecheck and lint miss
Do not commit code that fails any check.
- Strict mode enabled. No
anytypes unless absolutely necessary. - Interface names must be prefixed with
I(e.g.,IBlogPost,IBlogSection). - Use
=== undefined/=== nullinstead of truthy/falsy checks for strict boolean expressions. - Use
??(nullish coalescing) instead of||for fallback values. - Use template literals instead of string concatenation.
- No non-null assertions (
!). Use explicit guards instead. noUnusedLocalsandnoUnusedParametersare enabled. Remove dead code, do not comment it out.noUncheckedIndexedAccessis enabled. Array/object index access returnsT | undefined. Handle it.verbatimModuleSyntaxis enabled. Useimport typefor type-only imports.
- Props must be sorted alphabetically in JSX (
react/jsx-sort-props). - Shorthand props (like
fill) must come before other props. - No nested ternaries. Use separate conditionals.
- No empty functions. Use a named noop or add a comment.
- Escape special characters in JSX text (
'not'). - Curly braces required after
ifconditions. - No
require()imports. Use ES moduleimportsyntax. - No
console.log. Useconsole.warnorconsole.errorif needed.
- In
globals.css, external@import url(...)statements must come before@import "tailwindcss". CSS requires@importrules to precede all other rules except@charsetand@layer. Tailwind v4 expands into a large CSS block, so anything after it is treated as "after all rules." - Use
@theme { }blocks for custom design tokens (colors, fonts). Place after the tailwind import. - Custom utility classes (
.blueprint-grid, etc.) go after the@themeblock.
All components in app/ are Server Components by default. Keep them that way unless you need interactivity.
Server Components should:
- Fetch data directly (no useEffect, no client-side fetching)
- Export
metadataorgenerateMetadatafor SEO - Render static content, pass data down to Client Components when needed
- Use
asyncwhen they need to await data
Do not add "use client" unless the component uses:
- Hooks (
useState,useEffect,usePathname, etc.) - Event handlers (
onClick,onChange,onSubmit) - Browser-only APIs (
window,document,localStorage)
- Add
"use client"as the very first line. - Keep them small. Extract the interactive part into a Client Component and keep the rest server-rendered.
- Do not fetch data in Client Components. Receive it as props from a Server Component parent.
- Do not export
metadatafrom Client Components. It only works in Server Components.
- Place in
app/api/<name>/route.ts. - Export named functions:
GET,POST,PUT,DELETE. - Validate input at the boundary. Use Zod for request body validation.
- Return
NextResponse.json()with appropriate status codes. - Keep secrets server-side. Never expose API keys to the client.
- When using Claude API with tools, always implement a multi-turn loop. The LLM stops after returning tool calls (
stop_reason: "tool_use"). You must send tool results back and let it continue. Without this, the LLM only makes one set of tool calls before stopping. - Cap the loop (e.g., 5 turns) to prevent infinite loops.
- Stream events to the frontend as they arrive, don't wait for the full loop to complete.
- Test every LLM integration with
curlto verify tool calls are actually returned before shipping.
Every page must export metadata (static) or generateMetadata (dynamic):
export const metadata: Metadata = {
title: "Page Title | Haptic",
description: "...",
openGraph: { title: "...", description: "...", type: "website" },
}- Include
openGraphandtwittercard data on all pages. - OG images must be JPEG, under 300KB.
- Use
generateMetadatafor dynamic routes (blog posts).
layout.tsxwraps child routes and persists across navigation. Use for shared UI (header, footer, providers).page.tsxis the unique content for a route.- Do not put page-specific content in layouts.
- The
<Header />is rendered per-page, not in the root layout. The root layout contains<Footer />and providers.
- Use
generateStaticParamsfor static generation of dynamic routes. Exception: routes that fetch from an external API at build time (e.g./data/*/[slug]) — skipgenerateStaticParamsand rely ondynamicParams = true+ ISR (next.revalidatein the fetch). Build-time API fan-out is throttled by Cloudflare onhapticlabs.aiand dies on any one slow slug; see PR #54. - Return
notFound()for invalid params, not a blank page. - Type params with
Promise<{ ... }>(Next.js 16 async params).
- One component per file. Name the file after the component (
header.tsxexportsHeader). - Use named exports, not default exports (except for pages which require default).
- Props interfaces prefixed with
Iand defined above the component. - Destructure props in the function signature.
- Use React Context sparingly and only for truly global UI state (sidebar open/closed).
- Do not use Context for data that could be passed as props through 1-2 levels.
- No external state libraries. The app is simple enough for props + context.
- Always use a stable, unique key. Never use array index as key unless the list is static and never reordered.
- Prefer
item.idoritem.slugas key.
- Name handlers
handleX(e.g.,handleSubmit,handleClick). - For forms, use
onSubmiton the form element, notonClickon the button. - Always call
e.preventDefault()in form submit handlers.
- Use
&&for simple show/hide:{isOpen && <Menu />}. - Use ternary for if/else:
{isActive ? "navy" : "gray"}. - For complex conditions, extract to a variable or early return.
- Use
next/imagewithunoptimizedprop (image optimization is disabled in config). - Always include
alttext. - Provide
widthandheightto prevent layout shift. - Blog/OG images must be JPEG, under 300KB.
- Compress with:
sips -s format jpeg -s formatOptions 82 --resampleWidth 1400 input.png --out output.jpg
app/ # Next.js App Router pages and API routes
api/ # Route handlers (server-side only)
blog/ # Blog index and dynamic post routes
[page]/ # Static pages (team, deploy, etc.)
components/ # Shared React components
content/blog/ # Blog post content (TSX components)
lib/ # Utilities, types, data (blog.ts, structured-data.tsx)
public/ # Static assets (images, robots.txt, llms.txt)
docs/ # Canonical documentation
- Files: kebab-case (
email-capture.tsx,nav-drawer.tsx) - Components: PascalCase (
EmailCapture,NavDrawer) - Utilities/hooks: camelCase (
formatDate,useSidebar) - Types/interfaces: PascalCase with
Iprefix (IBlogPost) - Constants: UPPER_SNAKE_CASE (
SITE_URL)
- Create content component in
content/blog/<slug>.tsx - Register in
content/blog/index.ts(ES import, not require) - Add post metadata to
lib/blog.ts(slug, title, description, author, publishedAt, image) - Run
pnpm check
- Prefer Server Components. They send zero JS to the client.
- Lazy-load heavy Client Components with
next/dynamicif they are below the fold. - Do not import large libraries in Client Components unless necessary. Check bundle impact.
- Use
loading.tsxfor route-level suspense boundaries on slow pages.
- All pages must be mobile-first and responsive. No exceptions.
- All text must meet WCAG AA contrast ratio (4.5:1 minimum). No light gray text on light backgrounds.
- On cream backgrounds, use
text-blueprint-navyfor primary text andtext-blueprint-navy/70for secondary. Never use/40or/50opacity for readable text. cold-gray(#6B7280) is the minimum for secondary text on cream. The old value (#A3A7AC) failed WCAG AA.
- All pages must have proper Open Graph meta tags (title, description, image).
- OG images must be exactly 1200x630px, JPEG, under 300KB.
- Never stretch images to fit OG dimensions. Resize to fit height, then pad with brand cream (#EFECE4):
sips --resampleHeight 630 -s format jpeg -s formatOptions 82 input.png --out /tmp/resized.jpg sips -p 630 1200 --padColor EFECE4 /tmp/resized.jpg --out output.jpg
- Each page should have a unique title and description.
- Never use em dashes. Use commas, periods, or restructure.
- Use "Physical AI" not "robotics" for the broader category.
- Keep copy concise. No buzzwords or marketing speak.