Files
Achraf Ghellach 60f5d8e19f feat: add WhatsApp Business Cloud API adapter (#102)
* feat: add WhatsApp Business Cloud API adapter

Add @chat-adapter/whatsapp with support for sending/receiving messages,
reactions, interactive reply buttons, typing indicators, and webhook
verification via the Meta Graph API. Includes full test suite,
documentation updates, and workspace/turbo configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add media download, attachments, and location support to WhatsApp adapter

- Add downloadMedia() public method for fetching images, documents,
  audio, video, and stickers via the Graph API (two-step: URL then binary)
- Populate message attachments with lazy fetchData() for all media types
- Add location support with Google Maps URL and structured text
- Add audio, video, sticker, and location fields to WhatsAppInboundMessage
- Set isMention: true on all messages (WhatsApp DMs are always direct)
- Update parseMessage to include attachments and isMention
- Add 10 new tests covering all media types, locations, and isMention
- Update docs feature matrix to reflect media receive support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review feedback for WhatsApp adapter

- Validate Graph API response before accessing messages[0].id in
  sendTextMessage and sendInteractiveMessage
- Escape backticks and backslashes in escapeWhatsApp()
- Apply escapeWhatsApp() to renderText() content in all style branches
- Use webhook phoneNumberId in buildMessage() instead of this.phoneNumberId
- Encode proper threadId in parseMessage() instead of empty string
- Strict decodeThreadId() validation (exactly 2 segments after prefix)
- Add tests for extra segments in decodeThreadId and threadId in parseMessage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Migrate improvements from #179

Bring over several enhancements from chitru's WhatsApp adapter PR (#179):

- Voice message support (separate from audio)
- Legacy button response handling (template quick replies)
- Callback data encoding/decoding for interactive reply round-trips
- Message truncation at WhatsApp's 4096 char limit
- Example app integration (adapters, webhook route, package.json)
- GET webhook forwarding for WhatsApp verification challenges
- Package README and changeset
- Tests for all new functionality (68 total)

Co-Authored-By: Chitru Shrestha <chitra.shrestha@akuru.com.au>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): add error handling for inbound message processing

Wrap handleInboundMessage calls in try/catch to log errors if
synchronous processing fails (e.g., thread ID encoding). The async
processing already has its own error handling in Chat.processMessage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): prevent markdown regex from matching across newlines

Use [^\n*] and [^\n~] in fromWhatsAppFormat regex to prevent bold/strike
spans from merging across line boundaries. Adds a regression test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): use WhatsAppInteractiveMessage type instead of object

Replace the untyped `object` parameter in sendInteractiveMessage with
the proper WhatsAppInteractiveMessage type for full type safety.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): hoist emoji mapping to module-level constant

Move the emoji name-to-unicode mapping out of resolveEmoji() so it is
not re-allocated on every call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): remove duplicate JSDoc comment in types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(example): add startTyping to WhatsApp recording methods

The adapter supports typing indicators but the method was missing from
the recording proxy list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): fix formatting and add package to readme test allowlist

Fix line-length formatting in markdown.ts regex and add
@chat-adapter/whatsapp to the valid packages list in readme tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): use defaultEmojiResolver instead of custom emoji map

Replace the hand-rolled EMOJI_MAP with the shared defaultEmojiResolver
from the chat SDK. WhatsApp uses unicode emoji like GChat, so toGChat()
provides the correct mapping with broader coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): make Graph API version configurable

Add apiVersion option to WhatsAppAdapterConfig (defaults to v21.0)
so users can upgrade without waiting for a package release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): validate lat/lng before constructing Google Maps URL

Coerce and validate latitude/longitude with Number.isFinite() to
prevent unexpected URL construction from malformed webhook payloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): throw on editMessage instead of silently sending new message

Callers expecting an edit would get duplicate messages with the silent
fallback. Throwing makes the unsupported operation explicit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): document regex asymmetry between toWhatsApp and fromWhatsApp

