Project Rules & Lessons Learned
This guide synthesizes project rules and lessons learned from multiple production projects. Use it to establish effective CLAUDE.md/GEMINI.md files for your own projects and avoid common pitfalls.
Quick Navigation:
- Cloudflare Workers / Edge-First
- React / Frontend Apps
- Signal / Messaging Bots
- Python / Full-Stack Apps
- Cross-Cutting Concerns
Cloudflare Workers / Edge-First
Section titled “Cloudflare Workers / Edge-First”Key Stack Components
Section titled “Key Stack Components”| Service | Purpose | Alternative |
|---|---|---|
| Workers | Edge compute, API routes | Vercel Edge Functions, Deno Deploy |
| D1 | SQLite database | PlanetScale, Turso, Neon |
| R2 | Object storage | AWS S3, Backblaze B2 |
| KV | Key-value cache | Redis, Upstash |
| Workers AI | ML inference | OpenAI API, Replicate |
| Pages | Static hosting + Functions | Vercel, Netlify |
Critical Deployment Pattern
Section titled “Critical Deployment Pattern”# CORRECT: Build first, then deploy dist directorynpm run buildrsync -av --delete functions/ dist/functions/npx wrangler pages deploy dist --project-name=your-project
# WRONG: Deploying root directory serves development index.htmlnpx wrangler pages deploy . --project-name=your-project # CAUSES BLANK PAGE!Why this fails: Root index.html references /src/main.tsx (development). Production needs bundled assets in /assets/.
D1 Database Best Practices
Section titled “D1 Database Best Practices”-- ALWAYS use lowercase tables with snake_case fieldsCREATE TABLE events ( id TEXT PRIMARY KEY, start_datetime TEXT NOT NULL, max_participants INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP);
-- NEVER use PascalCase (causes FK constraint failures)-- Wrong: CREATE TABLE "Event" (...)Real Incident (Jan 2026): Orders from logged-in users silently failed for 3 days because Order table had FK referencing empty User table instead of populated users table. Stripe webhooks returned 200 OK while data was lost.
Timezone Handling
Section titled “Timezone Handling”Workers run in UTC. Convert to local timezone for user-facing calculations:
function convertToEasternTime(utcDate: Date): { dayOfWeek: number; hour: number } { const formatter = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', weekday: 'short', hour: 'numeric', hour12: false }); // Parse formatted parts to get correct local day/time}Bug Example: Date planner recommended closed venues because it checked Friday’s hours instead of Thursday’s (UTC vs EST mismatch).
KV Caching Patterns
Section titled “KV Caching Patterns”// Check cache firstconst cacheKey = `menu:v${version}:${category}`;const cached = await env.CACHE.get(cacheKey, 'json');if (cached) return cached;
// Fetch and cacheconst data = await fetchFromD1();await env.CACHE.put(cacheKey, JSON.stringify(data), { expirationTtl: 3600 // 1 hour});return data;Weather API Fallback Chain
Section titled “Weather API Fallback Chain”When using external APIs, implement fallback chains:
1. NWS API (api.weather.gov) - Primary, no rate limits2. Open-Meteo API - Fallback if NWS fails3. Seasonal averages - Last resort onlyBug Example: Open-Meteo hit rate limits (429), system silently used identical fallback data for all 7 days. Weather modifiers showed 1.0 (no adjustment).
React / Frontend Apps
Section titled “React / Frontend Apps”React Error #185 - Infinite Loop Prevention
Section titled “React Error #185 - Infinite Loop Prevention”NEVER call parent callbacks from useEffect:
// BROKEN - causes infinite loopuseEffect(() => { onScoresChange?.(scores, assessment); // Triggers parent re-render!}, [scores]);
// FIXED - call from event handlers onlyconst handleScoreChange = (criterion: string, value: number) => { const newScores = { ...scores, [criterion]: value }; setScores(newScores); onScoresChange?.(newScores, calculateAssessment(newScores));};Stable Array References
Section titled “Stable Array References”// BROKEN - creates new array every render<Child historicalData={data.map(d => ({ ...d }))} />
// FIXED - memoize transformationsconst stableData = useMemo(() => data.map(d => ({ ...d })), [data]);<Child historicalData={stableData} />Double JSON Parsing Bug
Section titled “Double JSON Parsing Bug”Data often gets parsed multiple times between API → storage → retrieval:
// BROKEN - safeJSONParse returns {} for objectsconst parsedData = safeJSONParse(currentAnalysis.data, {});
// FIXED - check type firstconst parsedData = typeof currentAnalysis.data === 'object' ? currentAnalysis.data : safeJSONParse(currentAnalysis.data, {});SPA Routing on Cloudflare Pages
Section titled “SPA Routing on Cloudflare Pages”Create /public/_redirects for client-side routing:
# API routes go to Functions/api/* 200
# Static assets serve directly/assets/* 200
# All other routes serve index.html/* /index.html 200Without this: Routes like /tools, /settings return 404 HTML, browser tries to execute as JavaScript → MIME type error.
Safari-Specific Issues
Section titled “Safari-Specific Issues”Clipboard API requires synchronous user gesture:
// BROKEN - async gap loses user gestureconst blob = await generateImage();await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
// FIXED - pass Promise to ClipboardItemconst clipboardItem = new ClipboardItem({ 'image/png': generateImage().then(blob => blob)});await navigator.clipboard.write([clipboardItem]);Mobile Safari CORS for R2:
- Even with CORS configured, mobile Safari fails
- Solution: Serve critical images locally from
public/images/
Tailwind v4 Gotchas
Section titled “Tailwind v4 Gotchas”/* container class is broken in v4 *//* NEVER: container mx-auto *//* ALWAYS: w-full max-w-7xl mx-auto */
/* Default border color changed to currentColor *//* Add explicit: border-gray-200 */Signal / Messaging Bots
Section titled “Signal / Messaging Bots”Docker Network DNS Resolution
Section titled “Docker Network DNS Resolution”Use full container names when connected to multiple networks:
# WRONG - ambiguous, resolves to wrong postgresDB_HOST=postgres
# CORRECT - full container nameDB_HOST=signal-bot-postgresIncident: Bot appeared to work but wrote data to wrong database (system-postgres instead of signal-bot-postgres).
Signal-cli Technical Rules
Section titled “Signal-cli Technical Rules”Mention positions use UTF-16 code units (JavaScript string indices):
// CORRECT - use string indicesconst start = message.indexOf(name);const length = name.length;
// WRONG - UTF-8 bytes cause position driftconst start = Buffer.byteLength(message.substring(0, index), 'utf8');Why: Emojis are 4 bytes UTF-8 but 2 code units UTF-16. Every emoji before a mention shifts position by 2.
Signal Text Styling
Section titled “Signal Text Styling”{ "method": "send", "params": { "message": "Bold text here", "textStyles": ["0:9:BOLD"] // NOT "textStyle" (singular)! }}Deployment Safety
Section titled “Deployment Safety”# CATASTROPHIC: rsync --delete to wrong directory deleted:# - docker-compose.yml# - Signal account registration (UNRECOVERABLE)# - .env with credentials
# SAFE: Always use deploy.sh which:# 1. Syncs to correct subdirectory# 2. Excludes data/, .env, signal-data/# 3. Uses --dry-run first./deploy.sh deploy --no-cacheMinIO Dual Client Pattern
Section titled “MinIO Dual Client Pattern”// Internal client for API operationsconst internalClient = new S3Client({ endpoint: 'http://minio-internal:9000'});
// Public client for presigned URLs (signatures must match public endpoint)const presignClient = new S3Client({ endpoint: 'https://s3.public.domain'});Python / Full-Stack Apps
Section titled “Python / Full-Stack Apps”Security-First Development
Section titled “Security-First Development”# NEVER use eval() on user input# Use safe parsers like ast.literal_eval() or dedicated libraries
# Whitelist inputs instead of blacklistingALLOWED_CHARS = set('0123456789+-*/(). ')if not all(c in ALLOWED_CHARS for c in user_input): raise ValueError("Invalid characters")Database Session Management
Section titled “Database Session Management”# NEVER share sessions across threads# Create new session per request
from contextlib import contextmanager
@contextmanagerdef get_db_session(): session = SessionLocal() try: yield session session.commit() except: session.rollback() raise finally: session.close()OAuth Redirect Patterns
Section titled “OAuth Redirect Patterns”# Store state in session before redirectsession['oauth_state'] = secrets.token_urlsafe(32)session['return_url'] = request.referrer
# Verify state on callbackif request.args.get('state') != session.pop('oauth_state', None): return abort(403, 'State mismatch')Environment Drift Prevention
Section titled “Environment Drift Prevention”# Always sync configuration files, not just source codersync -avz docker-compose.yml nginx.conf server:/path/
# Verify environment variables post-deploymentssh server "docker exec container printenv | grep 'CRITICAL_VAR'"
# Fail fast on startupif not os.environ.get('DATABASE_URL'): sys.exit("FATAL: DATABASE_URL not set")Cross-Cutting Concerns
Section titled “Cross-Cutting Concerns”API Field Naming Conventions
Section titled “API Field Naming Conventions”Frontend sends camelCase, backend expects snake_case:
// Backend should accept bothconst eventType = body.event_type || body.eventType;const sessionId = body.session_id || body.sessionId;Bug Example: Analytics tracking silently failed for weeks because frontend sent deviceId but backend validated device_id. sendBeacon is fire-and-forget with no error callback.
Null Value Handling
Section titled “Null Value Handling”// WRONG - || treats 0 as falsycost: selected.average_cost || stopToSwap.cost
// CORRECT - ?? only catches null/undefinedconst duration = selected.typical_duration ?? stopToSwap.duration ?? 60;Atomic Database Updates
Section titled “Atomic Database Updates”-- CORRECT: Single atomic query prevents race conditionsUPDATE eventsSET current_participants = current_participants + ?WHERE id = ? AND (current_participants + ?) <= max_participants;
-- Check result.changes === 0 means event is fullWebhook Reliability Pattern
Section titled “Webhook Reliability Pattern”// Order of operations matters!async function handleStripeWebhook(event) { // 1. Save to database FIRST await saveOrder(event.data);
// 2. Then send confirmation email await sendEmail(event.data);
// 3. Print label last await printLabel(event.data);}
// NOT: email → print → save (if save fails, no record but email sent)Pre-Change Safety Protocol
Section titled “Pre-Change Safety Protocol”# 1. Check for uncommitted changesgit status
# 2. If changes exist, commit or stash firstgit stash
# 3. Pull latestgit pull origin main
# 4. ALWAYS run --dry-run before rsyncrsync -avz --dry-run source/ dest/Post-Deployment Verification
Section titled “Post-Deployment Verification”# Verify correct content is servedcurl -sL "https://your-site.com" | grep -o '<title>[^<]*</title>'
# Check database migrations appliednpx wrangler d1 execute db --remote \ --command="SELECT name FROM sqlite_master WHERE type='table';"
# Test critical API endpointscurl -X POST https://api.example.com/healthCLAUDE.md Template
Section titled “CLAUDE.md Template”Based on these lessons, here’s a recommended CLAUDE.md structure:
# Project Name - CLAUDE.md
## Critical Rules (MUST READ)- Deployment command: `./deploy.sh`- Database naming: lowercase tables, snake_case fields- NEVER use [specific anti-pattern]
## Stack- Runtime: Cloudflare Workers- Database: D1- Frontend: React + Vite
## Common Commands\`\`\`bashnpm run dev # Local development./deploy.sh # Production deploy\`\`\`
## Lessons Learned### [Date] - [Issue Title]**Problem:** Brief description**Root Cause:** Technical explanation**Fix:** Code or command that resolved it**Prevention:** How to avoid in futureRelated Resources
Section titled “Related Resources”- Claude Code Guide - Setup, pricing, and usage
- Context7 MCP - Documentation fetching
- Cloudflare Stack - Edge-first architecture
This guide is synthesized from production projects including Downtown-Guide, Muse & Co, Community-Signal-Moderation-Bot, ResearchToolsPy, and others. Last updated: January 2026.