Purpose: This document provides structured guidance for LLM coding agents working on the NEO monorepo. Follow these instructions to maintain code quality, consistency, and project integrity.
NEO is a Yarn workspaces monorepo with Turborepo (turbo.json): shared application code lives in packages/app, the web app in apps/next (Next.js 16, App Router), and the native app in apps/expo (Expo 54, React Native). Styling uses Uniwind (Tailwind CSS v4 syntax for React Native) on native and Tailwind CSS 4 (via @tailwindcss/postcss) on web, with semantic design tokens from settings—see README.md for versions, scripts (yarn web, yarn native), and setup, and uniwind.md for the NativeWind→Uniwind migration notes.
Product integration (see also .cursorrules): the frontend targets UNA CMS APIs (not a generic REST backend). Use app/lib/fetcher for requests, follow /api.php?r=module/action/Template patterns, respect proxy/env configuration for web vs native, and use Pusher where real-time updates are required. Authentication and menus follow existing currentUser and app/lib/util patterns. Routing: Expo Router under apps/expo/app/, Next.js App Router under apps/next/app/.
Backend contract for UNA developers (una-api skill): blocks and services must tolerate guest (non-logged-in) users—guard user/profile access, avoid PHP fatals, and return JSON for NEO. A fatal or HTML error page on a block breaks page JSON parsing and can blank the whole screen.
How installed skills help: Vercel-oriented skills add React/Next performance guidance, cache/PPR notes, Turborepo usage, UI/accessibility audits, deploy automation, browser automation, and Expo/native UI patterns. They do not replace NEO-specific rules above when the two conflict—see Skill precedence.
Skills are installed with the Vercel Agent Skills workflow (npx skills add <owner/repo>, optionally --skill <id>). Canonical copy: .agents/skills/ at the repo root (one directory per skill, typically with SKILL.md). The CLI also writes skills-lock.json at the repo root (version pins/hashes for reproducibility) and may create .claude/skills/ symlinks into .agents/skills/. Trae and Windsurf are not used—.trae/ and .windsurf/ are gitignored if recreated. This repo also ships local skills (not from npx skills): una-api and react-doctor. Commit .agents/, skills-lock.json, and .claude/skills/ so the team shares the same capabilities; they are not ignored by default.
CLI note: the skills package uses different --skill ids than some GitHub folder names—for vercel-labs/agent-skills, use ids such as vercel-react-best-practices, vercel-composition-patterns, and vercel-react-native-skills (not always the short folder names). Run npx skills add <owner/repo> interactively or check the CLI’s “Available skills” list if a flag fails.
| Skill (install id / folder) | Source repo | Use when |
|---|---|---|
vercel-react-best-practices |
vercel-labs/agent-skills | React/Next performance, bundles, rerenders, server/client data patterns |
vercel-composition-patterns |
same | Compound components, reducing boolean prop sprawl, scalable component APIs |
vercel-react-native-skills |
same | React Native / Expo performance, lists, navigation, images, gestures |
web-design-guidelines |
same | Auditing web UI for a11y, UX, forms, motion—map suggestions to design tokens where applicable |
deploy-to-vercel |
same | Deploy flows and Vercel-oriented deployment tasks |
next-best-practices |
vercel-labs/next-skills | Next.js app patterns, bundling, fonts, hydration, async |
next-cache-components |
same | Cache Components / use cache / PPR-oriented guidance |
next-upgrade |
same | Next.js upgrade assistance (CLI may flag higher codegen risk—review changes) |
turborepo |
vercel/turborepo | Task graph, caching, CI, monorepo boundaries |
agent-browser |
vercel-labs/agent-browser | Browser automation / debugging workflows tied to that toolchain |
building-native-ui |
expo/skills | Expo Router native UI patterns (tabs, media, controls)—complements shared packages/app code |
uniwind |
uni-stack/uniwind | Uniwind / Tailwind CSS v4 styling in React Native: className on RN components, theming, variants, Metro config |
una-api |
NEO (local, .agents/skills/una-api) |
UNA CMS guest-safe blocks, JSON API responses, fetcher//api.php?r=... alignment with packages/app |
react-doctor |
NEO (local, .agents/skills/react-doctor) |
Code-quality sweep when finishing a feature or before committing: lint, dead code, a11y, bundle size, architecture diagnostics |
fetcher, correct /api.php?r=... endpoints, env/proxy rules, and expectations in the una-api skill. Generic Next/React skills may assume arbitrary APIs.packages/app; keep icon.js / iconset.js / iconset.web.js and other platform splits consistent with .cursorrules. For animated Lucide icons (registry, scenes, SVG constraints), follow animated-icons.md.memo/useCallback should be applied only when justified (profiling or clear benefit).apps/next/app only. Shared code in packages/app is predominantly client ('use client') because it must also run under Expo. Do not strip 'use client' from packages/app for “RSC purity.”Extension point for derivative work: packages/app/customization/resources/agents.md and packages/app/customization/resources/claude.md reference this file and CLAUDE.md at the repo root and hold per-project notes (client UNA setup, deployment, team conventions). See packages/app/customization/resources/README.md for the full workflow.
Merge hygiene: On the upstream monorepo, keep those customization files small so they seldom conflict. In branch or fork projects that track upstream, prefer changing only files under packages/app/customization/ (including resources/) instead of editing root AGENTS.md / CLAUDE.md or default files under packages/app/ when a customization hook exists—so upstream merges stay straightforward.
The active monorepo surface is:
apps/next
apps/expo
packages/app
When working on next or expo:
packages/appBefore adding imports, verify the source:
// ✅ CORRECT for main apps
import { Button } from 'app/ui/atoms/button';
import { settings } from 'app/customization/settings';
// ❌ WRONG - bypassing shared app code from feature files
import { SomeExternalComponent } from 'external-lib';
Before implementing features, verify the installed framework versions:
# Check package.json in the relevant app directory
cat apps/next/package.json | grep -E '"next"|"react"'
cat apps/expo/package.json | grep -E '"expo"|"react-native"'
| Framework | Version | Key Changes |
|---|---|---|
| Next.js | 16.3.x | React Compiler support (stable), new caching patterns, use cache directive |
| React | 19.1.x | Actions, use() hook, improved Suspense |
| Expo | 54.x | New Architecture enabled by default, expo-router 6 |
| React Native | 0.81.x | Bridgeless mode, concurrent features |
| Tailwind CSS | 4.2.x | Web via @tailwindcss/postcss |
| Uniwind | 1.6.x | Native Tailwind v4 (apps/expo/global.combined.css) |
If you're unsure about best practices for the installed version:
Next.js 16 has stable React Compiler support. This means:
useMemo, useCallback, and React.memo are often unnecessary| Layer | Default | Notes |
|---|---|---|
apps/next/app |
Server Components | Thin shells: fetch page JSON, pass into shared client tree |
packages/app |
Client ('use client') |
Shared with Expo/React Native — not RSC |
apps/expo |
Client / native | Expo Router; no RSC |
Prefer Server Components inside apps/next/app when they do not need hooks or browser APIs. Do not refactor shared packages/app UI into RSC.
// ✅ PREFERRED in apps/next/app — Server Component page shell
export default async function Page() {
const data = await fetchData();
return <Root initialData={data} />; // packages/app Root is client
}
// ✅ EXPECTED in packages/app — Client Component
'use client';
export default function InteractiveWidget() {
const [state, setState] = useState();
// ...
}
Use 'use client' in apps/next only for:
useState, useEffect, useRefwindow, document, localStorage, navigatorpackages/app that already needs the client boundary (usually already marked)When you must use client components under Next, wrap them as islands so parent server components can still stream:
// ✅ CORRECT - Island pattern
// components/interactive-widget.client.js
'use client';
export function InteractiveWidget({ initialData }) {
const [state, setState] = useState(initialData);
return <button onClick={() => setState(s => s + 1)}>{state}</button>;
}
// app/page.js (Server Component)
import { Suspense } from 'react';
import { InteractiveWidget } from './components/interactive-widget.client';
export default async function Page() {
const data = await fetchData(); // Server-side fetch
return (
<div>
<h1>{data.title}</h1> {/* Server rendered */}
<Suspense fallback={<div>Loading...</div>}>
<InteractiveWidget initialData={data.count} />
</Suspense>
</div>
);
}
Use the new use cache directive for data caching:
// ✅ Modern caching pattern (Next.js 16+)
import { cacheLife } from 'next/cache';
async function getCachedData() {
'use cache';
cacheLife('hours'); // or 'minutes', 'days', 'weeks', 'max'
return await fetchExpensiveData();
}
// ❌ AVOID - Old pattern
// unstable_cache is deprecated in favor of 'use cache'
use cache directive - For expensive operations needing cachingFollow the established pattern for organizing components:
packages/app/
├── ui/
│ ├── primitives/ # Basic building blocks (View, Text wrappers)
│ ├── atoms/ # Simple, single-purpose components
│ │ ├── button.js
│ │ ├── input.js
│ │ └── badge.js
│ └── molecules/ # Composite components
│ ├── card.js
│ ├── dropdown-menu.js
│ └── profile-hover-card.js
├── components/
│ ├── elements/ # Page section components
│ ├── units/ # List item renderers
│ ├── forms/ # Form components
│ └── nav/ # Navigation components
Never add external components directly to pages/features
// ❌ WRONG - Installing component in page
// app/dashboard/page.js
import { SomeExternalComponent } from 'external-lib';
// ✅ CORRECT - Wrap in ui/atoms or ui/molecules first
// packages/app/ui/atoms/some-component.js
import { SomeExternalComponent } from 'external-lib';
export function SomeComponent(props) {
return <SomeExternalComponent {...props} />;
}
// Then use in pages
import { SomeComponent } from 'app/ui/atoms/some-component';
Check for existing components before creating new ones
# Search for existing components
grep -r "ComponentName" packages/app/ui/
grep -r "similar-functionality" packages/app/
Follow the component map pattern for overridable components
// packages/app/customization/molecules/_map.js
import { componentsMapDefault } from 'app/ui/molecules/_map';
import CustomComponent from 'app/customization/molecules/my-component';
componentsMapDefault['my-component'] = CustomComponent;
export const componentsMap = componentsMapDefault;
When creating components for both web and native:
// component.tsx (shared logic or web default)
export function Component({ ...props }) {
return <View><Text>Web/Shared</Text></View>;
}
// component.native.tsx (native-specific)
export function Component({ ...props }) {
return <View><Text>Native</Text></View>;
}
// component.web.tsx (web-specific, if needed)
export function Component({ ...props }) {
return <div>Web Only</div>;
}
Always use design tokens from settings - never hardcode colors or spacing:
// ✅ CORRECT - Using design tokens
<View className="bg-card text-card-foreground border-border rounded-xl p-4">
// ❌ WRONG - Hardcoded values
<View className="bg-white text-gray-900 border-gray-200 rounded-[12px] p-[16px]">
Reference tokens from packages/app/default/settings.js (and the packages/app/settings/ modules it composes):
| Category | Examples |
|---|---|
| Colors | bg-card, text-foreground, border-border, bg-primary |
| Spacing | Use Tailwind scale: p-2, p-3, p-4, gap-2, gap-4 |
| Typography | text-sm, text-base, text-lg, font-medium, font-semibold |
| Borders | rounded-lg, rounded-xl, rounded-2xl, border, border-border/60 |
| Shadows | shadow-sm, shadow-md, shadow-lg |
Use button variants from settings:
// Available variants (from settings.theme.button_styles)
<Button variant="default" /> // Neutral/popover style
<Button variant="primary" /> // Primary action
<Button variant="secondary" /> // Secondary action
<Button variant="text" /> // Text-only button
<Button variant="link" /> // Link-style button
<Button variant="outline" /> // Bordered button
<Button variant="danger" /> // Destructive action
Use Tailwind breakpoints consistently:
// Mobile-first responsive design
<View className="
p-2 gap-2 // Mobile
sm:p-3 sm:gap-3 // Small screens (640px+)
md:p-4 md:gap-4 // Medium screens (768px+)
lg:p-6 // Large screens (1024px+)
xl:max-w-7xl // Extra large (1280px+)
2xl:max-w-screen-2xl // 2XL (1536px+)
">
Use semantic color tokens that automatically adapt:
// ✅ CORRECT - Automatic dark mode
<View className="bg-card text-card-foreground">
// ⚠️ AVOID - Manual dark mode (only when necessary)
<View className="bg-white dark:bg-gray-900">
On React Native, text styles do NOT cascade from View/Pressable parents to Text children. This is a fundamental difference from web CSS.
// ❌ WRONG - Text color on Pressable won't apply to Text child on native
<Pressable className="text-muted-foreground">
<Text>This text will be BLACK on native, correct on web</Text>
</Pressable>
// ✅ CORRECT - Apply text styles directly to Text component
<Pressable className="bg-muted">
<Text className="text-muted-foreground">This works on both platforms</Text>
</Pressable>
When to watch for this issue:
text-foreground, text-muted-foreground) seem "broken" on nativeinheritColor patterns from web CSSSolution pattern for settings-based styles:
// In packages/app/default/settings.js (or settings/* modules) - separate container and text classes
feed: {
post_trigger: 'active:bg-muted rounded-lg flex-auto', // Container styles
post_trigger_text: 'font-medium text-muted-foreground', // Text styles
}
// In component - apply to correct elements
<Pressable className={appSetting('feed', 'post_trigger')}>
<Text className={appSetting('feed', 'post_trigger_text')}>Content</Text>
</Pressable>
Note: Semantic tokens work perfectly in Uniwind - ensure they're applied to the correct element type.
NEO supports optional animated Lucide icons (filled/active states, web hover “draw” scenes, etc.) via a small registry and scene classes on Icon. Do not guess the wiring: read animated-icons.md for registry keys, icon-scene-* classes, fork customization, and implementation pitfalls (especially web + react-native-svg).
For "icons connected by a wavy line" graphics (onboarding, pipelines, integration explainers — horizontal or vertical), use the cross-platform NodeFlow atom. Built on the same react-native-svg + RN Animated stack as the animated icons (no Skia/Lottie). Variants follow the same registry pattern as animated icons: defaults in packages/app/default/node-flow-variants.js, branch overrides in packages/app/customization/node-flow-variants.js. On web, prefer next/dynamic(() => import('app/ui/atoms/node-flow'), { ssr: false }) at call sites to keep route bundles lean.
NeoButton is the SwiftUI-faithful button in packages/app/design/controls/ (role, style, controlSize, borderShape, tint axes, providers, transitions, shadow-based surfaces). Full API reference, resolver/scope precedence, SwiftUI/Expo UI mapping, and migration table live in packages/app/design/controls/README.md. A live playground for every axis is at the dev-only /playground route (apps/next/app/playground/page.js).
UNA backend requirements (guest users, JSON responses, block safety) for NEO are documented in the una-api skill.
Always use the fetcher function for UNA API calls:
import { fetcher } from 'app/lib/fetcher';
// Fetch data from UNA CMS
const data = await fetcher('/api.php?r=module/action/Template¶ms[]=JSON');
UNA APIs follow this structure:
/api.php?r={module}/{action}/{Template}¶ms[]={param1}¶ms[]={param2}
Examples:
// Get user profile
'/api.php?r=system/get_data_api/TemplServiceProfiles¶ms[]=' + userId
// Get timeline feed
'/api.php?r=bx_timeline/get/¶ms[]=' + JSON.stringify(params)
// Get comments
'/api.php?r=system/get_data_api/TemplCmtsServices'
Check authentication state before protected operations:
import { useCurrentUser } from 'app/context/user';
function ProtectedComponent() {
const { currentUser, isLoading } = useCurrentUser();
if (isLoading) return <Loading />;
if (!currentUser) return <LoginPrompt />;
return <AuthenticatedContent user={currentUser} />;
}
For server components, use Bearer token authentication:
// Server-side fetch with auth
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
Use Pusher for real-time features:
import Pusher from 'pusher-js';
import { settings } from 'app/customization/settings';
// Connect to Pusher (configured in settings)
const pusher = new Pusher(settings.config.sockets.key, {
cluster: settings.config.sockets.host,
});
// Subscribe to channels
const channel = pusher.subscribe('channel-name');
channel.bind('event-name', (data) => {
// Handle real-time update
});
Before submitting changes, verify:
npx tsc --noEmit in the relevant app directoryarray[0]?.prop)packages/appapps/next or apps/expoapps/next/app: Server Components preferred for page shells / data fetchpackages/app: client shared UI — do not strip 'use client' for RSCfetcher function for API callsuna-api skill when changing UNA blocks/services'use client' directivesAlways run TypeScript check before committing:
# Check specific app
cd apps/next && npx tsc --noEmit
cd apps/expo && npx tsc --noEmit
Common patterns that cause production TypeScript errors:
Array access without bounds checking:
// ❌ WRONG - Object is possibly 'undefined'
const firstItem = items[0].id;
// ✅ CORRECT - Safe access with fallback
const firstItem = items[0]?.id ?? defaultValue;
Object property access on potentially undefined:
// ❌ WRONG - Object is possibly 'undefined'
const name = user.profile.name;
// ✅ CORRECT - Optional chaining
const name = user?.profile?.name ?? 'Unknown';
Function parameters that could be null:
// ❌ WRONG - Parameter might be null
function process(data) {
return data.value;
}
// ✅ CORRECT - Handle null case
function process(data) {
if (!data) return null;
return data.value;
}
Map/filter results assumed to exist:
// ❌ WRONG - find() can return undefined
const item = items.find(i => i.id === id);
console.log(item.name);
// ✅ CORRECT - Check before use
const item = items.find(i => i.id === id);
if (item) {
console.log(item.name);
}
Note: Development builds may not catch all TypeScript errors that strict production builds will catch. Always verify with tsc --noEmit before pushing.
| Type | Pattern | Example |
|---|---|---|
| Components | kebab-case | profile-hover-card.js |
| Pages (Next.js) | lowercase | page.js, layout.js |
| Screens (Expo) | kebab-case in folders | app/(tabs)/index.tsx |
| Utilities | kebab-case | form-helpers.js |
| Types | PascalCase | UserTypes.ts |
// Main apps
import { X } from 'app/...'; // packages/app
import { Y } from 'lucide-react'; // Web icons
import { Z } from 'lucide-react-native'; // Native icons
// Conditional platform code
import { Platform } from 'react-native';
const isWeb = Platform.OS === 'web';
// Conditional client/server
'use client'; // Only at top of file when needed
// Cached server function (Next.js 16+)
async function getData() {
'use cache';
return await expensiveFetch();
}