Explain why toWhatsAppFormat doesn't need newline guards like
fromWhatsAppFormat does — the standard markdown parser output
never produces spans crossing line boundaries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): document callback data passthrough behavior

Add comments explaining that non-prefixed and malformed callback data
is intentionally passed through for legacy/external button IDs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): add editMessage and deleteMessage to recording methods

Include all adapter methods in the recording list for complete
debugging traces, even for unsupported operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): preserve escaped formatting chars in toWhatsAppFormat

Escaped asterisks and tildes in standard markdown (e.g. \* and \~) are
now preserved through the conversion pipeline so WhatsApp renders them
as literal characters instead of misinterpreting them as formatting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): split long messages instead of truncating

Replace silent truncation at 4096 chars with message splitting that
breaks on paragraph (\n\n) then line (\n) boundaries, sending multiple
messages so no content is lost. Adds 8 tests for the splitting logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(whatsapp): align editMessage/deleteMessage behavior and docs

- Fix README: editMessage/deleteMessage both throw, not fallback/no-op
- Fix editMessage JSDoc to reflect it throws
- Make deleteMessage throw instead of silently warning (consistent with editMessage)
- Bump @types/node to ^25.3.2 to match monorepo
- Add sample-messages.md with webhook payload examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs(whatsapp): add adapter documentation page

Add whatsapp.mdx covering installation, usage, Meta app setup,
webhook config, interactive messages, media attachments, 24-hour
messaging window, configuration, features, and troubleshooting.
Also add WhatsApp to the adapters navigation in meta.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add whatsapp adapter debug logging and try/catch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(whatsapp): convert emoji placeholders in outgoing messages

WhatsApp adapter was sending raw {{emoji:wave}} placeholders instead of
Unicode emoji. Apply convertEmojiPlaceholders on all outgoing paths:
text messages, card fallback text, and interactive message fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix button rendering and streaming (needs to buffer)

* fix(example): handle editMessage failure on WhatsApp

WhatsApp Cloud API doesn't support message editing. Catch the error
in the demo "processing" animation and send a follow-up instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(chat): add onDirectMessage handler, stop treating DMs as mentions

DMs now route to dedicated onDirectMessage handlers instead of being
forced through onNewMention. If no DM handlers registered, DMs fall
through to onNewMention for backward compat. Adapters no longer set
isMention=true for DMs — the Chat SDK handles routing via adapter.isDM().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(chat): always route DMs to onDirectMessage regardless of subscription

Previously, onDirectMessage only fired for unsubscribed DM threads.
Subscribed DMs were routed to onSubscribedMessage, which was confusing
on non-threaded platforms (WhatsApp, Telegram) where all DMs share one
threadId — after the first message, onDirectMessage never fired again.

Now, DMs always route to onDirectMessage first, and onSubscribedMessage
only handles non-DM subscribed threads. Backward compat is preserved:
if no onDirectMessage handlers are registered, DMs fall through as
mentions.

The example bot is simplified accordingly — onDirectMessage now fetches
conversation history via fetchMessages each time instead of relying on
subscribe() and stored state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(chat): pass channel as third argument to DirectMessageHandler

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(example): reply to channel instead of thread in DM handler

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(example): use thread instead of channel for DM operations

Channel ID is only two parts (whatsapp:{phoneNumberId}) which isn't a
valid conversation target on WhatsApp. The thread ID includes the user
phone and is required for startTyping/post/fetchMessages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Revert "fix(example): use thread instead of channel for DM operations"

This reverts commit f4801d7015.

* fix(adapters): return valid thread IDs from channelIdFromThreadId

WhatsApp's channelIdFromThreadId was stripping the user WA ID, producing
an invalid ID that caused ValidationError on channel operations like
startTyping(). Since every WhatsApp conversation is a 1:1 DM, channel
and thread are identical.

Telegram's channelIdFromThreadId was returning a raw chatId without the
telegram: prefix, which is not a valid thread ID for adapter operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(chat): normalize fullStream in Channel.post() to extract text deltas

Channel.post() was coercing AI SDK fullStream objects to strings via +=,
producing "[object Object]" output. Now uses fromFullStream() to extract
text-delta events, matching how Thread.post() already handles streams.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(example): use thread.allMessages for DM history instead of adapter directly

The DM handler was calling channel.adapter.fetchMessages() which always
returns empty on WhatsApp (no native history API). Now uses
thread.allMessages which falls back to the persisted message history
cache, giving the AI conversation context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(chat): add message history support to Channel for DM platforms

Channel now falls back to the persisted message history cache when the
adapter lacks native message fetch (e.g. WhatsApp, Telegram). Incoming
messages are persisted under both thread and channel IDs. Outgoing
messages from channel.post() are also persisted.

The example DM handler now uses channel.messages instead of calling the
adapter directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(whatsapp): improve markdown rendering and remove broken typing indicator

- Convert headings to bold text, thematic breaks to text separators,
  and tables to code blocks (WhatsApp doesn't support these)
- Convert standard italic (*text*) to WhatsApp italic (_text_) since
  WhatsApp uses *text* for bold
- Make startTyping a no-op (Cloud API doesn't support typing indicators)
- Update channelIdFromThreadId test for channel===thread change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(example): reverse channel.messages to chronological order for AI

channel.messages yields newest first but AI expects chronological order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(chat): auto-sort messages chronologically in toAiMessages

toAiMessages now sorts by dateSent (oldest first) so callers don't need
to worry about iteration order from channel.messages or thread.messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(chat): pass accumulated stream text as markdown in Channel.post()

Stream text was posted as a plain string, bypassing the adapter's format
converter. Now wraps it as { markdown: accumulated } so headings, bold,
italic etc. are properly converted for each platform.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(whatsapp): use stringifier options for emphasis and bullets

Use emphasis: '_' and bullet: '-' options in stringifyMarkdown so the
only * in output is **strong**, avoiding conflicts between list bullets
and italic markers. Simplifies toWhatsAppFormat to only convert
**bold** -> *bold* and ~~strike~~ -> ~strike~.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(whatsapp): flatten bold inside headings to avoid triple asterisks

When AI outputs headings with bold text like `## **Choose React if:**`,
the heading-to-bold conversion created nested strong nodes producing
`***text***`. Now flattens strong children in headings so they merge
into a single bold span.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(whatsapp): add full toBe assertion for complex markdown conversion

Also use ━━━ for thematic breaks instead of --- to avoid remark escaping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add WhatsApp replay tests from production recordings

Adds WhatsApp DM replay test infrastructure:
- Fixture from real webhook recordings (dm/whatsapp.json)
- WhatsApp test utilities with HMAC-signed request factory and
  Graph API fetch mock (whatsapp-utils.ts)
- 6 replay tests covering DM handling, thread/channel IDs, message
  sending, status update filtering, sequential messages, and
  message history persistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(whatsapp): fix type narrowing in replay test after merge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update WhatsApp logo

* Update adapters.json

* Update logos.tsx

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Hayden Bleasel <hello@haydenbleasel.com>
Co-authored-by: Chitru Shrestha <chitra.shrestha@akuru.com.au>
Co-authored-by: Malte Ubl <malte.ubl@gmail.com>
2026-03-10 15:16:10 -07:00

13 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Build Commands

# Install dependencies
pnpm install

# Build all packages (uses Turborepo)
pnpm build

# Type-check all packages
pnpm typecheck

# Check all packages (linting and formatting)
pnpm -w run check

# Auto-fix linting and formatting issues
pnpm -w run check --write

# Check for unused exports/dependencies
pnpm knip

# Run all tests
pnpm test

# Run full validation. ALWAYS do this before declaring a task to be done.
pnpm validate


# Run dev mode (watch for changes)
pnpm dev

# Build a specific package
pnpm --filter chat build
pnpm --filter @chat-adapter/slack build
pnpm --filter @chat-adapter/gchat build
pnpm --filter @chat-adapter/teams build

# Run tests for a specific package
pnpm --filter chat test
pnpm --filter @chat-adapter/integration-tests test

# Run a single test file
pnpm --filter @chat-adapter/integration-tests test src/slack.test.ts

Code Style

  • Install dependencies with pnpm add rather than manually editing package.json
  • sample-messages.md files in adapter packages contain real-world webhook logs as examples

Architecture

This is a pnpm monorepo using Turborepo for build orchestration. All packages use ESM ("type": "module"), TypeScript, and tsup for bundling.

Package Structure

  • packages/chat-sdk - Core SDK (chat package) with Chat class, types, and markdown utilities (mdast-based)
  • packages/adapter-slack - Slack adapter using @slack/web-api
  • packages/adapter-gchat - Google Chat adapter using googleapis
  • packages/adapter-teams - Microsoft Teams adapter using botbuilder
  • packages/state-memory - In-memory state adapter (for development/testing)
  • packages/state-redis - Redis state adapter (for production)
  • packages/adapter-whatsapp - WhatsApp adapter using Meta Cloud API
  • packages/integration-tests - Integration tests against real platform APIs
  • examples/nextjs-chat - Example Next.js app showing how to use the SDK

Core Concepts

  1. Chat (packages/chat-sdk/src/chat.ts in chat package) - Main entry point that coordinates adapters and handlers
  2. Adapter - Platform-specific implementations (Slack, Teams, Google Chat). Each adapter:
    • Handles webhook verification and parsing
    • Converts platform-specific message formats to/from normalized format
    • Provides FormatConverter for markdown/AST transformations
  3. StateAdapter - Persistence layer for subscriptions and distributed locking
  4. Thread - Represents a conversation thread with methods like post(), subscribe(), startTyping()
  5. Message - Normalized message format with text, formatted (mdast AST), and raw (platform-specific)

Thread ID Format

All thread IDs follow the pattern: {adapter}:{channel}:{thread}

  • Slack: slack:C123ABC:1234567890.123456
  • Teams: teams:{base64(conversationId)}:{base64(serviceUrl)}
  • Google Chat: gchat:spaces/ABC123:{base64(threadName)}

Message Handling Flow

  1. Platform sends webhook to /api/webhooks/{platform}
  2. Adapter verifies request, parses message, calls chat.handleIncomingMessage()
  3. Chat class acquires lock on thread, then:
    • Checks if thread is subscribed -> calls onSubscribedMessage handlers
    • Checks for @mention -> calls onNewMention handlers
    • Checks message patterns -> calls matching onNewMessage handlers
  4. Handler receives Thread and Message objects

Formatting System

Messages use mdast (Markdown AST) as the canonical format. Each adapter has a FormatConverter that:

  • toAst(platformText) - Converts platform format to mdast
  • fromAst(ast) - Converts mdast to platform format
  • renderPostable(message) - Renders a PostableMessage to platform string

Testing

Test Utilities

The packages/chat/src/mock-adapter.ts file provides shared test utilities:

  • createMockAdapter(name) - Creates a mock Adapter with vi.fn() mocks for all methods
  • createMockState() - Creates a mock StateAdapter with working in-memory subscriptions, locks, and cache
  • createTestMessage(id, text, overrides?) - Creates a test Message object
  • mockLogger - A mock Logger that captures all log calls

Example usage:

import { createMockAdapter, createMockState, createTestMessage } from "./mock-adapter";

const adapter = createMockAdapter("slack");
const state = createMockState();
const message = createTestMessage("msg-1", "Hello world");

Recording & Replay Tests

Production webhook interactions can be recorded and converted into replay tests:

  1. Recording: Enable RECORDING_ENABLED=true in deployed environment. Recordings are tagged with git SHA.
  2. Export: Use pnpm recording:list and pnpm recording:export <session-id> from examples/nextjs-chat
  3. Convert: Extract webhook payloads and create JSON fixtures in packages/integration-tests/fixtures/replay/
  4. Test: Write replay tests using helpers from replay-test-utils.ts

See packages/integration-tests/fixtures/replay/README.md for detailed workflow.

Downloading and Analyzing Recordings

When debugging production issues, download recordings for the current git SHA:

cd examples/nextjs-chat

# Get current SHA
git rev-parse HEAD

# List all recording sessions (look for sessions starting with your SHA)
pnpm recording:list

# Export a specific session to a file
pnpm recording:export session-<SHA>-<timestamp>-<random> 2>&1 | \
  grep -v "^>" | grep -v "^\[dotenv" | grep -v "^$" > /tmp/recording.json

# View number of entries
cat /tmp/recording.json | jq 'length'

# Group webhooks by platform
cat /tmp/recording.json | jq '[.[] | select(.type == "webhook")] | group_by(.platform) | .[] | {platform: .[0].platform, count: length}'

# Extract and analyze platform-specific webhooks
cat /tmp/recording.json | jq '[.[] | select(.type == "webhook" and .platform == "teams") | .body | fromjson]' > /tmp/teams-webhooks.json
cat /tmp/recording.json | jq '[.[] | select(.type == "webhook" and .platform == "slack") | .body | fromjson]' > /tmp/slack-webhooks.json
cat /tmp/recording.json | jq '[.[] | select(.type == "webhook" and .platform == "gchat") | .body | fromjson]' > /tmp/gchat-webhooks.json

# Inspect specific webhook fields (e.g., Teams channelData)
cat /tmp/teams-webhooks.json | jq '[.[] | {type, text, channelData, value}]'

Changesets (Release Flow)

This monorepo uses Changesets to manage versioning and changelogs. Every PR that changes a package's behavior must include a changeset.

Creating a changeset

pnpm changeset

You'll be prompted to:

  1. Select the affected package(s) — choose which packages your change touches (e.g., @chat-adapter/slack, chat)
  2. Choose the semver bump — patch for fixes, minor for new features, major for breaking changes
  3. Write a summary — a short description of the change (this goes into the CHANGELOG)

This creates a markdown file in .changeset/ — commit it with your PR.

When to use which bump

  • patch — bug fixes, internal refactors with no API change
  • minor — new features, new exports, new options
  • major — breaking changes (removed exports, changed signatures, dropped support)

Example

pnpm changeset
# → select: @chat-adapter/slack
# → bump: minor
# → summary: Add custom installation prefix support for preview deployments

Publishing (maintainers)

When changesets are merged to main, the Changesets GitHub Action opens a "Version Packages" PR that bumps versions and updates CHANGELOGs. Merging that PR triggers publishing to npm.

Environment Variables

Key env vars used (see turbo.json for full list):

  • SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET - Slack credentials
  • TEAMS_APP_ID, TEAMS_APP_PASSWORD, TEAMS_APP_TENANT_ID - Teams credentials
  • GOOGLE_CHAT_CREDENTIALS or GOOGLE_CHAT_USE_ADC - Google Chat auth
  • WHATSAPP_ACCESS_TOKEN, WHATSAPP_APP_SECRET, WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_VERIFY_TOKEN - WhatsApp credentials
  • REDIS_URL - Redis connection for state adapter
  • BOT_USERNAME - Default bot username

Ultracite Code Standards

This project uses Ultracite, a zero-config preset that enforces strict code quality standards through automated formatting and linting.

Quick Reference

  • Format code: pnpm dlx ultracite fix
  • Check for issues: pnpm dlx ultracite check
  • Diagnose setup: pnpm dlx ultracite doctor

Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable.


Core Principles

Write code that is accessible, performant, type-safe, and maintainable. Focus on clarity and explicit intent over brevity.

Type Safety & Explicitness

  • Use explicit types for function parameters and return values when they enhance clarity
  • Prefer unknown over any when the type is genuinely unknown
  • Use const assertions (as const) for immutable values and literal types
  • Leverage TypeScript's type narrowing instead of type assertions
  • Use meaningful variable names instead of magic numbers - extract constants with descriptive names

Modern JavaScript/TypeScript

  • Use arrow functions for callbacks and short functions
  • Prefer for...of loops over .forEach() and indexed for loops
  • Use optional chaining (?.) and nullish coalescing (??) for safer property access
  • Prefer template literals over string concatenation
  • Use destructuring for object and array assignments
  • Use const by default, let only when reassignment is needed, never var

Async & Promises

  • Always await promises in async functions - don't forget to use the return value
  • Use async/await syntax instead of promise chains for better readability
  • Handle errors appropriately in async code with try-catch blocks
  • Don't use async functions as Promise executors

React & JSX

  • Use function components over class components
  • Call hooks at the top level only, never conditionally
  • Specify all dependencies in hook dependency arrays correctly
  • Use the key prop for elements in iterables (prefer unique IDs over array indices)
  • Nest children between opening and closing tags instead of passing as props
  • Don't define components inside other components
  • Use semantic HTML and ARIA attributes for accessibility:
    • Provide meaningful alt text for images
    • Use proper heading hierarchy
    • Add labels for form inputs
    • Include keyboard event handlers alongside mouse events
    • Use semantic elements (<button>, <nav>, etc.) instead of divs with roles

Error Handling & Debugging

  • Remove console.log, debugger, and alert statements from production code
  • Throw Error objects with descriptive messages, not strings or other values
  • Use try-catch blocks meaningfully - don't catch errors just to rethrow them
  • Prefer early returns over nested conditionals for error cases

Code Organization

  • Keep functions focused and under reasonable cognitive complexity limits
  • Extract complex conditions into well-named boolean variables
  • Use early returns to reduce nesting
  • Prefer simple conditionals over nested ternary operators
  • Group related code together and separate concerns

Security

  • Add rel="noopener" when using target="_blank" on links
  • Avoid dangerouslySetInnerHTML unless absolutely necessary
  • Don't use eval() or assign directly to document.cookie
  • Validate and sanitize user input

Performance

  • Avoid spread syntax in accumulators within loops
  • Use top-level regex literals instead of creating them in loops
  • Prefer specific imports over namespace imports
  • Avoid barrel files (index files that re-export everything)
  • Use proper image components (e.g., Next.js <Image>) over <img> tags

Framework-Specific Guidance

Next.js:

  • Use Next.js <Image> component for images
  • Use next/head or App Router metadata API for head elements
  • Use Server Components for async data fetching instead of async Client Components

React 19+:

  • Use ref as a prop instead of React.forwardRef

Solid/Svelte/Vue/Qwik:

  • Use class and for attributes (not className or htmlFor)

Testing

  • Write assertions inside it() or test() blocks
  • Avoid done callbacks in async tests - use async/await instead
  • Don't use .only or .skip in committed code
  • Keep test suites reasonably flat - avoid excessive describe nesting

When Biome Can't Help

Biome's linter will catch most issues automatically. Focus your attention on:

  1. Business logic correctness - Biome can't validate your algorithms
  2. Meaningful naming - Use descriptive names for functions, variables, and types
  3. Architecture decisions - Component structure, data flow, and API design
  4. Edge cases - Handle boundary conditions and error states
  5. User experience - Accessibility, performance, and usability considerations
  6. Documentation - Add comments for complex logic, but prefer self-documenting code

Most formatting and common issues are automatically fixed by Biome. Run pnpm dlx ultracite fix before committing to ensure compliance.