chore: import upstream snapshot with attribution
Component Security Validation / Security Audit (push) Has been cancelled
Deploy to Cloudflare Pages / deploy (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:58 +08:00
commit bb5c75ce05
8824 changed files with 1946442 additions and 0 deletions
@@ -0,0 +1,359 @@
---
name: astro
description: "Build content-focused websites with Astro — zero JS by default, islands architecture, multi-framework components, and Markdown/MDX support."
category: frontend
risk: safe
source: community
date_added: "2026-03-18"
author: suhaibjanjua
tags: [astro, ssg, ssr, islands, content, markdown, mdx, performance]
tools: [claude, cursor, gemini]
---
# Astro Web Framework
## Overview
Astro is a web framework designed for content-rich websites — blogs, docs, portfolios, marketing sites, and e-commerce. Its core innovation is the **Islands Architecture**: by default, Astro ships zero JavaScript to the browser. Interactive components are selectively hydrated as isolated "islands." Astro supports React, Vue, Svelte, Solid, and other UI frameworks simultaneously in the same project, letting you pick the right tool per component.
## When to Use This Skill
- Use when building a blog, documentation site, marketing page, or portfolio
- Use when performance and Core Web Vitals are the top priority
- Use when the project is content-heavy with Markdown or MDX files
- Use when you want SSG (static) output with optional SSR for dynamic routes
- Use when the user asks about `.astro` files, `Astro.props`, content collections, or `client:` directives
## How It Works
### Step 1: Project Setup
```bash
npm create astro@latest my-site
cd my-site
npm install
npm run dev
```
Add integrations as needed:
```bash
npx astro add tailwind # Tailwind CSS
npx astro add react # React component support
npx astro add mdx # MDX support
npx astro add sitemap # Auto sitemap.xml
npx astro add vercel # Vercel SSR adapter
```
Project structure:
```
src/
pages/ ← File-based routing (.astro, .md, .mdx)
layouts/ ← Reusable page shells
components/ ← UI components (.astro, .tsx, .vue, etc.)
content/ ← Type-safe content collections (Markdown/MDX)
styles/ ← Global CSS
public/ ← Static assets (copied as-is)
astro.config.mjs ← Framework config
```
### Step 2: Astro Component Syntax
`.astro` files have a code fence at the top (server-only) and a template below:
```astro
---
// src/components/Card.astro
// This block runs on the server ONLY — never in the browser
interface Props {
title: string;
href: string;
description: string;
}
const { title, href, description } = Astro.props;
---
<article class="card">
<h2><a href={href}>{title}</a></h2>
<p>{description}</p>
</article>
<style>
/* Scoped to this component automatically */
.card { border: 1px solid #eee; padding: 1rem; }
</style>
```
### Step 3: File-Based Pages and Routing
```
src/pages/index.astro → /
src/pages/about.astro → /about
src/pages/blog/[slug].astro → /blog/:slug (dynamic)
src/pages/blog/[...path].astro → /blog/* (catch-all)
```
Dynamic route with `getStaticPaths`:
```astro
---
// src/pages/blog/[slug].astro
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<h1>{post.data.title}</h1>
<Content />
```
### Step 4: Content Collections
Content collections give you type-safe access to Markdown and MDX files:
```typescript
// src/content/config.ts
import { z, defineCollection } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
```
```astro
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
const posts = (await getCollection('blog'))
.filter(p => !p.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
---
<ul>
{posts.map(post => (
<li>
<a href={`/blog/${post.slug}`}>{post.data.title}</a>
<time>{post.data.date.toLocaleDateString()}</time>
</li>
))}
</ul>
```
### Step 5: Islands — Selective Hydration
By default, UI framework components render to static HTML with no JS. Use `client:` directives to hydrate:
```astro
---
import Counter from '../components/Counter.tsx'; // React component
import VideoPlayer from '../components/VideoPlayer.svelte';
---
<!-- Static HTML — no JavaScript sent to browser -->
<Counter initialCount={0} />
<!-- Hydrate immediately on page load -->
<Counter initialCount={0} client:load />
<!-- Hydrate when the component scrolls into view -->
<VideoPlayer src="/demo.mp4" client:visible />
<!-- Hydrate only when browser is idle -->
<Analytics client:idle />
<!-- Hydrate only on a specific media query -->
<MobileMenu client:media="(max-width: 768px)" />
```
### Step 6: Layouts
```astro
---
// src/layouts/BaseLayout.astro
interface Props {
title: string;
description?: string;
}
const { title, description = 'My Astro Site' } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{title}</title>
<meta name="description" content={description} />
</head>
<body>
<nav>...</nav>
<main>
<slot /> <!-- page content renders here -->
</main>
<footer>...</footer>
</body>
</html>
```
```astro
---
// src/pages/about.astro
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="About Us">
<h1>About Us</h1>
<p>Welcome to our company...</p>
</BaseLayout>
```
### Step 7: SSR Mode (On-Demand Rendering)
Enable SSR for dynamic pages by setting an adapter:
```javascript
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';
export default defineConfig({
output: 'hybrid', // 'static' | 'server' | 'hybrid'
adapter: vercel(),
});
```
Opt individual pages into SSR with `export const prerender = false`.
## Examples
### Example 1: Blog with RSS Feed
```typescript
// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const posts = await getCollection('blog');
return rss({
title: 'My Blog',
description: 'Latest posts',
site: context.site,
items: posts.map(post => ({
title: post.data.title,
pubDate: post.data.date,
link: `/blog/${post.slug}/`,
})),
});
}
```
### Example 2: API Endpoint (SSR)
```typescript
// src/pages/api/subscribe.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const { email } = await request.json();
if (!email) {
return new Response(JSON.stringify({ error: 'Email required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
await addToNewsletter(email);
return new Response(JSON.stringify({ success: true }), { status: 200 });
};
```
### Example 3: React Component as Island
```tsx
// src/components/SearchBox.tsx
import { useState } from 'react';
export default function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
async function search(e: React.FormEvent) {
e.preventDefault();
const data = await fetch(`/api/search?q=${query}`).then(r => r.json());
setResults(data);
}
return (
<form onSubmit={search}>
<input value={query} onChange={e => setQuery(e.target.value)} />
<button type="submit">Search</button>
<ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>
</form>
);
}
```
```astro
---
import SearchBox from '../components/SearchBox.tsx';
---
<!-- Hydrated immediately — this island is interactive -->
<SearchBox client:load />
```
## Best Practices
- ✅ Keep most components as static `.astro` files — only hydrate what must be interactive
- ✅ Use content collections for all Markdown/MDX content — you get type safety and auto-validation
- ✅ Prefer `client:visible` over `client:load` for below-the-fold components to reduce initial JS
- ✅ Use `import.meta.env` for environment variables — prefix public vars with `PUBLIC_`
- ✅ Add `<ViewTransitions />` from `astro:transitions` for smooth page navigation without a full SPA
- ❌ Don't use `client:load` on every component — this defeats Astro's performance advantage
- ❌ Don't put secrets in `.astro` frontmatter that gets used in client-facing templates
- ❌ Don't skip `getStaticPaths` for dynamic routes in static mode — builds will fail
## Security & Safety Notes
- Frontmatter code in `.astro` files runs server-side only and is never exposed to the browser.
- Use `import.meta.env.PUBLIC_*` only for non-sensitive values. Private env vars (no `PUBLIC_` prefix) are never sent to the client.
- When using SSR mode, validate all `Astro.request` inputs before database queries or API calls.
- Sanitize any user-supplied content before rendering with `set:html` — it bypasses auto-escaping.
## Common Pitfalls
- **Problem:** JavaScript from a React/Vue component doesn't run in the browser
**Solution:** Add a `client:` directive (`client:load`, `client:visible`, etc.) — without it, components render as static HTML only.
- **Problem:** `getStaticPaths` data is stale after content updates during dev
**Solution:** Astro's dev server watches content files — restart if changes to `content/config.ts` are not reflected.
- **Problem:** `Astro.props` type is `any` — no autocomplete
**Solution:** Define a `Props` interface or type in the frontmatter and Astro will infer it automatically.
- **Problem:** CSS from a `.astro` component bleeds into other components
**Solution:** Styles in `.astro` `<style>` tags are automatically scoped. Use `:global()` only when intentionally targeting children.
## Related Skills
- `@sveltekit` — When you need a full-stack framework with reactive UI (vs Astro's content focus)
- `@nextjs-app-router-patterns` — When you need a React-first full-stack framework
- `@tailwind-patterns` — Styling Astro sites with Tailwind CSS
- `@progressive-web-app` — Adding PWA capabilities to an Astro site
@@ -0,0 +1,292 @@
---
name: blockrun
description: Use when user needs capabilities Claude lacks (image generation, real-time X/Twitter data) or explicitly requests external models ("blockrun", "use grok", "use gpt", "dall-e", "deepseek")
allowed-tools: Read, Bash(python:*), Bash(python3:*), Bash(pip:*), Bash(source:*)
---
# BlockRun
**BlockRun works with Claude Code and Google Antigravity.**
You can't generate images. You have no real-time X/Twitter access. BlockRun gives you these capabilities.
## Philosophy
You have a wallet. When you need a capability you lack, you pay for it autonomously.
| You Need | You Call | Pricing |
|----------|----------|---------|
| Image generation | DALL-E | $0.04/image |
| Real-time X data | Grok + Live Search | $0.025/source |
| Second opinion | GPT-5.2 | $1.75/M input, $14/M output |
| Cheaper processing | DeepSeek | $0.14/M input, $0.28/M output |
**How it works:** BlockRun uses x402 micropayments to route your requests to OpenAI, xAI, Google, and other providers. No API keys needed - your wallet pays per token.
## Budget Control (Optional)
If the user specifies a budget (e.g., "use at most $1"), track spending and stop when budget is reached:
```python
from blockrun_llm import setup_agent_wallet
client = setup_agent_wallet()
budget = 1.0 # User's budget
# Before each call, check if within budget
spending = client.get_spending()
if spending['total_usd'] >= budget:
print(f"Budget reached: ${spending['total_usd']:.4f} spent")
# Stop making calls
else:
response = client.chat("openai/gpt-5.2", "Hello!")
# At the end, report spending
spending = client.get_spending()
print(f"💰 Total spent: ${spending['total_usd']:.4f} across {spending['calls']} calls")
```
## When to Use
| Trigger | Your Action |
|---------|-------------|
| User explicitly requests ("blockrun second opinion with GPT on...", "use grok to check...", "generate image with dall-e") | Execute via BlockRun |
| User needs something you can't do (images, live X data) | Suggest BlockRun, wait for confirmation |
| You can handle the task fine | Do it yourself, don't mention BlockRun |
## Example User Prompts
Users will say things like:
| User Says | What You Do |
|-----------|-------------|
| "blockrun generate an image of a sunset" | Call DALL-E via ImageClient |
| "use grok to check what's trending on X" | Call Grok with `search=True` |
| "blockrun GPT review this code" | Call GPT-5.2 via LLMClient |
| "what's the latest news about AI agents?" | Suggest Grok (you lack real-time data) |
| "generate a logo for my startup" | Suggest DALL-E (you can't generate images) |
| "blockrun check my balance" | Show wallet balance via `get_balance()` |
| "blockrun deepseek summarize this file" | Call DeepSeek for cost savings |
## Wallet & Balance
Use `setup_agent_wallet()` to auto-create a wallet and get a client. This shows the QR code and welcome message on first use.
**Initialize client (always start with this):**
```python
from blockrun_llm import setup_agent_wallet
client = setup_agent_wallet() # Auto-creates wallet, shows QR if new
```
**Check balance (when user asks "show balance", "check wallet", etc.):**
```python
balance = client.get_balance() # On-chain USDC balance
print(f"Balance: ${balance:.2f} USDC")
print(f"Wallet: {client.get_wallet_address()}")
```
**Show QR code for funding:**
```python
from blockrun_llm import generate_wallet_qr_ascii, get_wallet_address
# ASCII QR for terminal display
print(generate_wallet_qr_ascii(get_wallet_address()))
```
## SDK Usage
**Prerequisite:** Install the SDK with `pip install blockrun-llm`
### Basic Chat
```python
from blockrun_llm import setup_agent_wallet
client = setup_agent_wallet() # Auto-creates wallet if needed
response = client.chat("openai/gpt-5.2", "What is 2+2?")
print(response)
# Check spending
spending = client.get_spending()
print(f"Spent ${spending['total_usd']:.4f}")
```
### Real-time X/Twitter Search (xAI Live Search)
**IMPORTANT:** For real-time X/Twitter data, you MUST enable Live Search with `search=True` or `search_parameters`.
```python
from blockrun_llm import setup_agent_wallet
client = setup_agent_wallet()
# Simple: Enable live search with search=True
response = client.chat(
"xai/grok-3",
"What are the latest posts from @blockrunai on X?",
search=True # Enables real-time X/Twitter search
)
print(response)
```
### Advanced X Search with Filters
```python
from blockrun_llm import setup_agent_wallet
client = setup_agent_wallet()
response = client.chat(
"xai/grok-3",
"Analyze @blockrunai's recent content and engagement",
search_parameters={
"mode": "on",
"sources": [
{
"type": "x",
"included_x_handles": ["blockrunai"],
"post_favorite_count": 5
}
],
"max_search_results": 20,
"return_citations": True
}
)
print(response)
```
### Image Generation
```python
from blockrun_llm import ImageClient
client = ImageClient()
result = client.generate("A cute cat wearing a space helmet")
print(result.data[0].url)
```
## xAI Live Search Reference
Live Search is xAI's real-time data API. Cost: **$0.025 per source** (default 10 sources = ~$0.26).
To reduce costs, set `max_search_results` to a lower value:
```python
# Only use 5 sources (~$0.13)
response = client.chat("xai/grok-3", "What's trending?",
search_parameters={"mode": "on", "max_search_results": 5})
```
### Search Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `mode` | string | "auto" | "off", "auto", or "on" |
| `sources` | array | web,news,x | Data sources to query |
| `return_citations` | bool | true | Include source URLs |
| `from_date` | string | - | Start date (YYYY-MM-DD) |
| `to_date` | string | - | End date (YYYY-MM-DD) |
| `max_search_results` | int | 10 | Max sources to return (customize to control cost) |
### Source Types
**X/Twitter Source:**
```python
{
"type": "x",
"included_x_handles": ["handle1", "handle2"], # Max 10
"excluded_x_handles": ["spam_account"], # Max 10
"post_favorite_count": 100, # Min likes threshold
"post_view_count": 1000 # Min views threshold
}
```
**Web Source:**
```python
{
"type": "web",
"country": "US", # ISO alpha-2 code
"allowed_websites": ["example.com"], # Max 5
"safe_search": True
}
```
**News Source:**
```python
{
"type": "news",
"country": "US",
"excluded_websites": ["tabloid.com"] # Max 5
}
```
## Available Models
| Model | Best For | Pricing |
|-------|----------|---------|
| `openai/gpt-5.2` | Second opinions, code review, general | $1.75/M in, $14/M out |
| `openai/gpt-5-mini` | Cost-optimized reasoning | $0.30/M in, $1.20/M out |
| `openai/o4-mini` | Latest efficient reasoning | $1.10/M in, $4.40/M out |
| `openai/o3` | Advanced reasoning, complex problems | $10/M in, $40/M out |
| `xai/grok-3` | Real-time X/Twitter data | $3/M + $0.025/source |
| `deepseek/deepseek-chat` | Simple tasks, bulk processing | $0.14/M in, $0.28/M out |
| `google/gemini-2.5-flash` | Very long documents, fast | $0.15/M in, $0.60/M out |
| `openai/dall-e-3` | Photorealistic images | $0.04/image |
| `google/nano-banana` | Fast, artistic images | $0.01/image |
*M = million tokens. Actual cost depends on your prompt and response length.*
## Cost Reference
All LLM costs are per million tokens (M = 1,000,000 tokens).
| Model | Input | Output |
|-------|-------|--------|
| GPT-5.2 | $1.75/M | $14.00/M |
| GPT-5-mini | $0.30/M | $1.20/M |
| Grok-3 (no search) | $3.00/M | $15.00/M |
| DeepSeek | $0.14/M | $0.28/M |
| Fixed Cost Actions | |
|-------|--------|
| Grok Live Search | $0.025/source (default 10 = $0.25) |
| DALL-E image | $0.04/image |
| Nano Banana image | $0.01/image |
**Typical costs:** A 500-word prompt (~750 tokens) to GPT-5.2 costs ~$0.001 input. A 1000-word response (~1500 tokens) costs ~$0.02 output.
## Setup & Funding
**Wallet location:** `$HOME/.blockrun/.session` (e.g., `/Users/username/.blockrun/.session`)
**First-time setup:**
1. Wallet auto-creates when `setup_agent_wallet()` is called
2. Check wallet and balance:
```python
from blockrun_llm import setup_agent_wallet
client = setup_agent_wallet()
print(f"Wallet: {client.get_wallet_address()}")
print(f"Balance: ${client.get_balance():.2f} USDC")
```
3. Fund wallet with $1-5 USDC on Base network
**Show QR code for funding (ASCII for terminal):**
```python
from blockrun_llm import generate_wallet_qr_ascii, get_wallet_address
print(generate_wallet_qr_ascii(get_wallet_address()))
```
## Troubleshooting
**"Grok says it has no real-time access"**
→ You forgot to enable Live Search. Add `search=True`:
```python
response = client.chat("xai/grok-3", "What's trending?", search=True)
```
**Module not found**
→ Install the SDK: `pip install blockrun-llm`
## Updates
```bash
pip install --upgrade blockrun-llm
```
@@ -0,0 +1,69 @@
---
name: building-blog
description: "Use when adding a blog to a Next.js + Sanity site, building a blog section from scratch, integrating Sanity CMS for editorial content, or setting up an SEO-optimized article surface. Triggers on phrases like 'add a blog', 'build the blog', 'create blog section', 'set up blog with Sanity', 'integrate Sanity CMS', 'blog architecture', or any new-blog scoping conversation on a Next.js project."
risk: safe
source: community
date_added: "2026-05-21"
---
# Building a Blog (Next.js + Sanity)
## Overview
Universal workflow for adding a blog to a Next.js site backed by Sanity CMS. Targets a corporate blog: SEO-first, i18n-ready, performance-budgeted, accessibility-compliant.
**SKILL.md is the orchestrator. The spec lives in the two reference files.**
## Files
- `blog-technical-requirements.md` — §0 intake questionnaire (30+ questions) → §1 Project Profile → §2–§20 universal spec → checklist. Edit only §0 answers and §1 per project.
- `blog-image-style-guide.md` — universal template for AI-generated hero imagery via Gemini 3 Pro Image (Nano Banana Pro). Intake questions on top, aesthetic skeleton in the middle, three example slots at the bottom.
## Workflow
### Step 1 — Scan the host project (before asking anything)
Read what's already there. Pre-filling answers from detected values is much better than asking the user. Look for:
- `package.json` — Next.js version, `next-intl`, Sanity packages, Tailwind, `framer-motion`
- `next.config.{ts,js,mjs}` — existing `images.formats`, i18n config
- `src/messages/**`, `src/i18n/**`, `messages/**` — current locale set
- `src/app/[locale]/**` or `app/[locale]/**` — routing pattern
- `tailwind.config.*`, `globals.css`, design-system files — tokens, fonts
- `CLAUDE.md`, `AGENTS.md`, `GEMINI.md` — project conventions and approved overrides
- Existing `sanity-studio/` or `studio/` folder
- `app/layout.{tsx,jsx}` — fonts, analytics, motion config
- `public/brand/**`, `public/logo*` — publisher logo
- `sitemap.ts`, `robots.ts` — existing routes
- `.env*` files — names of already-set env vars (do not log secret values)
Record findings as a brief "Detected" list before the questionnaire.
### Step 2 — Run the intake questionnaire
Open `blog-technical-requirements.md` and walk through §0.
- **Claude Code:** use `AskUserQuestion`. Group related questions into single calls (max 4 per call). Pre-fill recommended answers from the scan.
- **Codex / CLIs without an interactive picker:** list questions numerically in plain text, ask the user to answer in batches of 510. Show recommended answers from the scan.
- **Headless / non-interactive:** apply universal defaults to whatever the prompt did not explicitly cover; list every assumption.
Write the answers into §1 "Project Profile" (in a project-local copy under `docs/blog/`, not in the universal source).
### Step 3 — Produce the high-level plan
Use the plan template at the end of §0. One page. Phases, locked-in scope, out-of-scope items, open decisions. **Wait for explicit user approval. Do not start coding.**
### Step 4 — Implement against the spec
Follow §2–§20 in order. §19 (Pass/Fail Checklist) is the definition of done. The image style guide drives §20 if AI-generated hero images are in scope.
## When NOT to use this skill
- Static / MDX-only blogs (no CMS) — different stack, different patterns
- High-velocity news publishers — sitemap chunking and editor tooling become primary concerns
- Documentation sites — use a docs framework instead
- Marketing landing pages that aren't really a blog
## Source
Maintained at [BuildShipGrowRepeat/nextjs-sanity-blog-skill](https://github.com/BuildShipGrowRepeat/nextjs-sanity-blog-skill). MIT licensed.
@@ -0,0 +1,198 @@
# Blog — Featured Image Style Guide (Universal Template)
Used when generating hero images (via Nano Banana Pro / Gemini 3 Pro Image, or an equivalent text-to-image model) for blog articles.
The **structure** of this document — target audience, reference points, palette, aesthetic rules, composition, topic-reflection table, prompt template, worked examples — is the cross-project template. The **content** is per-project: walk the intake questions in §I and fill in every `[FILL: ...]` block. Keep this file as a self-contained brief so a human designer or an image model can use it without other context.
---
## §I. Intake Questions
Walk these first. Use `AskUserQuestion` when available; otherwise list numerically and ask the user to answer in batches. Pre-fill from any visual identity already documented in the host project (`globals.css`, design tokens, brand book, existing site photography).
### Brand
1. **Brand in one sentence.** Positioning, sector, and what the visual language is *not* (e.g. "not a SaaS marketing asset", "not a generic event-industry stock library").
2. **Photographic register.** One of: editorial documentary / industrial editorial / lifestyle editorial / studio still life / mixed. Each pulls a different aesthetic from the model.
3. **Magazine touchstones.** Two to four named publications whose photography matches the desired feel (e.g. Monocle, Dezeen, MIT Technology Review, Wallpaper\*, Disegno, *Cabinet*, Apartamento). The model adheres more reliably to named touchstones than to abstract adjectives.
### Audience
4. **Target readers (35 bullets).** Who actually reads the blog, what they value, what they are allergic to in stock imagery.
### Reference points
5. **12 concrete reference images from the live site or brand library.** Describe each in 12 sentences. Model adherence improves enormously when the prompt can echo a named scene rather than abstract adjectives.
### Colors
6. **Brand neutrals.** Pull from `globals.css` or the design token export. List 46 hex codes (canvas, ink, body gray, dividers).
7. **Signature accent.** One saturated brand color, used as a *small* accent only — never dominant.
8. **Secondary accent or wash (optional).** A soft tone used sparingly for halos or selection states.
### Allowed working palette
9. **Authentic material colors specific to the industry.** Beyond the brand palette, the model is allowed to use the colors of the materials themselves (denim, marigold, kraft, concrete, brushed aluminium, walnut, etc.). List 510 honest material hues.
### Hard avoids
10. **Project-specific hard avoids.** Default universal hard avoids are listed in §V; add project-specific ones (e.g. "no rainbow LED on screens in frame", "no full frontal faces", "no readable show signage").
### Composition
11. **Aspect ratio.** Default 16:9 landscape at 1920×1080 or higher. Confirm.
12. **Title overlay side.** Where titles will overlay on listing cards vs. article heroes — drives where negative space goes.
### Topic reflection
13. **Topic table (812 rows).** A 2-column table mapping topic areas in your domain to concrete visual subjects. This is the single most important section: every generated image must hint at its article through real material or industrial context, never icons or symbols. See §VII for the structure and an example skeleton.
### Worked examples
14. **35 worked example prompts.** One for a high-traffic topic area (cornerstone), one for a difficult abstract topic (ROI / governance / strategy), one or two more spanning the rest of the topic table. See §IX for the structure and an example shape.
### Generator hookup
15. **Image asset reuse strategy.** One image shared across all locales (default; locale-specific alt text in the uploader) or one image per locale (only when the imagery itself differs by language).
---
## §II. Brand in one sentence
`[FILL: one-sentence positioning. Example shape: "<Company> is a <sector adjective> company that <does specific thing>. The visual language is <photographic register> — <three qualitative descriptors>. Think a <Magazine A> or <Magazine B> feature on a real <subject>, not a <anti-example>."]`
---
## §III. Target audience
`[FILL: 35 bullets from intake question 4. Who readers are, what they value, what they are allergic to in stock imagery.]`
---
## §IV. Reference points (existing site)
`[FILL: 12 concrete images from the live site that anchor the visual calibration. Describe each specifically — composition, light, materials, color, mood. The model adheres more reliably to named scenes than to abstract adjectives.]`
---
## §V. Brand colors
Sampled from the site's canonical color tokens (`globals.css`, design system export, or brand book):
- **Neutrals:** `[FILL: 46 hex codes — canvas / off-white / near-black / body gray / dividers]`
- **Signature accent:** `[FILL: single saturated brand color, hex code]` — used as a *small* accent only, never dominant
- **Secondary accent / wash (optional):** `[FILL: hex codes if applicable]`
### Allowed working palette in images
Beyond brand neutrals + signature accent, encourage the **natural colors of the materials themselves** specific to your industry. `[FILL: 510 honest material/industry colors from intake question 9.]` Saturation is welcome when it comes from real material, not post-production.
### Hard avoids (universal — keep all)
purple / magenta / neon cyan / acid pink / rainbow eco-gradients / over-filtered teal-and-orange grading / glowing neon / dark-mode SaaS / 3D renders / chrome plastic / HDR / CGI molecule chains / cartoon flat-design illustrations / stock-photo handshakes / globes-with-leaves / full frontal faces / AI-looking "smooth" plastic skin / any readable text, words, numbers, logos, or watermarks in the frame.
### Hard avoids (project-specific)
`[FILL: additions from intake question 10. Examples: "no rainbow LED content on screens", "no recognizable portraits", "no fluorescent uplighting on stages".]`
---
## §VI. Aesthetic rules
**Do:**
- `[FILL: photographic register — editorial documentary / industrial editorial / lifestyle / studio still life — shot with a 35 mm or 50 mm equivalent, or 24 mm for wide context shots]`
- Natural light (window, skylight, industrial ceiling fixtures, mixed daylight + warm work lights) — not ring light, not neon, not stage uplighting
- Real materials specific to the industry: `[FILL: list 510 real materials that naturally appear in your business. Examples: extruded aluminium, brushed steel, dye-sub fabric, walnut joinery, concrete floor, glass vessel, kraft paper, polymer pellet, raw resin.]`
- Shallow-to-moderate depth of field — enough to isolate subject, not dreamy bokeh
- Quiet asymmetry — subject on a third, generous neutral space on the opposite side (titles overlay)
- Organic touches when relevant (leaves, wooden pallet, hemp twine, plant life) — picks up sustainability/quality cues without cliché
- Human presence through hands, gloved hands, forearms, high-vis torsos, backs-of-heads, profiles — never full frontal faces or recognizable portraits
- `[FILL: add project-specific Do rules from the intake. Examples: "honest industrial dust, cable management in progress, unfinished graphic layers during install — pre-show authenticity, not marketing-perfect" / "matte architectural interiors, product stations, natural plant life, warm walnut or ash joinery"]`
**Don't:** see "Hard avoids" above (universal + project-specific).
---
## §VII. Composition
- **Aspect:** 16:9 landscape — target 1920×1080 or higher.
- **Framing:** subject on a third; generous neutral area on the opposite side for title overlay. `[FILL: left-weighted on listing cards vs. right-weighted on article heroes, or whatever your overlay system uses.]`
- **Depth:** one clear hero element + one supporting texture/context layer; resist stacking three subjects.
- **Focus:** tactile — a reader should want to touch the material.
- **Color discipline:** roughly 80% of the frame in neutrals, 15% in authentic material colour, ≤5% in signature accent.
---
## §VIII. Topic reflection
Every image must hint at its article topic through **real material or real industrial context**, never through icons, symbols, or stock-photo categories.
Build a 2-column table mapping the topic areas your blog actually covers to concrete visual subjects. Aim for 812 rows. Example skeleton:
| Topic area | Visual subject |
|---|---|
| `[FILL: topic area 1]` | `[FILL: 13 concrete visual subjects — real materials, real context, no symbols]` |
| `[FILL: topic area 2]` | `[FILL: ...]` |
| `[FILL: topic area 3]` | `[FILL: ...]` |
| `[FILL: ...]` | `[FILL: ...]` |
**Rule:** a reader glancing at the image should feel *"this is about a real industry and real materials,"* not *"this is a generic blog banner."*
---
## §IX. Prompt template
```
[REGISTER: e.g. Editorial documentary photograph / Industrial editorial photograph], shot on a 35mm, 50mm, or 24mm-wide lens, magazine-quality (think [TOUCHSTONE A], [TOUCHSTONE B], [TOUCHSTONE C]). 16:9 landscape, 1920x1080. [SUBJECT: one specific tactile material, industrial detail, or environment drawn from the topic table]. Natural light — [soft diffuse daylight from a window / warm industrial ceiling light / overcast exterior light / architectural daylight through a shopfront / warm work lamp on a bench]. Shallow-to-moderate depth of field, honest focus on the hero element. Restrained palette: neutrals ([LIST: e.g. white, concrete gray, matte black, warm kraft, brushed aluminium, warm walnut]) plus the authentic material colors [LIST 12 real material hues — e.g. denim blue, marigold, cream polyester, amber polymer, stainless], with at most a single small accent of [SIGNATURE ACCENT HEX] appearing naturally (equipment stripe, kraft tag, safety marker, plant leaf, painted door edge), never dominant. Tactile textures emphasised: [LIST 12 specific textures — e.g. shredded polyester fiber, polymer pellets, stainless steel, kraft paper, concrete floor, glass vessel, gaffer tape, fabric weave]. Generous negative space on one side for text overlay. No text, no words, no letters, no numbers, no watermarks, no readable logos. No cartoon illustration, no 3D render, no glowing neon, no HDR, no cliché stock-photo handshakes, no full frontal faces, [PROJECT-SPECIFIC AVOIDS]. Calm, precise, confident — documentary realism.
```
Fill the bracketed slots per article. Keep the prompt under ~1500 characters total — Nano Banana Pro adherence drops sharply past that.
---
## §X. Worked examples
Replace each placeholder block with a real article-specific example from your project. Aim for 35 examples that together span the topic table (one cornerstone, one abstract/financial topic, one process/material topic, one optional location/environment).
### Example 1 — `[FILL: article title or topic]`
```
[FILL: full crafted prompt following §IX template, with all bracketed slots filled. Should read as one paragraph, no line breaks inside the model input.]
```
### Example 2 — `[FILL: article title or topic]`
```
[FILL: full crafted prompt.]
```
### Example 3 — `[FILL: article title or topic]`
```
[FILL: full crafted prompt.]
```
### Example 4 (optional) — `[FILL: article title or topic]`
```
[FILL: full crafted prompt.]
```
### Example 5 (optional) — `[FILL: article title or topic]`
```
[FILL: full crafted prompt.]
```
---
## §XI. Notes for the generation script
- One image per topic is shared across all locales by default. Alt text is localized per locale in the uploader.
- Re-runs are idempotent: posts already carrying `heroImage.asset` are skipped. Pass `--force <slug>` to regenerate a single article.
- Generated files are committed under `public/images/blog/<primary-slug>.<ext>` for git asset history alongside the Sanity upload (skip the local copy if §1.J3 of the technical spec = sanity-only).
- If a topic sits between two rows of the topic table, pick the one that best matches the article's *primary question*, not its incidental vocabulary.
- If the model keeps pushing the signature accent into dominance, remove the accent line from the prompt entirely for that generation rather than fighting it.
- Human presence is strongly preferred over empty objects for roughly half of heroes — but always partial (hands, profiles, backs-of-heads) and never full frontal faces or recognizable portraits.
- Generate one smoke-test image and review it for brand adherence before batching the full set. Tighten the prompt template if the first image misses materially.
@@ -0,0 +1,83 @@
---
name: chrome-extension-developer
description: "Expert in building Chrome Extensions using Manifest V3. Covers background scripts, service workers, content scripts, and cross-context communication."
risk: safe
source: community
date_added: "2026-02-27"
---
You are a senior Chrome Extension Developer specializing in modern extension architecture, focusing on Manifest V3, cross-script communication, and production-ready security practices.
## Use this skill when
- Designing and building new Chrome Extensions from scratch
- Migrating extensions from Manifest V2 to Manifest V3
- Implementing service workers, content scripts, or popup/options pages
- Debugging cross-context communication (message passing)
- Implementing extension-specific APIs (storage, permissions, alarms, side panel)
## Do not use this skill when
- The task is for Safari App Extensions (use `safari-extension-expert` if available)
- Developing for Firefox without the WebExtensions API
- General web development that doesn't interact with extension APIs
## Instructions
1. **Manifest V3 Only**: Always prioritize Service Workers over Background Pages.
2. **Context Separation**: Clearly distinguish between Service Workers (background), Content Scripts (DOM-accessible), and UI contexts (popups, options).
3. **Message Passing**: Use `chrome.runtime.sendMessage` and `chrome.tabs.sendMessage` for reliable communication. Always use the `responseCallback`.
4. **Permissions**: Follow the principle of least privilege. Use `optional_permissions` where possible.
5. **Storage**: Use `chrome.storage.local` or `chrome.storage.sync` for persistent data instead of `localStorage`.
6. **Declarative APIs**: Use `declarativeNetRequest` for network filtering/modification.
## Examples
### Example 1: Basic Manifest V3 Structure
```json
{
"manifest_version": 3,
"name": "My Agentic Extension",
"version": "1.0.0",
"action": {
"default_popup": "popup.html"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"js": ["content.js"]
}
],
"permissions": ["storage", "activeTab"]
}
```
### Example 2: Message Passing Policy
```javascript
// background.js (Service Worker)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "GREET_AGENT") {
console.log("Received message from content script:", message.data);
sendResponse({ status: "ACK", reply: "Hello from Background" });
}
return true; // Keep message channel open for async response
});
```
## Best Practices
-**Do:** Use `chrome.runtime.onInstalled` for extension initialization.
-**Do:** Use modern ES modules in scripts if configured in manifest.
-**Do:** Validate external input in content scripts before acting on it.
-**Don't:** Use `innerHTML` or `eval()` - prefer `textContent` and safe DOM APIs.
-**Don't:** Block the main thread in the service worker; it must remain responsive.
## Troubleshooting
**Problem:** Service worker becomes inactive.
**Solution:** Background service workers are ephemeral. Use `chrome.alarms` for scheduled tasks rather than `setTimeout` or `setInterval` which may be killed.
@@ -0,0 +1,363 @@
---
name: drizzle-orm-expert
description: "Expert in Drizzle ORM for TypeScript — schema design, relational queries, migrations, and serverless database integration. Use when building type-safe database layers with Drizzle."
risk: safe
source: community
date_added: "2026-03-04"
---
# Drizzle ORM Expert
You are a production-grade Drizzle ORM expert. You help developers build type-safe, performant database layers using Drizzle ORM with TypeScript. You know schema design, the relational query API, Drizzle Kit migrations, and integrations with Next.js, tRPC, and serverless databases (Neon, PlanetScale, Turso, Supabase).
## When to Use This Skill
- Use when the user asks to set up Drizzle ORM in a new or existing project
- Use when designing database schemas with Drizzle's TypeScript-first approach
- Use when writing complex relational queries (joins, subqueries, aggregations)
- Use when setting up or troubleshooting Drizzle Kit migrations
- Use when integrating Drizzle with Next.js App Router, tRPC, or Hono
- Use when optimizing database performance (prepared statements, batching, connection pooling)
- Use when migrating from Prisma, TypeORM, or Knex to Drizzle
## Core Concepts
### Why Drizzle
Drizzle ORM is a TypeScript-first ORM that generates zero runtime overhead. Unlike Prisma (which uses a query engine binary), Drizzle compiles to raw SQL — making it ideal for edge runtimes and serverless. Key advantages:
- **SQL-like API**: If you know SQL, you know Drizzle
- **Zero dependencies**: Tiny bundle, works in Cloudflare Workers, Vercel Edge, Deno
- **Full type inference**: Schema → types → queries are all connected at compile time
- **Relational Query API**: Prisma-like nested includes without N+1 problems
## Schema Design Patterns
### Table Definitions
```typescript
// db/schema.ts
import { pgTable, text, integer, timestamp, boolean, uuid, pgEnum } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
// Enums
export const roleEnum = pgEnum("role", ["admin", "user", "moderator"]);
// Users table
export const users = pgTable("users", {
id: uuid("id").defaultRandom().primaryKey(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
role: roleEnum("role").default("user").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
// Posts table with foreign key
export const posts = pgTable("posts", {
id: uuid("id").defaultRandom().primaryKey(),
title: text("title").notNull(),
content: text("content"),
published: boolean("published").default(false).notNull(),
authorId: uuid("author_id").references(() => users.id, { onDelete: "cascade" }).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
```
### Relations
```typescript
// db/relations.ts
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
```
### Type Inference
```typescript
// Infer types directly from your schema — no separate type files needed
import type { InferSelectModel, InferInsertModel } from "drizzle-orm";
export type User = InferSelectModel<typeof users>;
export type NewUser = InferInsertModel<typeof users>;
export type Post = InferSelectModel<typeof posts>;
export type NewPost = InferInsertModel<typeof posts>;
```
## Query Patterns
### Select Queries (SQL-like API)
```typescript
import { eq, and, like, desc, count, sql } from "drizzle-orm";
// Basic select
const allUsers = await db.select().from(users);
// Filtered with conditions
const admins = await db.select().from(users).where(eq(users.role, "admin"));
// Partial select (only specific columns)
const emails = await db.select({ email: users.email }).from(users);
// Join query
const postsWithAuthors = await db
.select({
title: posts.title,
authorName: users.name,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt))
.limit(10);
// Aggregation
const postCounts = await db
.select({
authorId: posts.authorId,
postCount: count(posts.id),
})
.from(posts)
.groupBy(posts.authorId);
```
### Relational Queries (Prisma-like API)
```typescript
// Nested includes — Drizzle resolves in a single query
const usersWithPosts = await db.query.users.findMany({
with: {
posts: {
where: eq(posts.published, true),
orderBy: [desc(posts.createdAt)],
limit: 5,
},
},
});
// Find one with nested data
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
with: { posts: true },
});
```
### Insert, Update, Delete
```typescript
// Insert with returning
const [newUser] = await db
.insert(users)
.values({ email: "dev@example.com", name: "Dev" })
.returning();
// Batch insert
await db.insert(posts).values([
{ title: "Post 1", authorId: newUser.id },
{ title: "Post 2", authorId: newUser.id },
]);
// Update
await db.update(users).set({ name: "Updated" }).where(eq(users.id, userId));
// Delete
await db.delete(posts).where(eq(posts.authorId, userId));
```
### Transactions
```typescript
const result = await db.transaction(async (tx) => {
const [user] = await tx.insert(users).values({ email, name }).returning();
await tx.insert(posts).values({ title: "Welcome Post", authorId: user.id });
return user;
});
```
## Migration Workflow (Drizzle Kit)
### Configuration
```typescript
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
```
### Commands
```bash
# Generate migration SQL from schema changes
npx drizzle-kit generate
# Push schema directly to database (development only — skips migration files)
npx drizzle-kit push
# Run pending migrations (production)
npx drizzle-kit migrate
# Open Drizzle Studio (GUI database browser)
npx drizzle-kit studio
```
## Database Client Setup
### PostgreSQL (Neon Serverless)
```typescript
// db/index.ts
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });
```
### SQLite (Turso/LibSQL)
```typescript
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import * as schema from "./schema";
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN,
});
export const db = drizzle(client, { schema });
```
### MySQL (PlanetScale)
```typescript
import { drizzle } from "drizzle-orm/planetscale-serverless";
import { Client } from "@planetscale/database";
import * as schema from "./schema";
const client = new Client({ url: process.env.DATABASE_URL! });
export const db = drizzle(client, { schema });
```
## Performance Optimization
### Prepared Statements
```typescript
// Prepare once, execute many times
const getUserById = db.query.users
.findFirst({
where: eq(users.id, sql.placeholder("id")),
})
.prepare("get_user_by_id");
// Execute with parameters
const user = await getUserById.execute({ id: "abc-123" });
```
### Batch Operations
```typescript
// Use db.batch() for multiple independent queries in one round-trip
const [allUsers, recentPosts] = await db.batch([
db.select().from(users),
db.select().from(posts).orderBy(desc(posts.createdAt)).limit(10),
]);
```
### Indexing in Schema
```typescript
import { index, uniqueIndex } from "drizzle-orm/pg-core";
export const posts = pgTable(
"posts",
{
id: uuid("id").defaultRandom().primaryKey(),
title: text("title").notNull(),
authorId: uuid("author_id").references(() => users.id).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [
index("posts_author_idx").on(table.authorId),
index("posts_created_idx").on(table.createdAt),
]
);
```
## Next.js Integration
### Server Component Usage
```typescript
// app/users/page.tsx (React Server Component)
import { db } from "@/db";
import { users } from "@/db/schema";
export default async function UsersPage() {
const allUsers = await db.select().from(users);
return (
<ul>
{allUsers.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
```
### Server Action
```typescript
// app/actions.ts
"use server";
import { db } from "@/db";
import { users } from "@/db/schema";
export async function createUser(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
await db.insert(users).values({ name, email });
}
```
## Best Practices
-**Do:** Keep all schema definitions in a single `db/schema.ts` or split by domain (`db/schema/users.ts`, `db/schema/posts.ts`)
-**Do:** Use `InferSelectModel` and `InferInsertModel` for type safety instead of manual interfaces
-**Do:** Use the relational query API (`db.query.*`) for nested data to avoid N+1 problems
-**Do:** Use prepared statements for frequently executed queries in production
-**Do:** Use `drizzle-kit generate` + `migrate` in production (never `push`)
-**Do:** Pass `{ schema }` to `drizzle()` to enable the relational query API
-**Don't:** Use `drizzle-kit push` in production — it can cause data loss
-**Don't:** Write raw SQL when the Drizzle query builder supports the operation
-**Don't:** Forget to define `relations()` if you want to use `db.query.*` with `with`
-**Don't:** Create a new database connection per request in serverless — use connection pooling
## Troubleshooting
**Problem:** `db.query.tableName` is undefined
**Solution:** Pass all schema objects (including relations) to `drizzle()`: `drizzle(client, { schema })`
**Problem:** Migration conflicts after schema changes
**Solution:** Run `npx drizzle-kit generate` to create a new migration, then `npx drizzle-kit migrate`
**Problem:** Type errors on `.returning()` with MySQL
**Solution:** MySQL does not support `RETURNING`. Use `.execute()` and read `insertId` from the result instead.
@@ -0,0 +1,856 @@
---
name: electron-development
description: "Master Electron desktop app development with secure IPC, contextIsolation, preload scripts, multi-process architecture, electron-builder packaging, code signing, and auto-update."
risk: safe
source: community
date_added: "2026-03-12"
---
# Electron Development
You are a senior Electron engineer specializing in secure, production-grade desktop application architecture. You have deep expertise in Electron's multi-process model, IPC security patterns, native OS integration, application packaging, code signing, and auto-update strategies.
## Use this skill when
- Building new Electron desktop applications from scratch
- Securing an Electron app (contextIsolation, sandbox, CSP, nodeIntegration)
- Setting up IPC communication between main, renderer, and preload processes
- Packaging and distributing Electron apps with electron-builder or electron-forge
- Implementing auto-update with electron-updater
- Debugging main process issues or renderer crashes
- Managing multiple windows and application lifecycle
- Integrating native OS features (menus, tray, notifications, file system dialogs)
- Optimizing Electron app performance and bundle size
## Do not use this skill when
- Building web-only applications without desktop distribution → use `react-patterns`, `nextjs-best-practices`
- Building Tauri apps (Rust-based desktop alternative) → use `tauri-development` if available
- Building Chrome extensions → use `chrome-extension-developer`
- Implementing deep backend/server logic → use `nodejs-backend-patterns`
- Building mobile apps → use `react-native-architecture` or `flutter-expert`
## Instructions
1. Analyze the project structure and identify process boundaries.
2. Enforce security defaults: `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true`.
3. Design IPC channels with explicit whitelisting in the preload script.
4. Implement, test, and build with appropriate tooling.
5. Validate against the Production Security Checklist before shipping.
---
## Core Expertise Areas
### 1. Project Structure & Architecture
**Recommended project layout:**
```
my-electron-app/
├── package.json
├── electron-builder.yml # or forge.config.ts
├── src/
│ ├── main/
│ │ ├── main.ts # Main process entry
│ │ ├── ipc-handlers.ts # IPC channel handlers
│ │ ├── menu.ts # Application menu
│ │ ├── tray.ts # System tray
│ │ └── updater.ts # Auto-update logic
│ ├── preload/
│ │ └── preload.ts # Bridge between main ↔ renderer
│ ├── renderer/
│ │ ├── index.html # Entry HTML
│ │ ├── App.tsx # UI root (React/Vue/Svelte/vanilla)
│ │ ├── components/
│ │ └── styles/
│ └── shared/
│ ├── constants.ts # IPC channel names, shared enums
│ └── types.ts # Shared TypeScript interfaces
├── resources/
│ ├── icon.png # App icon (1024x1024)
│ └── entitlements.mac.plist # macOS entitlements
├── tests/
│ ├── unit/
│ └── e2e/
└── tsconfig.json
```
**Key architectural principles:**
- **Separate entry points**: Main, preload, and renderer each have their own build configuration.
- **Shared types, not shared modules**: The `shared/` directory contains only types, constants, and enums — never executable code imported across process boundaries.
- **Keep main process lean**: Main should orchestrate windows, handle IPC, and manage app lifecycle. Business logic belongs in the renderer or dedicated worker processes.
---
### 2. Process Model (Main / Renderer / Preload / Utility)
Electron runs **multiple processes** that are isolated by design:
| Process | Role | Node.js Access | DOM Access |
|---------|------|----------------|------------|
| **Main** | App lifecycle, windows, native APIs, IPC hub | ✅ Full | ❌ None |
| **Renderer** | UI rendering, user interaction | ❌ None (by default) | ✅ Full |
| **Preload** | Secure bridge between main and renderer | ✅ Limited (via contextBridge) | ✅ Before page loads |
| **Utility** | CPU-intensive tasks, background work | ✅ Full | ❌ None |
**BrowserWindow with security defaults (MANDATORY):**
```typescript
import { BrowserWindow } from 'electron';
import path from 'node:path';
function createMainWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
// ── SECURITY DEFAULTS (NEVER CHANGE THESE) ──
contextIsolation: true, // Isolates preload from renderer context
nodeIntegration: false, // Prevents require() in renderer
sandbox: true, // OS-level process sandboxing
// ── PRELOAD SCRIPT ──
preload: path.join(__dirname, '../preload/preload.js'),
// ── ADDITIONAL HARDENING ──
webSecurity: true, // Enforce same-origin policy
allowRunningInsecureContent: false,
experimentalFeatures: false,
},
});
// Content Security Policy
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:;"
],
},
});
});
return win;
}
```
> ⚠️ **CRITICAL**: Never set `nodeIntegration: true` or `contextIsolation: false` in production. These settings expose the renderer to remote code execution (RCE) attacks through XSS vulnerabilities.
---
### 3. Secure IPC Communication
IPC is the **only** safe channel for communication between main and renderer processes. All IPC must flow through the preload script.
**Preload script (contextBridge + explicit whitelisting):**
```typescript
// src/preload/preload.ts
import { contextBridge, ipcRenderer } from 'electron';
// ── WHITELIST: Only expose specific channels ──
const ALLOWED_SEND_CHANNELS = [
'file:save',
'file:open',
'app:get-version',
'dialog:show-open',
] as const;
const ALLOWED_RECEIVE_CHANNELS = [
'file:saved',
'file:opened',
'app:version',
'update:available',
'update:progress',
'update:downloaded',
'update:error',
] as const;
type SendChannel = typeof ALLOWED_SEND_CHANNELS[number];
type ReceiveChannel = typeof ALLOWED_RECEIVE_CHANNELS[number];
contextBridge.exposeInMainWorld('electronAPI', {
// One-way: renderer → main
send: (channel: SendChannel, ...args: unknown[]) => {
if (ALLOWED_SEND_CHANNELS.includes(channel)) {
ipcRenderer.send(channel, ...args);
}
},
// Two-way: renderer → main → renderer (request/response)
invoke: (channel: SendChannel, ...args: unknown[]) => {
if (ALLOWED_SEND_CHANNELS.includes(channel)) {
return ipcRenderer.invoke(channel, ...args);
}
return Promise.reject(new Error(`Channel "${channel}" is not allowed`));
},
// One-way: main → renderer (subscriptions)
on: (channel: ReceiveChannel, callback: (...args: unknown[]) => void) => {
if (ALLOWED_RECEIVE_CHANNELS.includes(channel)) {
const listener = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
}
return () => {};
},
});
```
**Main process IPC handlers:**
```typescript
// src/main/ipc-handlers.ts
import { ipcMain, dialog, BrowserWindow } from 'electron';
import { readFile, writeFile } from 'node:fs/promises';
export function registerIpcHandlers(): void {
// invoke() pattern: returns a value to the renderer
ipcMain.handle('file:open', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Text Files', extensions: ['txt', 'md'] }],
});
if (canceled || filePaths.length === 0) return null;
const content = await readFile(filePaths[0], 'utf-8');
return { path: filePaths[0], content };
});
ipcMain.handle('file:save', async (_event, filePath: string, content: string) => {
// VALIDATE INPUTS — never trust renderer data blindly
if (typeof filePath !== 'string' || typeof content !== 'string') {
throw new Error('Invalid arguments');
}
await writeFile(filePath, content, 'utf-8');
return { success: true };
});
ipcMain.handle('app:get-version', () => {
return process.versions.electron;
});
}
```
**Renderer usage (type-safe):**
```typescript
// src/renderer/App.tsx — or any renderer code
// The electronAPI is globally available via contextBridge
declare global {
interface Window {
electronAPI: {
send: (channel: string, ...args: unknown[]) => void;
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
};
}
}
// Open a file via IPC
async function openFile() {
const result = await window.electronAPI.invoke('file:open');
if (result) {
console.log('File content:', result.content);
}
}
// Subscribe to updates from main process
const unsubscribe = window.electronAPI.on('update:available', (version) => {
console.log('Update available:', version);
});
// Cleanup on unmount
// unsubscribe();
```
**IPC Pattern Summary:**
| Pattern | Method | Use Case |
|---------|--------|----------|
| **Fire-and-forget** | `ipcRenderer.send()``ipcMain.on()` | Logging, telemetry, non-critical notifications |
| **Request/Response** | `ipcRenderer.invoke()``ipcMain.handle()` | File operations, dialogs, data queries |
| **Push to renderer** | `webContents.send()``ipcRenderer.on()` | Progress updates, download status, auto-update |
> ⚠️ **Never** use `ipcRenderer.sendSync()` in production — it blocks the renderer's event loop and freezes the UI.
---
### 4. Security Hardening
#### Production Security Checklist
```
── MANDATORY ──
[ ] contextIsolation: true
[ ] nodeIntegration: false
[ ] sandbox: true
[ ] webSecurity: true
[ ] allowRunningInsecureContent: false
── IPC ──
[ ] Preload uses contextBridge with explicit channel whitelisting
[ ] All IPC inputs are validated in the main process
[ ] No raw ipcRenderer exposed to renderer context
[ ] No use of ipcRenderer.sendSync()
── CONTENT ──
[ ] Content Security Policy (CSP) headers set on all windows
[ ] No use of eval(), new Function(), or innerHTML with untrusted data
[ ] Remote content (if any) loaded in separate BrowserView with restricted permissions
[ ] protocol.registerSchemesAsPrivileged() uses minimal permissions
── NAVIGATION ──
[ ] webContents 'will-navigate' event intercepted — block unexpected URLs
[ ] webContents 'new-window' event intercepted — prevent pop-up exploitation
[ ] No shell.openExternal() with unsanitized URLs
── PACKAGING ──
[ ] ASAR archive enabled (protects source from casual inspection)
[ ] No sensitive credentials or API keys bundled in the app
[ ] Code signing configured for both Windows and macOS
[ ] Auto-update uses HTTPS and verifies signatures
```
**Preventing Navigation Hijacking:**
```typescript
// In main process, after creating a BrowserWindow
win.webContents.on('will-navigate', (event, url) => {
const parsedUrl = new URL(url);
// Only allow navigation within your app
if (parsedUrl.origin !== 'http://localhost:5173') { // dev server
event.preventDefault();
console.warn(`Blocked navigation to: ${url}`);
}
});
// Prevent new windows from being opened
win.webContents.setWindowOpenHandler(({ url }) => {
try {
const externalUrl = new URL(url);
const allowedHosts = new Set(['example.com', 'docs.example.com']);
// Never forward raw renderer-controlled URLs to the OS.
// Unvalidated links can enable phishing or abuse platform URL handlers.
if (externalUrl.protocol === 'https:' && allowedHosts.has(externalUrl.hostname)) {
require('electron').shell.openExternal(externalUrl.toString());
} else {
console.warn(`Blocked external URL: ${url}`);
}
} catch {
console.warn(`Rejected invalid external URL: ${url}`);
}
return { action: 'deny' }; // Block all new Electron windows
});
```
**Custom Protocol Registration (secure):**
```typescript
import { protocol } from 'electron';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { URL } from 'node:url';
// Register a custom protocol for loading local assets securely
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);
app.whenReady().then(() => {
protocol.handle('app', async (request) => {
const url = new URL(request.url);
const baseDir = path.resolve(__dirname, '../renderer');
// Strip the leading slash so path.resolve keeps baseDir as the root.
const relativePath = path.normalize(decodeURIComponent(url.pathname).replace(/^[/\\]+/, ''));
const filePath = path.resolve(baseDir, relativePath);
if (!filePath.startsWith(baseDir)) {
return new Response('Forbidden', { status: 403 });
}
const data = await readFile(filePath);
return new Response(data);
});
});
```
---
### 5. State Management Across Processes
**Strategy 1: Main process as single source of truth (recommended for most apps)**
```typescript
// src/main/store.ts
import { app } from 'electron';
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
interface AppState {
theme: 'light' | 'dark';
recentFiles: string[];
windowBounds: { x: number; y: number; width: number; height: number };
}
const DEFAULTS: AppState = {
theme: 'light',
recentFiles: [],
windowBounds: { x: 0, y: 0, width: 1200, height: 800 },
};
class Store {
private data: AppState;
private filePath: string;
constructor() {
this.filePath = path.join(app.getPath('userData'), 'settings.json');
this.data = this.load();
}
private load(): AppState {
try {
const raw = readFileSync(this.filePath, 'utf-8');
return { ...DEFAULTS, ...JSON.parse(raw) };
} catch {
return { ...DEFAULTS };
}
}
get<K extends keyof AppState>(key: K): AppState[K] {
return this.data[key];
}
set<K extends keyof AppState>(key: K, value: AppState[K]): void {
this.data[key] = value;
writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
}
}
export const store = new Store();
```
**Strategy 2: electron-store (lightweight persistent storage)**
```typescript
import Store from 'electron-store';
const store = new Store({
schema: {
theme: { type: 'string', enum: ['light', 'dark'], default: 'light' },
windowBounds: {
type: 'object',
properties: {
width: { type: 'number', default: 1200 },
height: { type: 'number', default: 800 },
},
},
},
});
// Usage
store.set('theme', 'dark');
console.log(store.get('theme')); // 'dark'
```
**Multi-window state synchronization:**
```typescript
// Main process: broadcast state changes to all windows
import { BrowserWindow } from 'electron';
function broadcastToAllWindows(channel: string, data: unknown): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send(channel, data);
}
}
}
// When theme changes:
ipcMain.handle('settings:set-theme', (_event, theme: 'light' | 'dark') => {
store.set('theme', theme);
broadcastToAllWindows('settings:theme-changed', theme);
});
```
---
### 6. Build, Signing & Distribution
#### electron-builder Configuration
```yaml
# electron-builder.yml
appId: com.mycompany.myapp
productName: My App
directories:
output: dist
buildResources: resources
files:
- "out/**/*" # compiled main + preload
- "renderer/**/*" # built renderer assets
- "package.json"
asar: true
compression: maximum
# ── macOS ──
mac:
category: public.app-category.developer-tools
hardenedRuntime: true
gatekeeperAssess: false
entitlements: resources/entitlements.mac.plist
entitlementsInherit: resources/entitlements.mac.plist
target:
- target: dmg
arch: [x64, arm64]
- target: zip
arch: [x64, arm64]
# ── Windows ──
win:
target:
- target: nsis
arch: [x64, arm64]
signingHashAlgorithms: [sha256]
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
perMachine: false
# ── Linux ──
linux:
target:
- target: AppImage
- target: deb
category: Development
maintainer: your-email@example.com
# ── Auto Update ──
publish:
provider: github
owner: your-org
repo: your-repo
```
#### Code Signing
```bash
# macOS: requires Apple Developer certificate
# Set environment variables before building:
export CSC_LINK="path/to/Developer_ID_Application.p12"
export CSC_KEY_PASSWORD="your-password"
# Windows: requires EV or standard code signing certificate
# Set environment variables:
export WIN_CSC_LINK="path/to/code-signing.pfx"
export WIN_CSC_KEY_PASSWORD="your-password"
# Build signed app
npx electron-builder --mac --win --publish never
```
#### Auto-Update with electron-updater
```typescript
// src/main/updater.ts
import { autoUpdater } from 'electron-updater';
import { BrowserWindow } from 'electron';
import log from 'electron-log';
export function setupAutoUpdater(mainWindow: BrowserWindow): void {
autoUpdater.logger = log;
autoUpdater.autoDownload = false; // Let user decide
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('update-available', (info) => {
mainWindow.webContents.send('update:available', {
version: info.version,
releaseNotes: info.releaseNotes,
});
});
autoUpdater.on('download-progress', (progress) => {
mainWindow.webContents.send('update:progress', {
percent: Math.round(progress.percent),
bytesPerSecond: progress.bytesPerSecond,
});
});
autoUpdater.on('update-downloaded', () => {
mainWindow.webContents.send('update:downloaded');
});
autoUpdater.on('error', (err) => {
log.error('Update error:', err);
mainWindow.webContents.send('update:error', err.message);
});
// Check for updates every 4 hours
setInterval(() => autoUpdater.checkForUpdates(), 4 * 60 * 60 * 1000);
autoUpdater.checkForUpdates();
}
// Expose to renderer via IPC
ipcMain.handle('update:download', () => autoUpdater.downloadUpdate());
ipcMain.handle('update:install', () => autoUpdater.quitAndInstall());
```
#### Bundle Size Optimization
- ✅ Use `asar: true` to package sources into a single archive
- ✅ Set `compression: maximum` in electron-builder config
- ✅ Exclude dev dependencies: `"files"` pattern should only include compiled output
- ✅ Use a bundler (Vite, webpack, esbuild) to tree-shake the renderer
- ✅ Audit `node_modules` shipped with the app — use `electron-builder`'s `files` exclude patterns
- ✅ Consider `@electron/rebuild` for native modules instead of shipping prebuilt for all platforms
- ❌ Do NOT bundle the entire `node_modules` — only production dependencies
---
### 7. Developer Experience & Debugging
#### Development Setup with Hot Reload
```json
// package.json scripts
{
"scripts": {
"dev": "concurrently \"npm run dev:renderer\" \"npm run dev:main\"",
"dev:renderer": "vite",
"dev:main": "electron-vite dev",
"build": "electron-vite build",
"start": "electron ."
}
}
```
**Recommended toolchain:**
- **electron-vite** or **electron-forge with Vite plugin** — modern, fast HMR for renderer
- **tsx** or **ts-node** — for running TypeScript in main process during development
- **concurrently** — run renderer dev server + Electron simultaneously
#### Debugging the Main Process
```json
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"args": [".", "--remote-debugging-port=9223"],
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/out/**/*.js"],
"env": {
"NODE_ENV": "development"
}
}
]
}
```
**Other debugging techniques:**
```typescript
// Enable DevTools only in development
if (process.env.NODE_ENV === 'development') {
win.webContents.openDevTools({ mode: 'detach' });
}
// Inspect specific renderer processes from command line:
// electron . --inspect=5858 --remote-debugging-port=9223
```
#### Testing Strategy
**Unit testing (Vitest / Jest):**
```typescript
// tests/unit/store.test.ts
import { describe, it, expect, vi } from 'vitest';
// Mock Electron modules for unit tests
vi.mock('electron', () => ({
app: { getPath: () => '/tmp/test' },
}));
describe('Store', () => {
it('returns default values for missing keys', () => {
// Test store logic without Electron runtime
});
});
```
**E2E testing (Playwright + Electron):**
```typescript
// tests/e2e/app.spec.ts
import { test, expect, _electron as electron } from '@playwright/test';
test('app launches and shows main window', async () => {
const app = await electron.launch({ args: ['.'] });
const window = await app.firstWindow();
// Wait for the app to fully load
await window.waitForLoadState('domcontentloaded');
const title = await window.title();
expect(title).toBe('My App');
// Take a screenshot for visual regression
await window.screenshot({ path: 'tests/screenshots/main-window.png' });
await app.close();
});
test('file open dialog works via IPC', async () => {
const app = await electron.launch({ args: ['.'] });
const window = await app.firstWindow();
// Test IPC by evaluating in the renderer context
const version = await window.evaluate(async () => {
return window.electronAPI.invoke('app:get-version');
});
expect(version).toBeTruthy();
await app.close();
});
```
**Playwright config for Electron:**
```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
retries: 1,
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
});
```
---
## Application Lifecycle Management
```typescript
// src/main/main.ts
import { app, BrowserWindow } from 'electron';
import { registerIpcHandlers } from './ipc-handlers';
import { setupAutoUpdater } from './updater';
import { store } from './store';
let mainWindow: BrowserWindow | null = null;
app.whenReady().then(() => {
registerIpcHandlers();
mainWindow = createMainWindow();
// Restore window bounds
const bounds = store.get('windowBounds');
if (bounds) mainWindow.setBounds(bounds);
// Save window bounds on close
mainWindow.on('close', () => {
if (mainWindow) store.set('windowBounds', mainWindow.getBounds());
});
// Auto-update (only in production)
if (app.isPackaged) {
setupAutoUpdater(mainWindow);
}
// macOS: re-create window when dock icon is clicked
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createMainWindow();
}
});
});
// Quit when all windows are closed (except on macOS)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// Security: prevent additional renderers from being created
app.on('web-contents-created', (_event, contents) => {
contents.on('will-attach-webview', (event) => {
event.preventDefault(); // Block <webview> tags
});
});
```
---
## Common Issue Diagnostics
### White Screen on Launch
**Symptoms**: App starts but renderer shows a blank/white page
**Root causes**: Incorrect `loadFile`/`loadURL` path, build output missing, CSP blocking scripts
**Solutions**: Verify the path passed to `win.loadFile()` or `win.loadURL()` exists relative to the packaged app. Check DevTools console for CSP violations. In development, ensure the Vite/webpack dev server is running before Electron starts.
### IPC Messages Not Received
**Symptoms**: `invoke()` hangs or `send()` has no effect
**Root causes**: Channel name mismatch, preload not loaded, contextBridge not exposing the channel
**Solutions**: Verify channel names match exactly between preload, main, and renderer. Confirm `preload` path is correct in `webPreferences`. Check that the channel is in the whitelist array.
### Native Module Crashes
**Symptoms**: App crashes on startup with `MODULE_NOT_FOUND` or `invalid ELF header`
**Root causes**: Native module compiled for wrong Electron/Node ABI version
**Solutions**: Run `npx @electron/rebuild` after installing native modules. Ensure `electron-builder` is configured with the correct Electron version for rebuilding.
### App Not Updating
**Symptoms**: `autoUpdater.checkForUpdates()` returns nothing or errors
**Root causes**: Missing `publish` config, unsigned app (macOS), incorrect GitHub release assets
**Solutions**: Verify `publish` section in `electron-builder.yml`. On macOS, app must be code-signed and notarized. Ensure the GitHub release contains the `-mac.zip` and `latest-mac.yml` (or equivalent Windows files).
### Large Bundle Size (>200MB)
**Symptoms**: Built application is excessively large
**Root causes**: Dev dependencies bundled, no tree-shaking, duplicate Electron binaries
**Solutions**: Audit `files` patterns in `electron-builder.yml`. Use a bundler (Vite/esbuild) for the renderer. Check that `devDependencies` are not in `dependencies`. Use `compression: maximum`.
---
## Best Practices
-**Always** set `contextIsolation: true` and `nodeIntegration: false`
-**Always** use `contextBridge` in preload with an explicit channel whitelist
-**Always** validate IPC inputs in the main process — treat renderer as untrusted
-**Always** use `ipcMain.handle()` / `ipcRenderer.invoke()` for request/response IPC
-**Always** configure Content Security Policy headers
-**Always** sanitize URLs before passing to `shell.openExternal()`
-**Always** code-sign your production builds
- ✅ Use Playwright with `@playwright/test`'s Electron support for E2E tests
- ✅ Store user data in `app.getPath('userData')`, never in the app directory
-**Never** set `nodeIntegration: true` — this is the #1 Electron security vulnerability
-**Never** expose raw `ipcRenderer` or `require()` to the renderer context
-**Never** use `remote` module (deprecated and insecure)
-**Never** use `ipcRenderer.sendSync()` — it blocks the renderer event loop
-**Never** disable `webSecurity` in production
-**Never** load remote/untrusted content without a strict CSP and sandboxing
## Limitations
- Electron bundles Chromium + Node.js, resulting in a minimum ~150MB app size — this is a fundamental trade-off of the framework
- Not suitable for apps where minimal install size is critical (consider Tauri instead)
- Single-window apps are simpler to architect; multi-window state synchronization requires careful IPC design
- Auto-update on Linux requires distributing via Snap, Flatpak, or custom mechanisms — `electron-updater` has limited Linux support
- macOS notarization requires an Apple Developer account ($99/year) and is mandatory for distribution outside the Mac App Store
- Debugging main process issues requires VS Code or Chrome DevTools via `--inspect` flag — there is no integrated debugger in Electron itself
## Related Skills
- `chrome-extension-developer` — When building browser extensions instead of desktop apps (shares multi-process model concepts)
- `docker-expert` — When containerizing Electron's build pipeline or CI/CD
- `react-patterns` / `react-best-practices` — When using React for the renderer UI
- `typescript-pro` — When setting up advanced TypeScript configurations for multi-target builds
- `nodejs-backend-patterns` — When the main process needs complex backend logic
- `github-actions-templates` — When setting up CI/CD for cross-platform Electron builds
@@ -0,0 +1,36 @@
---
name: exa-search
description: Semantic search, similar content discovery, and structured research using Exa API
---
# exa-search
## Overview
Semantic search, similar content discovery, and structured research using Exa API
## When to Use
- When you need semantic/embeddings-based search
- When finding similar content
- When searching by category (company, people, research papers, etc.)
## Installation
```bash
npx skills add -g BenedictKing/exa-search
```
## Step-by-Step Guide
1. Install the skill using the command above
2. Configure Exa API key
3. Use naturally in Claude Code conversations
## Examples
See [GitHub Repository](https://github.com/BenedictKing/exa-search) for examples.
## Best Practices
- Configure API keys via environment variables
## Troubleshooting
See the GitHub repository for troubleshooting guides.
## Related Skills
- context7-auto-research, tavily-web, firecrawl-scraper, codex-review
@@ -0,0 +1,73 @@
---
name: expo-deployment
description: "Deploy Expo apps to production"
risk: safe
source: "https://github.com/expo/skills/tree/main/plugins/expo-deployment"
date_added: "2026-02-27"
---
# Expo Deployment
## Overview
Deploy Expo applications to production environments, including app stores and over-the-air updates.
## When to Use This Skill
Use this skill when you need to deploy Expo apps to production.
Use this skill when:
- Deploying Expo apps to production
- Publishing to app stores (iOS App Store, Google Play)
- Setting up over-the-air (OTA) updates
- Configuring production build settings
- Managing release channels and versions
## Instructions
This skill provides guidance for deploying Expo apps:
1. **Build Configuration**: Set up production build settings
2. **App Store Submission**: Prepare and submit to app stores
3. **OTA Updates**: Configure over-the-air update channels
4. **Release Management**: Manage versions and release channels
5. **Production Optimization**: Optimize apps for production
## Deployment Workflow
### Pre-Deployment
1. Ensure all tests pass
2. Update version numbers
3. Configure production environment variables
4. Review and optimize app bundle size
5. Test production builds locally
### App Store Deployment
1. Build production binaries (iOS/Android)
2. Configure app store metadata
3. Submit to App Store Connect / Google Play Console
4. Manage app store listings and screenshots
5. Handle app review process
### OTA Updates
1. Configure update channels (production, staging, etc.)
2. Build and publish updates
3. Manage rollout strategies
4. Monitor update adoption
5. Handle rollbacks if needed
## Best Practices
- Use EAS Build for reliable production builds
- Test production builds before submission
- Implement proper error tracking and analytics
- Use release channels for staged rollouts
- Keep app store metadata up to date
- Monitor app performance in production
## Resources
For more information, see the [source repository](https://github.com/expo/skills/tree/main/plugins/expo-deployment).
@@ -0,0 +1,456 @@
---
name: fastapi-endpoint
description: Plan and build production-ready FastAPI endpoints with async SQLAlchemy, Pydantic v2 models, dependency injection for auth, and pytest tests. Uses interview-driven planning to clarify data models, authentication method, pagination strategy, and caching before writing any code.
tags: [fastapi, python, api, async, pydantic, sqlalchemy, backend]
---
# FastAPI Endpoint Builder
## When to use
Use this skill when you need to:
- Add new API endpoints to an existing FastAPI project
- Build CRUD operations with proper validation and error handling
- Set up authenticated endpoints with dependency injection
- Create async database queries with SQLAlchemy 2.0
- Generate complete test coverage for API routes
## Phase 1: Explore (Plan Mode)
Enter plan mode. Before writing any code, explore the existing project to understand:
### Project structure
- Find the FastAPI app entry point (`main.py`, `app.py`, or `app/__init__.py`)
- Identify the router organization pattern (single file vs `routers/` directory)
- Check for existing `models/`, `schemas/`, `crud/`, or `services/` directories
- Look at `pyproject.toml` or `requirements.txt` for installed dependencies
### Existing patterns
- How are existing endpoints structured? (function-based vs class-based)
- What ORM is used? (SQLAlchemy 2.0 async, Tortoise, raw SQL, none)
- How is the database session managed? (`Depends(get_db)`, middleware, other)
- What auth pattern exists? (OAuth2PasswordBearer, API key header, custom)
- Are there existing Pydantic base models or shared schemas?
- What response format is standard? (direct model, wrapped `{"data": ..., "meta": ...}`)
### Test patterns
- Where do tests live? (`tests/`, `test_*.py`, `*_test.py`)
- What test client is used? (httpx AsyncClient, TestClient, pytest-asyncio)
- Are there test fixtures for database and auth?
## Phase 2: Interview (AskUserQuestion)
Use AskUserQuestion to clarify requirements. Ask in rounds — do NOT dump all questions at once.
### Round 1: Core endpoint
```
Question: "What resource does this endpoint manage?"
Header: "Resource"
Options:
- "New resource (I'll describe the fields)" — Creating a new data model from scratch
- "Existing model (extend it)" — Adding endpoints for a model that already exists in the codebase
- "Relationship endpoint (nested)" — e.g., /users/{id}/orders — endpoint on a related resource
Question: "Which HTTP methods do you need?"
Header: "Methods"
multiSelect: true
Options:
- "Full CRUD (GET list, GET detail, POST, PUT/PATCH, DELETE)" — All standard operations
- "Read-only (GET list + GET detail)" — No mutations
- "Custom action (POST /resource/{id}/action)" — Business logic endpoint, not standard CRUD
```
### Round 2: Data model (if new resource)
```
Question: "What fields does the resource have? (describe briefly)"
Header: "Fields"
Options:
- "Simple (< 6 fields, basic types)" — Strings, ints, booleans, dates
- "Medium (6-15 fields, some relations)" — Includes foreign keys or enums
- "Complex (nested objects, polymorphic)" — JSON fields, discriminated unions, computed fields
```
### Round 3: Auth and access control
```
Question: "How should this endpoint be authenticated?"
Header: "Auth"
Options:
- "JWT Bearer token (Recommended)" — OAuth2PasswordBearer with JWT decode
- "API Key header" — X-API-Key header validation
- "No auth (public)" — Open endpoint, no authentication required
- "Use existing auth" — Reuse the auth dependency already in the project
Question: "Do you need role-based access control?"
Header: "RBAC"
Options:
- "No — any authenticated user" — Single permission level
- "Yes — role check (admin, user, etc.)" — Require specific roles per endpoint
- "Yes — ownership check" — Users can only access their own resources
```
### Round 4: Pagination, filtering, caching
```
Question: "What pagination style for list endpoints?"
Header: "Pagination"
Options:
- "Cursor-based (Recommended)" — Best for real-time data, no offset drift
- "Offset/limit" — Simple, good for admin panels with page numbers
- "No pagination" — Small datasets, return all results
Question: "Do you need response caching?"
Header: "Caching"
Options:
- "No caching" — Fresh data on every request
- "Cache-Control headers" — Client-side caching via HTTP headers
- "Redis/in-memory cache" — Server-side caching with TTL
```
## Phase 3: Plan (ExitPlanMode)
Write a concrete implementation plan covering:
1. **Files to create/modify** — exact paths based on project structure discovered in Phase 1
2. **Pydantic schemas**`Create`, `Update`, `Response`, and `List` schemas with field types
3. **SQLAlchemy model** — table name, columns, relationships, indexes
4. **CRUD/service layer** — async functions for each operation
5. **Router** — endpoint signatures, status codes, response models
6. **Dependencies** — auth, pagination, filtering dependencies
7. **Tests** — test cases for happy path, validation errors, auth failures, not found
Present via ExitPlanMode for user approval.
## Phase 4: Execute
After approval, implement following this order:
### Step 1: Pydantic schemas
```python
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from uuid import UUID
class ResourceBase(BaseModel):
"""Shared fields between create and response."""
name: str
# ... fields from interview
class ResourceCreate(ResourceBase):
"""Fields required to create the resource."""
pass
class ResourceUpdate(BaseModel):
"""All fields optional for partial updates."""
name: str | None = None
class ResourceResponse(ResourceBase):
"""Full resource with DB-generated fields."""
model_config = ConfigDict(from_attributes=True)
id: UUID
created_at: datetime
updated_at: datetime
class ResourceListResponse(BaseModel):
"""Paginated list response."""
data: list[ResourceResponse]
next_cursor: str | None = None
has_more: bool
```
### Step 2: SQLAlchemy model
```python
from sqlalchemy import Column, String, DateTime, func
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
import uuid
from app.database import Base
class Resource(Base):
__tablename__ = "resources"
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String, nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
```
### Step 3: CRUD/service layer
```python
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from uuid import UUID
async def get_resource(db: AsyncSession, resource_id: UUID) -> Resource | None:
result = await db.execute(select(Resource).where(Resource.id == resource_id))
return result.scalar_one_or_none()
async def list_resources(
db: AsyncSession,
cursor: str | None = None,
limit: int = 20,
) -> tuple[list[Resource], str | None]:
query = select(Resource).order_by(Resource.created_at.desc()).limit(limit + 1)
if cursor:
query = query.where(Resource.created_at < decode_cursor(cursor))
result = await db.execute(query)
items = list(result.scalars().all())
next_cursor = encode_cursor(items[-1].created_at) if len(items) > limit else None
return items[:limit], next_cursor
async def create_resource(db: AsyncSession, data: ResourceCreate) -> Resource:
resource = Resource(**data.model_dump())
db.add(resource)
await db.commit()
await db.refresh(resource)
return resource
async def update_resource(
db: AsyncSession, resource_id: UUID, data: ResourceUpdate
) -> Resource | None:
resource = await get_resource(db, resource_id)
if not resource:
return None
for field, value in data.model_dump(exclude_unset=True).items():
setattr(resource, field, value)
await db.commit()
await db.refresh(resource)
return resource
async def delete_resource(db: AsyncSession, resource_id: UUID) -> bool:
resource = await get_resource(db, resource_id)
if not resource:
return False
await db.delete(resource)
await db.commit()
return True
```
### Step 4: Router with dependencies
```python
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import UUID
router = APIRouter(prefix="/resources", tags=["resources"])
@router.get("", response_model=ResourceListResponse)
async def list_resources_endpoint(
cursor: str | None = Query(None),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), # if auth required
):
items, next_cursor = await list_resources(db, cursor=cursor, limit=limit)
return ResourceListResponse(
data=items,
next_cursor=next_cursor,
has_more=next_cursor is not None,
)
@router.get("/{resource_id}", response_model=ResourceResponse)
async def get_resource_endpoint(
resource_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
resource = await get_resource(db, resource_id)
if not resource:
raise HTTPException(status_code=404, detail="Resource not found")
return resource
@router.post("", response_model=ResourceResponse, status_code=status.HTTP_201_CREATED)
async def create_resource_endpoint(
data: ResourceCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await create_resource(db, data)
@router.patch("/{resource_id}", response_model=ResourceResponse)
async def update_resource_endpoint(
resource_id: UUID,
data: ResourceUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
resource = await update_resource(db, resource_id, data)
if not resource:
raise HTTPException(status_code=404, detail="Resource not found")
return resource
@router.delete("/{resource_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_resource_endpoint(
resource_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
deleted = await delete_resource(db, resource_id)
if not deleted:
raise HTTPException(status_code=404, detail="Resource not found")
```
### Step 5: Tests
```python
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def client():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
@pytest.mark.asyncio
async def test_create_resource(client: AsyncClient, auth_headers: dict):
response = await client.post(
"/resources",
json={"name": "Test Resource"},
headers=auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Resource"
assert "id" in data
@pytest.mark.asyncio
async def test_get_resource_not_found(client: AsyncClient, auth_headers: dict):
response = await client.get(
"/resources/00000000-0000-0000-0000-000000000000",
headers=auth_headers,
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_list_resources_pagination(client: AsyncClient, auth_headers: dict):
# Create multiple resources first
for i in range(5):
await client.post(
"/resources",
json={"name": f"Resource {i}"},
headers=auth_headers,
)
response = await client.get("/resources?limit=2", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert len(data["data"]) == 2
assert data["has_more"] is True
assert data["next_cursor"] is not None
@pytest.mark.asyncio
async def test_create_resource_unauthorized(client: AsyncClient):
response = await client.post("/resources", json={"name": "Test"})
assert response.status_code in (401, 403)
@pytest.mark.asyncio
async def test_update_resource_partial(client: AsyncClient, auth_headers: dict):
# Create
create_resp = await client.post(
"/resources",
json={"name": "Original"},
headers=auth_headers,
)
resource_id = create_resp.json()["id"]
# Partial update
response = await client.patch(
f"/resources/{resource_id}",
json={"name": "Updated"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["name"] == "Updated"
@pytest.mark.asyncio
async def test_delete_resource(client: AsyncClient, auth_headers: dict):
create_resp = await client.post(
"/resources",
json={"name": "To Delete"},
headers=auth_headers,
)
resource_id = create_resp.json()["id"]
response = await client.delete(
f"/resources/{resource_id}", headers=auth_headers
)
assert response.status_code == 204
# Verify deleted
get_resp = await client.get(
f"/resources/{resource_id}", headers=auth_headers
)
assert get_resp.status_code == 404
```
## Key patterns to follow
### Dependency injection for auth
```python
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
payload = decode_jwt(token)
user = await db.get(User, payload["sub"])
if not user:
raise HTTPException(status_code=401, detail="Invalid token")
return user
def require_role(*roles: str):
"""Factory for role-based access control."""
async def checker(current_user: User = Depends(get_current_user)):
if current_user.role not in roles:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return current_user
return checker
```
### Cursor-based pagination helper
```python
import base64
from datetime import datetime
def encode_cursor(dt: datetime) -> str:
return base64.urlsafe_b64encode(dt.isoformat().encode()).decode()
def decode_cursor(cursor: str) -> datetime:
return datetime.fromisoformat(base64.urlsafe_b64decode(cursor).decode())
```
### Error responses
Always use FastAPI's `HTTPException` with consistent detail messages. For validation errors, Pydantic v2 handles them automatically via `RequestValidationError` (422).
```python
# 404 — not found
raise HTTPException(status_code=404, detail="Resource not found")
# 409 — conflict (duplicate)
raise HTTPException(status_code=409, detail="Resource with this name already exists")
# 403 — forbidden
raise HTTPException(status_code=403, detail="Not allowed to modify this resource")
```
## Checklist before finishing
- [ ] All endpoints return proper status codes (201 for POST, 204 for DELETE)
- [ ] Pydantic schemas use `model_config = ConfigDict(from_attributes=True)` for ORM mode
- [ ] List endpoint has pagination with configurable limit
- [ ] Auth dependency is applied to all non-public endpoints
- [ ] Tests cover: happy path, not found, unauthorized, validation errors
- [ ] Router is registered in the main FastAPI app
- [ ] Database model has proper indexes on filtered/sorted columns
@@ -0,0 +1,37 @@
---
name: firecrawl-scraper
description: Deep web scraping, screenshots, PDF parsing, and website crawling using Firecrawl API
---
# firecrawl-scraper
## Overview
Deep web scraping, screenshots, PDF parsing, and website crawling using Firecrawl API
## When to Use
- When you need deep content extraction from web pages
- When page interaction is required (clicking, scrolling, etc.)
- When you want screenshots or PDF parsing
- When batch scraping multiple URLs
## Installation
```bash
npx skills add -g BenedictKing/firecrawl-scraper
```
## Step-by-Step Guide
1. Install the skill using the command above
2. Configure Firecrawl API key
3. Use naturally in Claude Code conversations
## Examples
See [GitHub Repository](https://github.com/BenedictKing/firecrawl-scraper) for examples.
## Best Practices
- Configure API keys via environment variables
## Troubleshooting
See the GitHub repository for troubleshooting guides.
## Related Skills
- context7-auto-research, tavily-web, exa-search, codex-review
@@ -0,0 +1,348 @@
---
name: hono
description: "Build ultra-fast web APIs and full-stack apps with Hono — runs on Cloudflare Workers, Deno, Bun, Node.js, and any WinterCG-compatible runtime."
category: backend
risk: safe
source: community
date_added: "2026-03-18"
author: suhaibjanjua
tags: [hono, edge, cloudflare-workers, bun, deno, api, typescript, web-standards]
tools: [claude, cursor, gemini]
---
# Hono Web Framework
## Overview
Hono (炎, "flame" in Japanese) is a small, ultrafast web framework built on Web Standards (`Request`/`Response`/`fetch`). It runs anywhere: Cloudflare Workers, Deno Deploy, Bun, Node.js, AWS Lambda, and any WinterCG-compatible runtime — with the same code. Hono's router is one of the fastest available, and its middleware system, built-in JSX support, and RPC client make it a strong choice for edge APIs, BFFs, and lightweight full-stack apps.
## When to Use This Skill
- Use when building a REST or RPC API for edge deployment (Cloudflare Workers, Deno Deploy)
- Use when you need a minimal but type-safe server framework for Bun or Node.js
- Use when building a Backend for Frontend (BFF) layer with low latency requirements
- Use when migrating from Express but wanting better TypeScript support and edge compatibility
- Use when the user asks about Hono routing, middleware, `c.req`, `c.json`, or `hc()` RPC client
## How It Works
### Step 1: Project Setup
**Cloudflare Workers (recommended for edge):**
```bash
npm create hono@latest my-api
# Select: cloudflare-workers
cd my-api
npm install
npm run dev # Wrangler local dev
npm run deploy # Deploy to Cloudflare
```
**Bun / Node.js:**
```bash
mkdir my-api && cd my-api
bun init
bun add hono
```
```typescript
// src/index.ts (Bun)
import { Hono } from 'hono';
const app = new Hono();
app.get('/', c => c.text('Hello Hono!'));
export default {
port: 3000,
fetch: app.fetch,
};
```
### Step 2: Routing
```typescript
import { Hono } from 'hono';
const app = new Hono();
// Basic methods
app.get('/posts', c => c.json({ posts: [] }));
app.post('/posts', c => c.json({ created: true }, 201));
app.put('/posts/:id', c => c.json({ updated: true }));
app.delete('/posts/:id', c => c.json({ deleted: true }));
// Route params and query strings
app.get('/posts/:id', async c => {
const id = c.req.param('id');
const format = c.req.query('format') ?? 'json';
return c.json({ id, format });
});
// Wildcard
app.get('/static/*', c => c.text('static file'));
export default app;
```
**Chained routing:**
```typescript
app
.get('/users', listUsers)
.post('/users', createUser)
.get('/users/:id', getUser)
.patch('/users/:id', updateUser)
.delete('/users/:id', deleteUser);
```
### Step 3: Middleware
Hono middleware works exactly like `fetch` interceptors — before and after handlers:
```typescript
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
import { bearerAuth } from 'hono/bearer-auth';
const app = new Hono();
// Built-in middleware
app.use('*', logger());
app.use('/api/*', cors({ origin: 'https://myapp.com' }));
app.use('/api/admin/*', bearerAuth({ token: process.env.API_TOKEN! }));
// Custom middleware
app.use('*', async (c, next) => {
c.set('requestId', crypto.randomUUID());
await next();
c.header('X-Request-Id', c.get('requestId'));
});
```
**Available built-in middleware:** `logger`, `cors`, `csrf`, `etag`, `cache`, `basicAuth`, `bearerAuth`, `jwt`, `compress`, `bodyLimit`, `timeout`, `prettyJSON`, `secureHeaders`.
### Step 4: Request and Response Helpers
```typescript
app.post('/submit', async c => {
// Parse body
const body = await c.req.json<{ name: string; email: string }>();
const form = await c.req.formData();
const text = await c.req.text();
// Headers and cookies
const auth = c.req.header('authorization');
const token = getCookie(c, 'session');
// Responses
return c.json({ ok: true }); // JSON
return c.text('hello'); // plain text
return c.html('<h1>Hello</h1>'); // HTML
return c.redirect('/dashboard', 302); // redirect
return new Response(stream, { status: 200 }); // raw Response
});
```
### Step 5: Zod Validator Middleware
```typescript
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const createPostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
tags: z.array(z.string()).default([]),
});
app.post(
'/posts',
zValidator('json', createPostSchema),
async c => {
const data = c.req.valid('json'); // fully typed
const post = await db.post.create({ data });
return c.json(post, 201);
}
);
```
### Step 6: Route Groups and App Composition
```typescript
// src/routes/posts.ts
import { Hono } from 'hono';
const posts = new Hono();
posts.get('/', async c => { /* list posts */ });
posts.post('/', async c => { /* create post */ });
posts.get('/:id', async c => { /* get post */ });
export default posts;
```
```typescript
// src/index.ts
import { Hono } from 'hono';
import posts from './routes/posts';
import users from './routes/users';
const app = new Hono().basePath('/api');
app.route('/posts', posts);
app.route('/users', users);
export default app;
```
### Step 7: RPC Client (End-to-End Type Safety)
Hono's RPC mode exports route types that the `hc` client consumes — similar to tRPC but using fetch conventions:
```typescript
// server: src/routes/posts.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const posts = new Hono()
.get('/', c => c.json({ posts: [{ id: '1', title: 'Hello' }] }))
.post(
'/',
zValidator('json', z.object({ title: z.string() })),
async c => {
const { title } = c.req.valid('json');
return c.json({ id: '2', title }, 201);
}
);
export default posts;
export type PostsType = typeof posts;
```
```typescript
// client: src/client.ts
import { hc } from 'hono/client';
import type { PostsType } from '../server/routes/posts';
const client = hc<PostsType>('/api/posts');
// Fully typed — autocomplete on routes, params, and responses
const { posts } = await client.$get().json();
const newPost = await client.$post({ json: { title: 'New Post' } }).json();
```
## Examples
### Example 1: JWT Auth Middleware
```typescript
import { Hono } from 'hono';
import { jwt, sign } from 'hono/jwt';
const app = new Hono();
const SECRET = process.env.JWT_SECRET!;
app.post('/login', async c => {
const { email, password } = await c.req.json();
const user = await validateUser(email, password);
if (!user) return c.json({ error: 'Invalid credentials' }, 401);
const token = await sign({ sub: user.id, exp: Math.floor(Date.now() / 1000) + 3600 }, SECRET);
return c.json({ token });
});
app.use('/api/*', jwt({ secret: SECRET }));
app.get('/api/me', async c => {
const payload = c.get('jwtPayload');
const user = await getUserById(payload.sub);
return c.json(user);
});
export default app;
```
### Example 2: Cloudflare Workers with D1 Database
```typescript
// src/index.ts
import { Hono } from 'hono';
type Bindings = {
DB: D1Database;
API_TOKEN: string;
};
const app = new Hono<{ Bindings: Bindings }>();
app.get('/users', async c => {
const { results } = await c.env.DB.prepare('SELECT * FROM users LIMIT 50').all();
return c.json(results);
});
app.post('/users', async c => {
const { name, email } = await c.req.json();
await c.env.DB.prepare('INSERT INTO users (name, email) VALUES (?, ?)')
.bind(name, email)
.run();
return c.json({ created: true }, 201);
});
export default app;
```
### Example 3: Streaming Response
```typescript
import { stream, streamText } from 'hono/streaming';
app.get('/stream', c =>
streamText(c, async stream => {
for (const chunk of ['Hello', ' ', 'World']) {
await stream.write(chunk);
await stream.sleep(100);
}
})
);
```
## Best Practices
- ✅ Use route groups (sub-apps) to keep handlers in separate files — `app.route('/users', usersRouter)`
- ✅ Use `zValidator` for all request body, query, and param validation
- ✅ Type Cloudflare Workers bindings with the `Bindings` generic: `new Hono<{ Bindings: Env }>()`
- ✅ Use the RPC client (`hc`) when your frontend and backend share the same repo
- ✅ Prefer returning `c.json()`/`c.text()` over `new Response()` for cleaner code
- ❌ Don't use Node.js-specific APIs (`fs`, `path`, `process`) if you want edge portability
- ❌ Don't add heavy dependencies — Hono's value is its tiny footprint on edge runtimes
- ❌ Don't skip middleware typing — use generics (`Variables`, `Bindings`) to keep `c.get()` type-safe
## Security & Safety Notes
- Always validate input with `zValidator` before using data from requests.
- Use Hono's built-in `csrf` middleware on mutation endpoints when serving HTML/forms.
- For Cloudflare Workers, store secrets in `wrangler.toml` `[vars]` (non-secret) or `wrangler secret put` (secret) — never hardcode them in source.
- When using `bearerAuth` or `jwt`, ensure tokens are validated server-side — do not trust client-provided user IDs.
- Rate-limit sensitive endpoints (auth, password reset) with Cloudflare Rate Limiting or a custom middleware.
## Common Pitfalls
- **Problem:** Handler returns `undefined` — response is empty
**Solution:** Always `return` a response from handlers: `return c.json(...)` not just `c.json(...)`.
- **Problem:** Middleware runs after the response is sent
**Solution:** Call `await next()` before post-response logic; Hono runs code after `next()` as the response travels back up the chain.
- **Problem:** `c.env` is undefined on Node.js
**Solution:** Cloudflare `env` bindings only exist in Workers. Use `process.env` on Node.js.
- **Problem:** Route not matching — gets a 404
**Solution:** Check that `app.route('/prefix', subRouter)` uses the same prefix your client calls. Sub-routers should **not** repeat the prefix in their own routes.
## Related Skills
- `@cloudflare-workers-expert` — Deep dive into Cloudflare Workers platform specifics
- `@trpc-fullstack` — Alternative RPC approach for TypeScript full-stack apps
- `@zod-validation-expert` — Detailed Zod schema patterns used with `@hono/zod-validator`
- `@nodejs-backend-patterns` — When you need a Node.js-specific backend (not edge)
@@ -0,0 +1,36 @@
---
name: nextjs-app-router-patterns
description: "Comprehensive patterns for Next.js 14+ App Router architecture, Server Components, and modern full-stack React development."
risk: safe
source: community
date_added: "2026-02-27"
---
# Next.js App Router Patterns
Comprehensive patterns for Next.js 14+ App Router architecture, Server Components, and modern full-stack React development.
## Use this skill when
- Building new Next.js applications with App Router
- Migrating from Pages Router to App Router
- Implementing Server Components and streaming
- Setting up parallel and intercepting routes
- Optimizing data fetching and caching
- Building full-stack features with Server Actions
## Do not use this skill when
- The task is unrelated to next.js app router patterns
- You need a different domain or tool outside this scope
## Instructions
- Clarify goals, constraints, and required inputs.
- Apply relevant best practices and validate outcomes.
- Provide actionable steps and verification.
- If detailed examples are required, open `resources/implementation-playbook.md`.
## Resources
- `resources/implementation-playbook.md` for detailed patterns and examples.
@@ -0,0 +1,543 @@
# Next.js App Router Patterns Implementation Playbook
This file contains detailed patterns, checklists, and code samples referenced by the skill.
# Next.js App Router Patterns
Comprehensive patterns for Next.js 14+ App Router architecture, Server Components, and modern full-stack React development.
## When to Use This Skill
- Building new Next.js applications with App Router
- Migrating from Pages Router to App Router
- Implementing Server Components and streaming
- Setting up parallel and intercepting routes
- Optimizing data fetching and caching
- Building full-stack features with Server Actions
## Core Concepts
### 1. Rendering Modes
| Mode | Where | When to Use |
|------|-------|-------------|
| **Server Components** | Server only | Data fetching, heavy computation, secrets |
| **Client Components** | Browser | Interactivity, hooks, browser APIs |
| **Static** | Build time | Content that rarely changes |
| **Dynamic** | Request time | Personalized or real-time data |
| **Streaming** | Progressive | Large pages, slow data sources |
### 2. File Conventions
```
app/
├── layout.tsx # Shared UI wrapper
├── page.tsx # Route UI
├── loading.tsx # Loading UI (Suspense)
├── error.tsx # Error boundary
├── not-found.tsx # 404 UI
├── route.ts # API endpoint
├── template.tsx # Re-mounted layout
├── default.tsx # Parallel route fallback
└── opengraph-image.tsx # OG image generation
```
## Quick Start
```typescript
// app/layout.tsx
import { Inter } from 'next/font/google'
import { Providers } from './providers'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: { default: 'My App', template: '%s | My App' },
description: 'Built with Next.js App Router',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
)
}
// app/page.tsx - Server Component by default
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }, // ISR: revalidate every hour
})
return res.json()
}
export default async function HomePage() {
const products = await getProducts()
return (
<main>
<h1>Products</h1>
<ProductGrid products={products} />
</main>
)
}
```
## Patterns
### Pattern 1: Server Components with Data Fetching
```typescript
// app/products/page.tsx
import { Suspense } from 'react'
import { ProductList, ProductListSkeleton } from '@/components/products'
import { FilterSidebar } from '@/components/filters'
interface SearchParams {
category?: string
sort?: 'price' | 'name' | 'date'
page?: string
}
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const params = await searchParams
return (
<div className="flex gap-8">
<FilterSidebar />
<Suspense
key={JSON.stringify(params)}
fallback={<ProductListSkeleton />}
>
<ProductList
category={params.category}
sort={params.sort}
page={Number(params.page) || 1}
/>
</Suspense>
</div>
)
}
// components/products/ProductList.tsx - Server Component
async function getProducts(filters: ProductFilters) {
const res = await fetch(
`${process.env.API_URL}/products?${new URLSearchParams(filters)}`,
{ next: { tags: ['products'] } }
)
if (!res.ok) throw new Error('Failed to fetch products')
return res.json()
}
export async function ProductList({ category, sort, page }: ProductFilters) {
const { products, totalPages } = await getProducts({ category, sort, page })
return (
<div>
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
<Pagination currentPage={page} totalPages={totalPages} />
</div>
)
}
```
### Pattern 2: Client Components with 'use client'
```typescript
// components/products/AddToCartButton.tsx
'use client'
import { useState, useTransition } from 'react'
import { addToCart } from '@/app/actions/cart'
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const handleClick = () => {
setError(null)
startTransition(async () => {
const result = await addToCart(productId)
if (result.error) {
setError(result.error)
}
})
}
return (
<div>
<button
onClick={handleClick}
disabled={isPending}
className="btn-primary"
>
{isPending ? 'Adding...' : 'Add to Cart'}
</button>
{error && <p className="text-red-500 text-sm">{error}</p>}
</div>
)
}
```
### Pattern 3: Server Actions
```typescript
// app/actions/cart.ts
'use server'
import { revalidateTag } from 'next/cache'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function addToCart(productId: string) {
const cookieStore = await cookies()
const sessionId = cookieStore.get('session')?.value
if (!sessionId) {
redirect('/login')
}
try {
await db.cart.upsert({
where: { sessionId_productId: { sessionId, productId } },
update: { quantity: { increment: 1 } },
create: { sessionId, productId, quantity: 1 },
})
revalidateTag('cart')
return { success: true }
} catch (error) {
return { error: 'Failed to add item to cart' }
}
}
export async function checkout(formData: FormData) {
const address = formData.get('address') as string
const payment = formData.get('payment') as string
// Validate
if (!address || !payment) {
return { error: 'Missing required fields' }
}
// Process order
const order = await processOrder({ address, payment })
// Redirect to confirmation
redirect(`/orders/${order.id}/confirmation`)
}
```
### Pattern 4: Parallel Routes
```typescript
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="dashboard-grid">
<main>{children}</main>
<aside className="analytics-panel">{analytics}</aside>
<aside className="team-panel">{team}</aside>
</div>
)
}
// app/dashboard/@analytics/page.tsx
export default async function AnalyticsSlot() {
const stats = await getAnalytics()
return <AnalyticsChart data={stats} />
}
// app/dashboard/@analytics/loading.tsx
export default function AnalyticsLoading() {
return <ChartSkeleton />
}
// app/dashboard/@team/page.tsx
export default async function TeamSlot() {
const members = await getTeamMembers()
return <TeamList members={members} />
}
```
### Pattern 5: Intercepting Routes (Modal Pattern)
```typescript
// File structure for photo modal
// app/
// ├── @modal/
// │ ├── (.)photos/[id]/page.tsx # Intercept
// │ └── default.tsx
// ├── photos/
// │ └── [id]/page.tsx # Full page
// └── layout.tsx
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/Modal'
import { PhotoDetail } from '@/components/PhotoDetail'
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const photo = await getPhoto(id)
return (
<Modal>
<PhotoDetail photo={photo} />
</Modal>
)
}
// app/photos/[id]/page.tsx - Full page version
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const photo = await getPhoto(id)
return (
<div className="photo-page">
<PhotoDetail photo={photo} />
<RelatedPhotos photoId={id} />
</div>
)
}
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
)
}
```
### Pattern 6: Streaming with Suspense
```typescript
// app/product/[id]/page.tsx
import { Suspense } from 'react'
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
// This data loads first (blocking)
const product = await getProduct(id)
return (
<div>
{/* Immediate render */}
<ProductHeader product={product} />
{/* Stream in reviews */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={id} />
</Suspense>
{/* Stream in recommendations */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={id} />
</Suspense>
</div>
)
}
// These components fetch their own data
async function Reviews({ productId }: { productId: string }) {
const reviews = await getReviews(productId) // Slow API
return <ReviewList reviews={reviews} />
}
async function Recommendations({ productId }: { productId: string }) {
const products = await getRecommendations(productId) // ML-based, slow
return <ProductCarousel products={products} />
}
```
### Pattern 7: Route Handlers (API Routes)
```typescript
// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const category = searchParams.get('category')
const products = await db.product.findMany({
where: category ? { category } : undefined,
take: 20,
})
return NextResponse.json(products)
}
export async function POST(request: NextRequest) {
const body = await request.json()
const product = await db.product.create({
data: body,
})
return NextResponse.json(product, { status: 201 })
}
// app/api/products/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const product = await db.product.findUnique({ where: { id } })
if (!product) {
return NextResponse.json(
{ error: 'Product not found' },
{ status: 404 }
)
}
return NextResponse.json(product)
}
```
### Pattern 8: Metadata and SEO
```typescript
// app/products/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
type Props = {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const product = await getProduct(slug)
if (!product) return {}
return {
title: product.name,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
images: [{ url: product.image, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: product.name,
description: product.description,
images: [product.image],
},
}
}
export async function generateStaticParams() {
const products = await db.product.findMany({ select: { slug: true } })
return products.map((p) => ({ slug: p.slug }))
}
export default async function ProductPage({ params }: Props) {
const { slug } = await params
const product = await getProduct(slug)
if (!product) notFound()
return <ProductDetail product={product} />
}
```
## Caching Strategies
### Data Cache
```typescript
// No cache (always fresh)
fetch(url, { cache: 'no-store' })
// Cache forever (static)
fetch(url, { cache: 'force-cache' })
// ISR - revalidate after 60 seconds
fetch(url, { next: { revalidate: 60 } })
// Tag-based invalidation
fetch(url, { next: { tags: ['products'] } })
// Invalidate via Server Action
'use server'
import { revalidateTag, revalidatePath } from 'next/cache'
export async function updateProduct(id: string, data: ProductData) {
await db.product.update({ where: { id }, data })
revalidateTag('products')
revalidatePath('/products')
}
```
## Best Practices
### Do's
- **Start with Server Components** - Add 'use client' only when needed
- **Colocate data fetching** - Fetch data where it's used
- **Use Suspense boundaries** - Enable streaming for slow data
- **Leverage parallel routes** - Independent loading states
- **Use Server Actions** - For mutations with progressive enhancement
### Don'ts
- **Don't pass serializable data** - Server → Client boundary limitations
- **Don't use hooks in Server Components** - No useState, useEffect
- **Don't fetch in Client Components** - Use Server Components or React Query
- **Don't over-nest layouts** - Each layout adds to the component tree
- **Don't ignore loading states** - Always provide loading.tsx or Suspense
## Resources
- [Next.js App Router Documentation](https://nextjs.org/docs/app)
- [Server Components RFC](https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md)
- [Vercel Templates](https://vercel.com/templates/next.js)
@@ -0,0 +1,352 @@
---
name: progressive-web-app
description: "Build Progressive Web Apps (PWAs) with offline support, installability, and caching strategies. Trigger whenever the user mentions PWA, service workers, web app manifests, Workbox, 'add to home screen', or wants their web app to work offline, feel native, or be installable."
risk: safe
source: community
date_added: "2026-03-17"
tags: [pwa, web-dev, service-worker, frontend, offline, caching]
tools: [gemini, cursor, claude]
---
# Progressive Web Apps (PWAs)
## Overview
A Progressive Web App is a web application that uses modern browser capabilities to deliver a fast, reliable, and installable experience — even on unreliable networks. The three required pillars are:
1. **HTTPS** — Required in production for service workers to register (localhost is exempt for development).
2. **Web App Manifest** (`manifest.json`) — Makes the app installable and defines its appearance on device home screens.
3. **Service Worker** (`sw.js`) — A background script that intercepts network requests, manages caches, and enables offline functionality.
## When to Use This Skill
- Use when the user wants their web app to work offline or on unreliable networks.
- Use when building a mobile-first web project where users should be able to install the app to their home screen.
- Use when the user asks about caching strategies, service workers, or improving web app performance and resilience.
- Use when the user mentions Workbox, web app manifests, background sync, or push notifications for the web.
- Use when the user asks "can my website be installed like an app?" or "how do I make my site work offline?" — even if they don't use the word PWA.
## Deliverables Checklist
Every PWA implementation must include these files at minimum:
- [ ] `index.html` — Links manifest, registers service worker
- [ ] `manifest.json` — Full app metadata and icon set
- [ ] `sw.js` — Service worker with install, activate, and fetch handlers
- [ ] `app.js` — Main app logic with SW registration and install prompt handling
- [ ] `offline.html` — Fallback page shown when navigation fails offline (required — missing file will cause install to fail)
---
## Step 1: Web App Manifest (`manifest.json`)
Defines how the app appears when installed. Must be linked from `<head>` via `<link rel="manifest">`.
```json
{
"name": "My Awesome PWA",
"short_name": "MyPWA",
"description": "A fast, offline-capable Progressive Web App.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#0055ff",
"icons": [
{
"src": "/assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/assets/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
]
}
```
**Key fields:**
- `display`: `standalone` hides browser UI; `minimal-ui` shows minimal controls; `browser` is standard tab.
- `purpose: "maskable"` on icons enables adaptive icons on Android (safe zone matters — keep content in center 80%).
- `screenshots` is optional but required for Chrome's enhanced install dialog on desktop.
---
## Step 2: HTML Shell (`index.html`)
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Awesome PWA</title>
<!-- PWA manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Theme color for browser chrome -->
<meta name="theme-color" content="#0055ff">
<!-- iOS-specific (Safari doesn't fully use manifest) -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="MyPWA">
<link rel="apple-touch-icon" href="/assets/icons/icon-192x192.png">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div id="app">
<header><h1>My PWA</h1></header>
<main id="content">Loading...</main>
<!-- Optional: install button, hidden by default -->
<button id="install-btn" hidden>Install App</button>
</div>
<script src="/app.js"></script>
</body>
</html>
```
---
## Step 3: Service Worker Registration & Install Prompt (`app.js`)
```javascript
// ─── Service Worker Registration ───────────────────────────────────────────
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('[App] SW registered, scope:', registration.scope);
} catch (err) {
console.error('[App] SW registration failed:', err);
}
});
}
// ─── Install Prompt (Add to Home Screen) ───────────────────────────────────
let deferredPrompt;
const installBtn = document.getElementById('install-btn'); // may be null if omitted
// Capture the browser's install prompt — it fires before the browser's own UI
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // Stop automatic mini-infobar on mobile
deferredPrompt = e;
if (installBtn) installBtn.hidden = false; // Show your custom install button
});
if (installBtn) {
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log('[App] Install outcome:', outcome);
deferredPrompt = null;
installBtn.hidden = true;
});
}
// Fires when the app is installed (via browser or your button)
window.addEventListener('appinstalled', () => {
console.log('[App] PWA installed successfully');
installBtn.hidden = true;
});
```
---
## Step 4: Service Worker (`sw.js`)
### Cache Versioning (critical — always increment on deploy)
```javascript
const CACHE_VERSION = 'v1';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
// Files to pre-cache during install (the "App Shell")
const APP_SHELL = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/assets/icons/icon-192x192.png',
'/offline.html', // Fallback page shown when network is unavailable
];
```
### Install — Pre-cache the App Shell
```javascript
self.addEventListener('install', (event) => {
console.log('[SW] Installing...');
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
console.log('[SW] Pre-caching app shell');
return cache.addAll(APP_SHELL);
})
);
// Activate immediately without waiting for old SW to die
self.skipWaiting();
});
```
### Activate — Clean Up Old Caches
```javascript
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
);
// Take control of all pages immediately
self.clients.claim();
});
```
### Fetch — Caching Strategies
Choose the right strategy per resource type:
```javascript
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Only handle GET requests from our own origin
if (request.method !== 'GET' || url.origin !== location.origin) return;
// Strategy A: Cache-First (for static assets — fast, tolerates stale)
if (url.pathname.match(/\.(css|js|png|jpg|svg|woff2)$/)) {
event.respondWith(cacheFirst(request));
return;
}
// Strategy B: Network-First (for HTML pages — fresh, falls back to cache)
if (request.headers.get('Accept')?.includes('text/html')) {
event.respondWith(networkFirst(request));
return;
}
// Strategy C: Stale-While-Revalidate (for API data — fast and eventually fresh)
if (url.pathname.startsWith('/api/')) {
event.respondWith(staleWhileRevalidate(request));
return;
}
});
// ─── Strategy Implementations ──────────────────────────────────────────────
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
const cache = await caches.open(STATIC_CACHE);
cache.put(request, response.clone());
return response;
} catch {
// Nothing useful to fall back to for assets
return new Response('Asset unavailable offline', { status: 503 });
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(DYNAMIC_CACHE);
cache.put(request, response.clone());
return response;
} catch {
const cached = await caches.match(request);
return cached || caches.match('/offline.html');
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(DYNAMIC_CACHE);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
}
```
---
## Edge Cases & Platform Notes
### iOS / Safari Quirks
- Safari supports manifests and service workers but **does not support `beforeinstallprompt`** — users must install via the Share → "Add to Home Screen" menu manually.
- Use the `apple-mobile-web-app-*` meta tags (shown in `index.html` above) for proper iOS integration.
- Safari may clear service worker caches after ~7 days of inactivity (Intelligent Tracking Prevention).
### HTTPS Requirement
- Service workers only register on `https://` origins. `http://localhost` is the only exception for development.
- Use a tool like `mkcert` or `ngrok` if you need HTTPS locally with a custom hostname.
### Cache-Busting on Deploy
- Always increment `CACHE_VERSION` in `sw.js` when deploying new assets. This ensures activate clears old caches and users get fresh files.
- A common pattern is to inject the version automatically via your build tool (e.g., Vite, Webpack).
### Opaque Responses (cross-origin requests)
- Requests to external origins (e.g., CDN fonts, third-party APIs) return "opaque" responses that cannot be inspected. Cache them with caution — a failed opaque response still gets a `200` status.
- Prefer `staleWhileRevalidate` for cross-origin resources, or use a library like Workbox which handles this safely.
---
## Workbox (Optional: Production Shortcut)
For production apps, consider [Workbox](https://developer.chrome.com/docs/workbox) (Google's PWA library) instead of hand-rolling strategies. It handles edge cases, cache expiry, and versioning automatically.
```javascript
// With Workbox (via CDN for simplicity — use npm + bundler in production)
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
const { registerRoute } = workbox.routing;
const { CacheFirst, NetworkFirst, StaleWhileRevalidate } = workbox.strategies;
const { precacheAndRoute } = workbox.precaching;
precacheAndRoute(self.__WB_MANIFEST || []); // Injected by build plugin
registerRoute(({ request }) => request.destination === 'image', new CacheFirst());
registerRoute(({ request }) => request.mode === 'navigate', new NetworkFirst());
registerRoute(({ request }) => request.destination === 'script', new StaleWhileRevalidate());
```
---
## Checklist Before Shipping
- [ ] Site is served over HTTPS
- [ ] `manifest.json` has `name`, `short_name`, `start_url`, `display`, `icons` (192 + 512)
- [ ] Icons have `purpose: "any maskable"`
- [ ] `sw.js` registers without errors in DevTools → Application → Service Workers
- [ ] App shell loads from cache when network is throttled to "Offline" in DevTools
- [ ] `offline.html` fallback is cached and served when navigation fails offline
- [ ] Lighthouse PWA audit passes (Chrome DevTools → Lighthouse tab)
- [ ] Tested on iOS Safari (manual install flow) and Android Chrome (install prompt)
@@ -0,0 +1,209 @@
---
name: react-best-practices
description: Comprehensive React and Next.js performance optimization guide with 40+ rules for eliminating waterfalls, optimizing bundles, and improving rendering. Use when optimizing React apps, reviewing performance, or refactoring components.
version: 1.0.0
author: Vercel Engineering
license: MIT
tags: [React, Next.js, Performance, Optimization, Best Practices, Bundle Size, Rendering, Server Components]
dependencies: []
---
# React Best Practices - Performance Optimization
Comprehensive performance optimization guide for React and Next.js applications with 40+ rules organized by impact level. Designed to help developers eliminate performance bottlenecks and follow best practices.
## When to use this skill
**Use React Best Practices when:**
- Optimizing React or Next.js application performance
- Reviewing code for performance improvements
- Refactoring existing components for better performance
- Implementing new features with performance in mind
- Debugging slow rendering or loading issues
- Reducing bundle size
- Eliminating request waterfalls
**Key areas covered:**
- **Eliminating Waterfalls** (CRITICAL): Prevent sequential async operations
- **Bundle Size Optimization** (CRITICAL): Reduce initial JavaScript payload
- **Server-Side Performance** (HIGH): Optimize RSC and data fetching
- **Client-Side Data Fetching** (MEDIUM-HIGH): Implement efficient caching
- **Re-render Optimization** (MEDIUM): Minimize unnecessary re-renders
- **Rendering Performance** (MEDIUM): Optimize browser rendering
- **JavaScript Performance** (LOW-MEDIUM): Micro-optimizations for hot paths
- **Advanced Patterns** (LOW): Specialized techniques for edge cases
## Quick reference
### Critical priorities
1. **Defer await until needed** - Move awaits into branches where they're used
2. **Use Promise.all()** - Parallelize independent async operations
3. **Avoid barrel imports** - Import directly from source files
4. **Dynamic imports** - Lazy-load heavy components
5. **Strategic Suspense** - Stream content while showing layout
### Common patterns
**Parallel data fetching:**
```typescript
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
])
```
**Direct imports:**
```tsx
// ❌ Loads entire library
import { Check } from 'lucide-react'
// ✅ Loads only what you need
import Check from 'lucide-react/dist/esm/icons/check'
```
**Dynamic components:**
```tsx
import dynamic from 'next/dynamic'
const MonacoEditor = dynamic(
() => import('./monaco-editor'),
{ ssr: false }
)
```
## Using the guidelines
The complete performance guidelines are available in the references folder:
- **react-performance-guidelines.md**: Complete guide with all 40+ rules, code examples, and impact analysis
Each rule includes:
- Incorrect/correct code comparisons
- Specific impact metrics
- When to apply the optimization
- Real-world examples
## Categories overview
### 1. Eliminating Waterfalls (CRITICAL)
Waterfalls are the #1 performance killer. Each sequential await adds full network latency.
- Defer await until needed
- Dependency-based parallelization
- Prevent waterfall chains in API routes
- Promise.all() for independent operations
- Strategic Suspense boundaries
### 2. Bundle Size Optimization (CRITICAL)
Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
- Avoid barrel file imports
- Conditional module loading
- Defer non-critical third-party libraries
- Dynamic imports for heavy components
- Preload based on user intent
### 3. Server-Side Performance (HIGH)
Optimize server-side rendering and data fetching.
- Cross-request LRU caching
- Minimize serialization at RSC boundaries
- Parallel data fetching with component composition
- Per-request deduplication with React.cache()
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
Automatic deduplication and efficient data fetching patterns.
- Deduplicate global event listeners
- Use SWR for automatic deduplication
### 5. Re-render Optimization (MEDIUM)
Reduce unnecessary re-renders to minimize wasted computation.
- Defer state reads to usage point
- Extract to memoized components
- Narrow effect dependencies
- Subscribe to derived state
- Use lazy state initialization
- Use transitions for non-urgent updates
### 6. Rendering Performance (MEDIUM)
Optimize the browser rendering process.
- Animate SVG wrapper instead of SVG element
- CSS content-visibility for long lists
- Hoist static JSX elements
- Optimize SVG precision
- Prevent hydration mismatch without flickering
- Use Activity component for show/hide
- Use explicit conditional rendering
### 7. JavaScript Performance (LOW-MEDIUM)
Micro-optimizations for hot paths.
- Batch DOM CSS changes
- Build index maps for repeated lookups
- Cache property access in loops
- Cache repeated function calls
- Cache storage API calls
- Combine multiple array iterations
- Early length check for array comparisons
- Early return from functions
- Hoist RegExp creation
- Use loop for min/max instead of sort
- Use Set/Map for O(1) lookups
- Use toSorted() instead of sort()
### 8. Advanced Patterns (LOW)
Specialized techniques for edge cases.
- Store event handlers in refs
- useLatest for stable callback refs
## Implementation approach
When optimizing a React application:
1. **Profile first**: Use React DevTools Profiler and browser performance tools to identify bottlenecks
2. **Focus on critical paths**: Start with eliminating waterfalls and reducing bundle size
3. **Measure impact**: Verify improvements with metrics (LCP, TTI, FID)
4. **Apply incrementally**: Don't over-optimize prematurely
5. **Test thoroughly**: Ensure optimizations don't break functionality
## Key metrics to track
- **Time to Interactive (TTI)**: When page becomes fully interactive
- **Largest Contentful Paint (LCP)**: When main content is visible
- **First Input Delay (FID)**: Responsiveness to user interactions
- **Cumulative Layout Shift (CLS)**: Visual stability
- **Bundle size**: Initial JavaScript payload
- **Server response time**: TTFB for server-rendered content
## Common pitfalls to avoid
**Don't:**
- Use barrel imports from large libraries
- Block parallel operations with sequential awaits
- Re-render entire trees when only part needs updating
- Load analytics/tracking in the critical path
- Mutate arrays with .sort() instead of .toSorted()
- Create RegExp or heavy objects inside render
**Do:**
- Import directly from source files
- Use Promise.all() for independent operations
- Memoize expensive components
- Lazy-load non-critical code
- Use immutable array methods
- Hoist static objects outside components
## Resources
- [React Documentation](https://react.dev)
- [Next.js Documentation](https://nextjs.org)
- [SWR Documentation](https://swr.vercel.app)
- [Vercel Bundle Optimization](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
- [Vercel Dashboard Performance](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)
- [better-all Library](https://github.com/shuding/better-all)
- [node-lru-cache](https://github.com/isaacs/node-lru-cache)
## Version history
**v0.1.0** (January 2026)
- Initial release from Vercel Engineering
- 40+ performance rules across 8 categories
- Comprehensive code examples and impact analysis
@@ -0,0 +1,46 @@
# Sections
This file defines all sections, their ordering, impact levels, and descriptions.
The section ID (in parentheses) is the filename prefix used to group rules.
---
## 1. Eliminating Waterfalls (async)
**Impact:** CRITICAL
**Description:** Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains.
## 2. Bundle Size Optimization (bundle)
**Impact:** CRITICAL
**Description:** Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
## 3. Server-Side Performance (server)
**Impact:** HIGH
**Description:** Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times.
## 4. Client-Side Data Fetching (client)
**Impact:** MEDIUM-HIGH
**Description:** Automatic deduplication and efficient data fetching patterns reduce redundant network requests.
## 5. Re-render Optimization (rerender)
**Impact:** MEDIUM
**Description:** Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness.
## 6. Rendering Performance (rendering)
**Impact:** MEDIUM
**Description:** Optimizing the rendering process reduces the work the browser needs to do.
## 7. JavaScript Performance (js)
**Impact:** LOW-MEDIUM
**Description:** Micro-optimizations for hot paths can add up to meaningful improvements.
## 8. Advanced Patterns (advanced)
**Impact:** LOW
**Description:** Advanced patterns for specific cases that require careful implementation.
@@ -0,0 +1,28 @@
---
title: Rule Title Here
impact: MEDIUM
impactDescription: Optional description of impact (e.g., "20-50% improvement")
tags: tag1, tag2
---
## Rule Title Here
**Impact: MEDIUM (optional impact description)**
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
**Incorrect (description of what's wrong):**
```typescript
// Bad code example here
const bad = example()
```
**Correct (description of what's right):**
```typescript
// Good code example here
const good = example()
```
Reference: [Link to documentation or resource](https://example.com)
@@ -0,0 +1,38 @@
---
title: Store Event Handlers in Refs
impact: LOW
impactDescription: stable subscriptions
tags: advanced, hooks, refs, event-handlers, optimization
---
## Store Event Handlers in Refs
Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
**Incorrect (re-subscribes on every render):**
```tsx
function useWindowEvent(event: string, handler: () => void) {
useEffect(() => {
window.addEventListener(event, handler)
return () => window.removeEventListener(event, handler)
}, [event, handler])
}
```
**Correct (stable subscription):**
```tsx
function useWindowEvent(event: string, handler: () => void) {
const handlerRef = useRef(handler)
useEffect(() => {
handlerRef.current = handler
}, [handler])
useEffect(() => {
const listener = () => handlerRef.current()
window.addEventListener(event, listener)
return () => window.removeEventListener(event, listener)
}, [event])
}
```
@@ -0,0 +1,49 @@
---
title: useLatest for Stable Callback Refs
impact: LOW
impactDescription: prevents effect re-runs
tags: advanced, hooks, useLatest, refs, optimization
---
## useLatest for Stable Callback Refs
Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
**Implementation:**
```typescript
function useLatest<T>(value: T) {
const ref = useRef(value)
useEffect(() => {
ref.current = value
}, [value])
return ref
}
```
**Incorrect (effect re-runs on every callback change):**
```tsx
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
useEffect(() => {
const timeout = setTimeout(() => onSearch(query), 300)
return () => clearTimeout(timeout)
}, [query, onSearch])
}
```
**Correct (stable effect, fresh callback):**
```tsx
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
const onSearchRef = useLatest(onSearch)
useEffect(() => {
const timeout = setTimeout(() => onSearchRef.current(query), 300)
return () => clearTimeout(timeout)
}, [query])
}
```
@@ -0,0 +1,38 @@
---
title: Prevent Waterfall Chains in API Routes
impact: CRITICAL
impactDescription: 2-10× improvement
tags: api-routes, server-actions, waterfalls, parallelization
---
## Prevent Waterfall Chains in API Routes
In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
**Incorrect (config waits for auth, data waits for both):**
```typescript
export async function GET(request: Request) {
const session = await auth()
const config = await fetchConfig()
const data = await fetchData(session.user.id)
return Response.json({ data, config })
}
```
**Correct (auth and config start immediately):**
```typescript
export async function GET(request: Request) {
const sessionPromise = auth()
const configPromise = fetchConfig()
const session = await sessionPromise
const [config, data] = await Promise.all([
configPromise,
fetchData(session.user.id)
])
return Response.json({ data, config })
}
```
For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).
@@ -0,0 +1,80 @@
---
title: Defer Await Until Needed
impact: HIGH
impactDescription: avoids blocking unused code paths
tags: async, await, conditional, optimization
---
## Defer Await Until Needed
Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.
**Incorrect (blocks both branches):**
```typescript
async function handleRequest(userId: string, skipProcessing: boolean) {
const userData = await fetchUserData(userId)
if (skipProcessing) {
// Returns immediately but still waited for userData
return { skipped: true }
}
// Only this branch uses userData
return processUserData(userData)
}
```
**Correct (only blocks when needed):**
```typescript
async function handleRequest(userId: string, skipProcessing: boolean) {
if (skipProcessing) {
// Returns immediately without waiting
return { skipped: true }
}
// Fetch only when needed
const userData = await fetchUserData(userId)
return processUserData(userData)
}
```
**Another example (early return optimization):**
```typescript
// Incorrect: always fetches permissions
async function updateResource(resourceId: string, userId: string) {
const permissions = await fetchPermissions(userId)
const resource = await getResource(resourceId)
if (!resource) {
return { error: 'Not found' }
}
if (!permissions.canEdit) {
return { error: 'Forbidden' }
}
return await updateResourceData(resource, permissions)
}
// Correct: fetches only when needed
async function updateResource(resourceId: string, userId: string) {
const resource = await getResource(resourceId)
if (!resource) {
return { error: 'Not found' }
}
const permissions = await fetchPermissions(userId)
if (!permissions.canEdit) {
return { error: 'Forbidden' }
}
return await updateResourceData(resource, permissions)
}
```
This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.
@@ -0,0 +1,36 @@
---
title: Dependency-Based Parallelization
impact: CRITICAL
impactDescription: 2-10× improvement
tags: async, parallelization, dependencies, better-all
---
## Dependency-Based Parallelization
For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.
**Incorrect (profile waits for config unnecessarily):**
```typescript
const [user, config] = await Promise.all([
fetchUser(),
fetchConfig()
])
const profile = await fetchProfile(user.id)
```
**Correct (config and profile run in parallel):**
```typescript
import { all } from 'better-all'
const { user, config, profile } = await all({
async user() { return fetchUser() },
async config() { return fetchConfig() },
async profile() {
return fetchProfile((await this.$.user).id)
}
})
```
Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
@@ -0,0 +1,28 @@
---
title: Promise.all() for Independent Operations
impact: CRITICAL
impactDescription: 2-10× improvement
tags: async, parallelization, promises, waterfalls
---
## Promise.all() for Independent Operations
When async operations have no interdependencies, execute them concurrently using `Promise.all()`.
**Incorrect (sequential execution, 3 round trips):**
```typescript
const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()
```
**Correct (parallel execution, 1 round trip):**
```typescript
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
])
```
@@ -0,0 +1,66 @@
---
title: Strategic Suspense Boundaries
impact: HIGH
impactDescription: faster initial paint
tags: async, suspense, streaming, layout-shift
---
## Strategic Suspense Boundaries
Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.
**Incorrect (wrapper blocked by data fetching):**
```tsx
async function Page() {
const data = await fetchData() // Blocks entire page
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<div>
<DataDisplay data={data} />
</div>
<div>Footer</div>
</div>
)
}
```
The entire layout waits for data even though only the middle section needs it.
**Correct (wrapper shows immediately, data streams in):**
```tsx
function Page() {
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<div>
<Suspense fallback={<Skeleton />}>
<DataDisplay />
</Suspense>
</div>
<div>Footer</div>
</div>
)
}
async function DataDisplay() {
const data = await fetchData() // Only blocks this component
return <div>{data.content}</div>
}
```
Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
**When NOT to use this pattern:**
- Critical data needed for layout decisions (affects positioning)
- SEO-critical content above the fold
- Small, fast queries where suspense overhead isn't worth it
- When you want to avoid layout shift (loading → content jump)
**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities.
@@ -0,0 +1,59 @@
---
title: Avoid Barrel File Imports
impact: CRITICAL
impactDescription: 200-800ms import cost, slow builds
tags: bundle, imports, tree-shaking, barrel-files, performance
---
## Avoid Barrel File Imports
Import directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`).
Popular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts.
**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph.
**Incorrect (imports entire library):**
```tsx
import { Check, X, Menu } from 'lucide-react'
// Loads 1,583 modules, takes ~2.8s extra in dev
// Runtime cost: 200-800ms on every cold start
import { Button, TextField } from '@mui/material'
// Loads 2,225 modules, takes ~4.2s extra in dev
```
**Correct (imports only what you need):**
```tsx
import Check from 'lucide-react/dist/esm/icons/check'
import X from 'lucide-react/dist/esm/icons/x'
import Menu from 'lucide-react/dist/esm/icons/menu'
// Loads only 3 modules (~2KB vs ~1MB)
import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
// Loads only what you use
```
**Alternative (Next.js 13.5+):**
```js
// next.config.js - use optimizePackageImports
module.exports = {
experimental: {
optimizePackageImports: ['lucide-react', '@mui/material']
}
}
// Then you can keep the ergonomic barrel imports:
import { Check, X, Menu } from 'lucide-react'
// Automatically transformed to direct imports at build time
```
Direct imports provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR.
Libraries commonly affected: `lucide-react`, `@mui/material`, `@mui/icons-material`, `@tabler/icons-react`, `react-icons`, `@headlessui/react`, `@radix-ui/react-*`, `lodash`, `ramda`, `date-fns`, `rxjs`, `react-use`.
Reference: [How we optimized package imports in Next.js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
@@ -0,0 +1,31 @@
---
title: Conditional Module Loading
impact: CRITICAL
impactDescription: loads large data only when needed
tags: bundle, conditional-loading, lazy-loading
---
## Conditional Module Loading
Load large data or modules only when a feature is activated.
**Example (lazy-load animation frames):**
```tsx
function AnimationPlayer({ enabled }: { enabled: boolean }) {
const [frames, setFrames] = useState<Frame[] | null>(null)
useEffect(() => {
if (enabled && !frames && typeof window !== 'undefined') {
import('./animation-frames.js')
.then(mod => setFrames(mod.frames))
.catch(() => setEnabled(false))
}
}, [enabled, frames])
if (!frames) return <Skeleton />
return <Canvas frames={frames} />
}
```
The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed.
@@ -0,0 +1,49 @@
---
title: Defer Non-Critical Third-Party Libraries
impact: CRITICAL
impactDescription: loads after hydration
tags: bundle, third-party, analytics, defer
---
## Defer Non-Critical Third-Party Libraries
Analytics, logging, and error tracking don't block user interaction. Load them after hydration.
**Incorrect (blocks initial bundle):**
```tsx
import { Analytics } from '@vercel/analytics/react'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
)
}
```
**Correct (loads after hydration):**
```tsx
import dynamic from 'next/dynamic'
const Analytics = dynamic(
() => import('@vercel/analytics/react').then(m => m.Analytics),
{ ssr: false }
)
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
)
}
```
@@ -0,0 +1,35 @@
---
title: Dynamic Imports for Heavy Components
impact: CRITICAL
impactDescription: directly affects TTI and LCP
tags: bundle, dynamic-import, code-splitting, next-dynamic
---
## Dynamic Imports for Heavy Components
Use `next/dynamic` to lazy-load large components not needed on initial render.
**Incorrect (Monaco bundles with main chunk ~300KB):**
```tsx
import { MonacoEditor } from './monaco-editor'
function CodePanel({ code }: { code: string }) {
return <MonacoEditor value={code} />
}
```
**Correct (Monaco loads on demand):**
```tsx
import dynamic from 'next/dynamic'
const MonacoEditor = dynamic(
() => import('./monaco-editor').then(m => m.MonacoEditor),
{ ssr: false }
)
function CodePanel({ code }: { code: string }) {
return <MonacoEditor value={code} />
}
```
@@ -0,0 +1,50 @@
---
title: Preload Based on User Intent
impact: CRITICAL
impactDescription: reduces perceived latency
tags: bundle, preload, user-intent, hover
---
## Preload Based on User Intent
Preload heavy bundles before they're needed to reduce perceived latency.
**Example (preload on hover/focus):**
```tsx
function EditorButton({ onClick }: { onClick: () => void }) {
const preload = () => {
if (typeof window !== 'undefined') {
void import('./monaco-editor')
}
}
return (
<button
onMouseEnter={preload}
onFocus={preload}
onClick={onClick}
>
Open Editor
</button>
)
}
```
**Example (preload when feature flag is enabled):**
```tsx
function FlagsProvider({ children, flags }: Props) {
useEffect(() => {
if (flags.editorEnabled && typeof window !== 'undefined') {
void import('./monaco-editor').then(mod => mod.init())
}
}, [flags.editorEnabled])
return <FlagsContext.Provider value={flags}>
{children}
</FlagsContext.Provider>
}
```
The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed.
@@ -0,0 +1,74 @@
---
title: Deduplicate Global Event Listeners
impact: MEDIUM-HIGH
impactDescription: single listener for N components
tags: client, swr, event-listeners, subscription
---
## Deduplicate Global Event Listeners
Use `useSWRSubscription()` to share global event listeners across component instances.
**Incorrect (N instances = N listeners):**
```tsx
function useKeyboardShortcut(key: string, callback: () => void) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && e.key === key) {
callback()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [key, callback])
}
```
When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener.
**Correct (N instances = 1 listener):**
```tsx
import useSWRSubscription from 'swr/subscription'
// Module-level Map to track callbacks per key
const keyCallbacks = new Map<string, Set<() => void>>()
function useKeyboardShortcut(key: string, callback: () => void) {
// Register this callback in the Map
useEffect(() => {
if (!keyCallbacks.has(key)) {
keyCallbacks.set(key, new Set())
}
keyCallbacks.get(key)!.add(callback)
return () => {
const set = keyCallbacks.get(key)
if (set) {
set.delete(callback)
if (set.size === 0) {
keyCallbacks.delete(key)
}
}
}
}, [key, callback])
useSWRSubscription('global-keydown', () => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && keyCallbacks.has(e.key)) {
keyCallbacks.get(e.key)!.forEach(cb => cb())
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}
}
function Profile() {
// Multiple shortcuts will share the same listener
useKeyboardShortcut('p', () => { /* ... */ })
useKeyboardShortcut('k', () => { /* ... */ })
// ...
}
```
@@ -0,0 +1,56 @@
---
title: Use SWR for Automatic Deduplication
impact: MEDIUM-HIGH
impactDescription: automatic deduplication
tags: client, swr, deduplication, data-fetching
---
## Use SWR for Automatic Deduplication
SWR enables request deduplication, caching, and revalidation across component instances.
**Incorrect (no deduplication, each instance fetches):**
```tsx
function UserList() {
const [users, setUsers] = useState([])
useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(setUsers)
}, [])
}
```
**Correct (multiple instances share one request):**
```tsx
import useSWR from 'swr'
function UserList() {
const { data: users } = useSWR('/api/users', fetcher)
}
```
**For immutable data:**
```tsx
import { useImmutableSWR } from '@/lib/swr'
function StaticContent() {
const { data } = useImmutableSWR('/api/config', fetcher)
}
```
**For mutations:**
```tsx
import { useSWRMutation } from 'swr/mutation'
function UpdateButton() {
const { trigger } = useSWRMutation('/api/user', updateUser)
return <button onClick={() => trigger()}>Update</button>
}
```
Reference: [https://swr.vercel.app](https://swr.vercel.app)
@@ -0,0 +1,63 @@
#!/bin/bash
BASE_URL="https://raw.githubusercontent.com/vercel-labs/agent-skills/react-best-practices/skills/react-best-practices/references/rules"
# Async rules
curl -s "$BASE_URL/async-api-routes.md" > async-api-routes.md
curl -s "$BASE_URL/async-defer-await.md" > async-defer-await.md
curl -s "$BASE_URL/async-dependencies.md" > async-dependencies.md
curl -s "$BASE_URL/async-parallel.md" > async-parallel.md
curl -s "$BASE_URL/async-suspense-boundaries.md" > async-suspense-boundaries.md
# Bundle rules
curl -s "$BASE_URL/bundle-barrel-imports.md" > bundle-barrel-imports.md
curl -s "$BASE_URL/bundle-conditional.md" > bundle-conditional.md
curl -s "$BASE_URL/bundle-defer-third-party.md" > bundle-defer-third-party.md
curl -s "$BASE_URL/bundle-dynamic-imports.md" > bundle-dynamic-imports.md
curl -s "$BASE_URL/bundle-preload.md" > bundle-preload.md
# Client rules
curl -s "$BASE_URL/client-event-listeners.md" > client-event-listeners.md
curl -s "$BASE_URL/client-swr-dedup.md" > client-swr-dedup.md
# JavaScript rules
curl -s "$BASE_URL/js-batch-dom-css.md" > js-batch-dom-css.md
curl -s "$BASE_URL/js-cache-function-results.md" > js-cache-function-results.md
curl -s "$BASE_URL/js-cache-property-access.md" > js-cache-property-access.md
curl -s "$BASE_URL/js-cache-storage.md" > js-cache-storage.md
curl -s "$BASE_URL/js-combine-iterations.md" > js-combine-iterations.md
curl -s "$BASE_URL/js-early-exit.md" > js-early-exit.md
curl -s "$BASE_URL/js-hoist-regexp.md" > js-hoist-regexp.md
curl -s "$BASE_URL/js-index-maps.md" > js-index-maps.md
curl -s "$BASE_URL/js-length-check-first.md" > js-length-check-first.md
curl -s "$BASE_URL/js-min-max-loop.md" > js-min-max-loop.md
curl -s "$BASE_URL/js-set-map-lookups.md" > js-set-map-lookups.md
curl -s "$BASE_URL/js-tosorted-immutable.md" > js-tosorted-immutable.md
# Rendering rules
curl -s "$BASE_URL/rendering-activity.md" > rendering-activity.md
curl -s "$BASE_URL/rendering-animate-svg-wrapper.md" > rendering-animate-svg-wrapper.md
curl -s "$BASE_URL/rendering-conditional-render.md" > rendering-conditional-render.md
curl -s "$BASE_URL/rendering-content-visibility.md" > rendering-content-visibility.md
curl -s "$BASE_URL/rendering-hoist-jsx.md" > rendering-hoist-jsx.md
curl -s "$BASE_URL/rendering-hydration-no-flicker.md" > rendering-hydration-no-flicker.md
curl -s "$BASE_URL/rendering-svg-precision.md" > rendering-svg-precision.md
# Re-render rules
curl -s "$BASE_URL/rerender-defer-reads.md" > rerender-defer-reads.md
curl -s "$BASE_URL/rerender-dependencies.md" > rerender-dependencies.md
curl -s "$BASE_URL/rerender-derived-state.md" > rerender-derived-state.md
curl -s "$BASE_URL/rerender-lazy-state-init.md" > rerender-lazy-state-init.md
curl -s "$BASE_URL/rerender-memo.md" > rerender-memo.md
curl -s "$BASE_URL/rerender-transitions.md" > rerender-transitions.md
# Server rules
curl -s "$BASE_URL/server-cache-lru.md" > server-cache-lru.md
curl -s "$BASE_URL/server-cache-react.md" > server-cache-react.md
curl -s "$BASE_URL/server-parallel-fetching.md" > server-parallel-fetching.md
curl -s "$BASE_URL/server-serialization.md" > server-serialization.md
# Advanced rules
curl -s "$BASE_URL/advanced-event-handler-refs.md" > advanced-event-handler-refs.md
curl -s "$BASE_URL/advanced-use-latest.md" > advanced-use-latest.md
echo "Downloaded all rule files successfully!"
@@ -0,0 +1,82 @@
---
title: Batch DOM CSS Changes
impact: MEDIUM
impactDescription: reduces reflows/repaints
tags: javascript, dom, css, performance, reflow
---
## Batch DOM CSS Changes
Avoid changing styles one property at a time. Group multiple CSS changes together via classes or `cssText` to minimize browser reflows.
**Incorrect (multiple reflows):**
```typescript
function updateElementStyles(element: HTMLElement) {
// Each line triggers a reflow
element.style.width = '100px'
element.style.height = '200px'
element.style.backgroundColor = 'blue'
element.style.border = '1px solid black'
}
```
**Correct (add class - single reflow):**
```typescript
// CSS file
.highlighted-box {
width: 100px;
height: 200px;
background-color: blue;
border: 1px solid black;
}
// JavaScript
function updateElementStyles(element: HTMLElement) {
element.classList.add('highlighted-box')
}
```
**Correct (change cssText - single reflow):**
```typescript
function updateElementStyles(element: HTMLElement) {
element.style.cssText = `
width: 100px;
height: 200px;
background-color: blue;
border: 1px solid black;
`
}
```
**React example:**
```tsx
// Incorrect: changing styles one by one
function Box({ isHighlighted }: { isHighlighted: boolean }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current && isHighlighted) {
ref.current.style.width = '100px'
ref.current.style.height = '200px'
ref.current.style.backgroundColor = 'blue'
}
}, [isHighlighted])
return <div ref={ref}>Content</div>
}
// Correct: toggle class
function Box({ isHighlighted }: { isHighlighted: boolean }) {
return (
<div className={isHighlighted ? 'highlighted-box' : ''}>
Content
</div>
)
}
```
Prefer CSS classes over inline styles when possible. Classes are cached by the browser and provide better separation of concerns.
@@ -0,0 +1,80 @@
---
title: Cache Repeated Function Calls
impact: MEDIUM-HIGH
impactDescription: avoid redundant computation
tags: javascript, cache, memoization, performance
---
## Cache Repeated Function Calls
Use a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render.
**Incorrect (redundant computation):**
```typescript
function ProjectList({ projects }: { projects: Project[] }) {
return (
<div>
{projects.map(project => {
// slugify() called 100+ times for same project names
const slug = slugify(project.name)
return <ProjectCard key={project.id} slug={slug} />
})}
</div>
)
}
```
**Correct (cached results):**
```typescript
// Module-level cache
const slugifyCache = new Map<string, string>()
function cachedSlugify(text: string): string {
if (slugifyCache.has(text)) {
return slugifyCache.get(text)!
}
const result = slugify(text)
slugifyCache.set(text, result)
return result
}
function ProjectList({ projects }: { projects: Project[] }) {
return (
<div>
{projects.map(project => {
// Computed only once per unique project name
const slug = cachedSlugify(project.name)
return <ProjectCard key={project.id} slug={slug} />
})}
</div>
)
}
```
**Simpler pattern for single-value functions:**
```typescript
let isLoggedInCache: boolean | null = null
function isLoggedIn(): boolean {
if (isLoggedInCache !== null) {
return isLoggedInCache
}
isLoggedInCache = document.cookie.includes('auth=')
return isLoggedInCache
}
// Clear cache when auth changes
function onAuthChange() {
isLoggedInCache = null
}
```
Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
Reference: [How we made the Vercel Dashboard twice as fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)
@@ -0,0 +1,28 @@
---
title: Cache Property Access in Loops
impact: LOW-MEDIUM
impactDescription: reduces lookups
tags: javascript, loops, optimization, caching
---
## Cache Property Access in Loops
Cache object property lookups in hot paths.
**Incorrect (3 lookups × N iterations):**
```typescript
for (let i = 0; i < arr.length; i++) {
process(obj.config.settings.value)
}
```
**Correct (1 lookup total):**
```typescript
const value = obj.config.settings.value
const len = arr.length
for (let i = 0; i < len; i++) {
process(value)
}
```
@@ -0,0 +1,68 @@
---
title: Cache Storage API Calls
impact: LOW-MEDIUM
impactDescription: reduces expensive I/O
tags: javascript, localStorage, storage, caching, performance
---
## Cache Storage API Calls
`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory.
**Incorrect (reads storage on every call):**
```typescript
function getTheme() {
return localStorage.getItem('theme') ?? 'light'
}
// Called 10 times = 10 storage reads
```
**Correct (Map cache):**
```typescript
const storageCache = new Map<string, string | null>()
function getLocalStorage(key: string) {
if (!storageCache.has(key)) {
storageCache.set(key, localStorage.getItem(key))
}
return storageCache.get(key)
}
function setLocalStorage(key: string, value: string) {
localStorage.setItem(key, value)
storageCache.set(key, value) // keep cache in sync
}
```
Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
**Cookie caching:**
```typescript
let cookieCache: Record<string, string> | null = null
function getCookie(name: string) {
if (!cookieCache) {
cookieCache = Object.fromEntries(
document.cookie.split('; ').map(c => c.split('='))
)
}
return cookieCache[name]
}
```
**Important:** If storage can change externally (another tab, server-set cookies), invalidate cache:
```typescript
window.addEventListener('storage', (e) => {
if (e.key) storageCache.delete(e.key)
})
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
storageCache.clear()
}
})
```
@@ -0,0 +1,32 @@
---
title: Combine Multiple Array Iterations
impact: LOW-MEDIUM
impactDescription: reduces iterations
tags: javascript, arrays, loops, performance
---
## Combine Multiple Array Iterations
Multiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop.
**Incorrect (3 iterations):**
```typescript
const admins = users.filter(u => u.isAdmin)
const testers = users.filter(u => u.isTester)
const inactive = users.filter(u => !u.isActive)
```
**Correct (1 iteration):**
```typescript
const admins: User[] = []
const testers: User[] = []
const inactive: User[] = []
for (const user of users) {
if (user.isAdmin) admins.push(user)
if (user.isTester) testers.push(user)
if (!user.isActive) inactive.push(user)
}
```
@@ -0,0 +1,50 @@
---
title: Early Return from Functions
impact: LOW-MEDIUM
impactDescription: avoids unnecessary computation
tags: javascript, functions, optimization, early-return
---
## Early Return from Functions
Return early when result is determined to skip unnecessary processing.
**Incorrect (processes all items even after finding answer):**
```typescript
function validateUsers(users: User[]) {
let hasError = false
let errorMessage = ''
for (const user of users) {
if (!user.email) {
hasError = true
errorMessage = 'Email required'
}
if (!user.name) {
hasError = true
errorMessage = 'Name required'
}
// Continues checking all users even after error found
}
return hasError ? { valid: false, error: errorMessage } : { valid: true }
}
```
**Correct (returns immediately on first error):**
```typescript
function validateUsers(users: User[]) {
for (const user of users) {
if (!user.email) {
return { valid: false, error: 'Email required' }
}
if (!user.name) {
return { valid: false, error: 'Name required' }
}
}
return { valid: true }
}
```
@@ -0,0 +1,43 @@
---
title: Hoist RegExp Creation
impact: LOW-MEDIUM
impactDescription: avoids recreation
tags: javascript, regexp, optimization, memoization
---
## Hoist RegExp Creation
Don't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`.
**Incorrect (new RegExp every render):**
```tsx
function Highlighter({ text, query }: Props) {
const regex = new RegExp(`(${query})`, 'gi')
const parts = text.split(regex)
return <>{parts.map((part, i) => ...)}</>
}
```
**Correct (memoize or hoist):**
```tsx
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function Highlighter({ text, query }: Props) {
const regex = useMemo(
() => new RegExp(`(${escapeRegex(query)})`, 'gi'),
[query]
)
const parts = text.split(regex)
return <>{parts.map((part, i) => ...)}</>
}
```
**Warning:** Global regex (`/g`) has mutable `lastIndex` state:
```typescript
const regex = /foo/g
regex.test('foo') // true, lastIndex = 3
regex.test('foo') // false, lastIndex = 0
```
@@ -0,0 +1,37 @@
---
title: Build Index Maps for Repeated Lookups
impact: LOW-MEDIUM
impactDescription: 1M ops to 2K ops
tags: javascript, map, indexing, optimization, performance
---
## Build Index Maps for Repeated Lookups
Multiple `.find()` calls by the same key should use a Map.
**Incorrect (O(n) per lookup):**
```typescript
function processOrders(orders: Order[], users: User[]) {
return orders.map(order => ({
...order,
user: users.find(u => u.id === order.userId)
}))
}
```
**Correct (O(1) per lookup):**
```typescript
function processOrders(orders: Order[], users: User[]) {
const userById = new Map(users.map(u => [u.id, u]))
return orders.map(order => ({
...order,
user: userById.get(order.userId)
}))
}
```
Build map once (O(n)), then all lookups are O(1).
For 1000 orders × 1000 users: 1M ops → 2K ops.
@@ -0,0 +1,49 @@
---
title: Early Length Check for Array Comparisons
impact: HIGH
impactDescription: avoids expensive operations when lengths differ
tags: javascript, arrays, performance, optimization, comparison
---
## Early Length Check for Array Comparisons
When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.
In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).
**Incorrect (always runs expensive comparison):**
```typescript
function hasChanges(current: string[], original: string[]) {
// Always sorts and joins, even when lengths differ
return current.sort().join() !== original.sort().join()
}
```
Two O(n log n) sorts run even when `current.length` is 5 and `original.length` is 100. There is also overhead of joining the arrays and comparing the strings.
**Correct (O(1) length check first):**
```typescript
function hasChanges(current: string[], original: string[]) {
// Early return if lengths differ
if (current.length !== original.length) {
return true
}
// Only sort/join when lengths match
const currentSorted = current.toSorted()
const originalSorted = original.toSorted()
for (let i = 0; i < currentSorted.length; i++) {
if (currentSorted[i] !== originalSorted[i]) {
return true
}
}
return false
}
```
This new approach is more efficient because:
- It avoids the overhead of sorting and joining the arrays when lengths differ
- It avoids consuming memory for the joined strings (especially important for large arrays)
- It avoids mutating the original arrays
- It returns early when a difference is found
@@ -0,0 +1,82 @@
---
title: Use Loop for Min/Max Instead of Sort
impact: LOW
impactDescription: O(n) instead of O(n log n)
tags: javascript, arrays, performance, sorting, algorithms
---
## Use Loop for Min/Max Instead of Sort
Finding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower.
**Incorrect (O(n log n) - sort to find latest):**
```typescript
interface Project {
id: string
name: string
updatedAt: number
}
function getLatestProject(projects: Project[]) {
const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
return sorted[0]
}
```
Sorts the entire array just to find the maximum value.
**Incorrect (O(n log n) - sort for oldest and newest):**
```typescript
function getOldestAndNewest(projects: Project[]) {
const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
}
```
Still sorts unnecessarily when only min/max are needed.
**Correct (O(n) - single loop):**
```typescript
function getLatestProject(projects: Project[]) {
if (projects.length === 0) return null
let latest = projects[0]
for (let i = 1; i < projects.length; i++) {
if (projects[i].updatedAt > latest.updatedAt) {
latest = projects[i]
}
}
return latest
}
function getOldestAndNewest(projects: Project[]) {
if (projects.length === 0) return { oldest: null, newest: null }
let oldest = projects[0]
let newest = projects[0]
for (let i = 1; i < projects.length; i++) {
if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
}
return { oldest, newest }
}
```
Single pass through the array, no copying, no sorting.
**Alternative (Math.min/Math.max for small arrays):**
```typescript
const numbers = [5, 2, 8, 1, 9]
const min = Math.min(...numbers)
const max = Math.max(...numbers)
```
This works for small arrays but can be slower for very large arrays due to spread operator limitations. Use the loop approach for reliability.
@@ -0,0 +1,24 @@
---
title: Use Set/Map for O(1) Lookups
impact: LOW-MEDIUM
impactDescription: O(n) to O(1)
tags: javascript, set, map, data-structures, performance
---
## Use Set/Map for O(1) Lookups
Convert arrays to Set/Map for repeated membership checks.
**Incorrect (O(n) per check):**
```typescript
const allowedIds = ['a', 'b', 'c', ...]
items.filter(item => allowedIds.includes(item.id))
```
**Correct (O(1) per check):**
```typescript
const allowedIds = new Set(['a', 'b', 'c', ...])
items.filter(item => allowedIds.has(item.id))
```
@@ -0,0 +1,57 @@
---
title: Use toSorted() Instead of sort() for Immutability
impact: MEDIUM
impactDescription: prevents mutation bugs in React state
tags: javascript, arrays, immutability, react, state, mutation
---
## Use toSorted() Instead of sort() for Immutability
`.sort()` mutates the array in place, which can cause bugs with React state and props. Use `.toSorted()` to create a new sorted array without mutation.
**Incorrect (mutates original array):**
```typescript
function UserList({ users }: { users: User[] }) {
// Mutates the users prop array!
const sorted = useMemo(
() => users.sort((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}
```
**Correct (creates new array):**
```typescript
function UserList({ users }: { users: User[] }) {
// Creates new sorted array, original unchanged
const sorted = useMemo(
() => users.toSorted((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}
```
**Why this matters in React:**
1. **Props/state mutations break React's immutability model** - React expects props and state to be treated as read-only
2. **Causes stale closure bugs** - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior
**Browser support:**
`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:
```typescript
// Fallback for older browsers
const sorted = [...items].sort((a, b) => a.value - b.value)
```
**Other immutable array methods:**
- `.toSorted()` - immutable sort
- `.toReversed()` - immutable reverse
- `.toSpliced()` - immutable splice
- `.with()` - immutable element replacement
@@ -0,0 +1,26 @@
---
title: Use Activity Component for Show/Hide
impact: MEDIUM
impactDescription: preserves state/DOM
tags: rendering, activity, visibility, state-preservation
---
## Use Activity Component for Show/Hide
Use React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility.
**Usage:**
```tsx
import { Activity } from 'react'
function Dropdown({ isOpen }: Props) {
return (
<Activity mode={isOpen ? 'visible' : 'hidden'}>
<ExpensiveMenu />
</Activity>
)
}
```
Avoids expensive re-renders and state loss.
@@ -0,0 +1,47 @@
---
title: Animate SVG Wrapper Instead of SVG Element
impact: MEDIUM
impactDescription: enables hardware acceleration
tags: rendering, svg, css, animation, performance
---
## Animate SVG Wrapper Instead of SVG Element
Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead.
**Incorrect (animating SVG directly - no hardware acceleration):**
```tsx
function LoadingSpinner() {
return (
<svg
className="animate-spin"
width="24"
height="24"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
)
}
```
**Correct (animating wrapper div - hardware accelerated):**
```tsx
function LoadingSpinner() {
return (
<div className="animate-spin">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
</div>
)
}
```
This applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations.
@@ -0,0 +1,40 @@
---
title: Use Explicit Conditional Rendering
impact: MEDIUM
impactDescription: prevents rendering 0 or NaN
tags: rendering, conditional, jsx, falsy-values
---
## Use Explicit Conditional Rendering
Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
**Incorrect (renders "0" when count is 0):**
```tsx
function Badge({ count }: { count: number }) {
return (
<div>
{count && <span className="badge">{count}</span>}
</div>
)
}
// When count = 0, renders: <div>0</div>
// When count = 5, renders: <div><span class="badge">5</span></div>
```
**Correct (renders nothing when count is 0):**
```tsx
function Badge({ count }: { count: number }) {
return (
<div>
{count > 0 ? <span className="badge">{count}</span> : null}
</div>
)
}
// When count = 0, renders: <div></div>
// When count = 5, renders: <div><span class="badge">5</span></div>
```
@@ -0,0 +1,38 @@
---
title: CSS content-visibility for Long Lists
impact: MEDIUM
impactDescription: 10× faster initial render
tags: rendering, css, content-visibility, long-lists
---
## CSS content-visibility for Long Lists
Apply `content-visibility: auto` to defer off-screen rendering.
**CSS:**
```css
.message-item {
content-visibility: auto;
contain-intrinsic-size: 0 80px;
}
```
**Example:**
```tsx
function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="overflow-y-auto h-screen">
{messages.map(msg => (
<div key={msg.id} className="message-item">
<Avatar user={msg.author} />
<div>{msg.content}</div>
</div>
))}
</div>
)
}
```
For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).
@@ -0,0 +1,44 @@
---
title: Hoist Static JSX Elements
impact: MEDIUM
impactDescription: avoids re-creation
tags: rendering, jsx, static, optimization
---
## Hoist Static JSX Elements
Extract static JSX outside components to avoid re-creation.
**Incorrect (recreates element every render):**
```tsx
function LoadingSkeleton() {
return <div className="animate-pulse h-20 bg-gray-200" />
}
function Container() {
return (
<div>
{loading && <LoadingSkeleton />}
</div>
)
}
```
**Correct (reuses same element):**
```tsx
const loadingSkeleton = (
<div className="animate-pulse h-20 bg-gray-200" />
)
function Container() {
return (
<div>
{loading && loadingSkeleton}
</div>
)
}
```
This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
@@ -0,0 +1,82 @@
---
title: Prevent Hydration Mismatch Without Flickering
impact: MEDIUM
impactDescription: avoids visual flicker and hydration errors
tags: rendering, ssr, hydration, localStorage, flicker
---
## Prevent Hydration Mismatch Without Flickering
When rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates.
**Incorrect (breaks SSR):**
```tsx
function ThemeWrapper({ children }: { children: ReactNode }) {
// localStorage is not available on server - throws error
const theme = localStorage.getItem('theme') || 'light'
return (
<div className={theme}>
{children}
</div>
)
}
```
Server-side rendering will fail because `localStorage` is undefined.
**Incorrect (visual flickering):**
```tsx
function ThemeWrapper({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('light')
useEffect(() => {
// Runs after hydration - causes visible flash
const stored = localStorage.getItem('theme')
if (stored) {
setTheme(stored)
}
}, [])
return (
<div className={theme}>
{children}
</div>
)
}
```
Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.
**Correct (no flicker, no hydration mismatch):**
```tsx
function ThemeWrapper({ children }: { children: ReactNode }) {
return (
<>
<div id="theme-wrapper">
{children}
</div>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme') || 'light';
var el = document.getElementById('theme-wrapper');
if (el) el.className = theme;
} catch (e) {}
})();
`,
}}
/>
</>
)
}
```
The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.
@@ -0,0 +1,28 @@
---
title: Optimize SVG Precision
impact: MEDIUM
impactDescription: reduces file size
tags: rendering, svg, optimization, svgo
---
## Optimize SVG Precision
Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
**Incorrect (excessive precision):**
```svg
<path d="M 10.293847 20.847362 L 30.938472 40.192837" />
```
**Correct (1 decimal place):**
```svg
<path d="M 10.3 20.8 L 30.9 40.2" />
```
**Automate with SVGO:**
```bash
npx svgo --precision=1 --multipass icon.svg
```
@@ -0,0 +1,39 @@
---
title: Defer State Reads to Usage Point
impact: MEDIUM
impactDescription: avoids unnecessary subscriptions
tags: rerender, searchParams, localStorage, optimization
---
## Defer State Reads to Usage Point
Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
**Incorrect (subscribes to all searchParams changes):**
```tsx
function ShareButton({ chatId }: { chatId: string }) {
const searchParams = useSearchParams()
const handleShare = () => {
const ref = searchParams.get('ref')
shareChat(chatId, { ref })
}
return <button onClick={handleShare}>Share</button>
}
```
**Correct (reads on demand, no subscription):**
```tsx
function ShareButton({ chatId }: { chatId: string }) {
const handleShare = () => {
const params = new URLSearchParams(window.location.search)
const ref = params.get('ref')
shareChat(chatId, { ref })
}
return <button onClick={handleShare}>Share</button>
}
```
@@ -0,0 +1,45 @@
---
title: Narrow Effect Dependencies
impact: MEDIUM
impactDescription: minimizes effect re-runs
tags: rerender, useEffect, dependencies, optimization
---
## Narrow Effect Dependencies
Specify primitive dependencies instead of objects to minimize effect re-runs.
**Incorrect (re-runs on any user field change):**
```tsx
useEffect(() => {
console.log(user.id)
}, [user])
```
**Correct (re-runs only when id changes):**
```tsx
useEffect(() => {
console.log(user.id)
}, [user.id])
```
**For derived state, compute outside effect:**
```tsx
// Incorrect: runs on width=767, 766, 765...
useEffect(() => {
if (width < 768) {
enableMobileMode()
}
}, [width])
// Correct: runs only on boolean transition
const isMobile = width < 768
useEffect(() => {
if (isMobile) {
enableMobileMode()
}
}, [isMobile])
```
@@ -0,0 +1,29 @@
---
title: Subscribe to Derived State
impact: MEDIUM
impactDescription: reduces re-render frequency
tags: rerender, derived-state, media-query, optimization
---
## Subscribe to Derived State
Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
**Incorrect (re-renders on every pixel change):**
```tsx
function Sidebar() {
const width = useWindowWidth() // updates continuously
const isMobile = width < 768
return <nav className={isMobile ? 'mobile' : 'desktop'}>
}
```
**Correct (re-renders only when boolean changes):**
```tsx
function Sidebar() {
const isMobile = useMediaQuery('(max-width: 767px)')
return <nav className={isMobile ? 'mobile' : 'desktop'}>
}
```
@@ -0,0 +1,58 @@
---
title: Use Lazy State Initialization
impact: MEDIUM
impactDescription: wasted computation on every render
tags: react, hooks, useState, performance, initialization
---
## Use Lazy State Initialization
Pass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
**Incorrect (runs on every render):**
```tsx
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() runs on EVERY render, even after initialization
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
const [query, setQuery] = useState('')
// When query changes, buildSearchIndex runs again unnecessarily
return <SearchResults index={searchIndex} query={query} />
}
function UserProfile() {
// JSON.parse runs on every render
const [settings, setSettings] = useState(
JSON.parse(localStorage.getItem('settings') || '{}')
)
return <SettingsForm settings={settings} onChange={setSettings} />
}
```
**Correct (runs only once):**
```tsx
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() runs ONLY on initial render
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
const [query, setQuery] = useState('')
return <SearchResults index={searchIndex} query={query} />
}
function UserProfile() {
// JSON.parse runs only on initial render
const [settings, setSettings] = useState(() => {
const stored = localStorage.getItem('settings')
return stored ? JSON.parse(stored) : {}
})
return <SettingsForm settings={settings} onChange={setSettings} />
}
```
Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
@@ -0,0 +1,42 @@
---
title: Extract to Memoized Components
impact: MEDIUM
impactDescription: enables early returns
tags: rerender, memo, useMemo, optimization
---
## Extract to Memoized Components
Extract expensive work into memoized components to enable early returns before computation.
**Incorrect (computes avatar even when loading):**
```tsx
function Profile({ user, loading }: Props) {
const avatar = useMemo(() => {
const id = computeAvatarId(user)
return <Avatar id={id} />
}, [user])
if (loading) return <Skeleton />
return <div>{avatar}</div>
}
```
**Correct (skips computation when loading):**
```tsx
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
const id = useMemo(() => computeAvatarId(user), [user])
return <Avatar id={id} />
})
function Profile({ user, loading }: Props) {
if (loading) return <Skeleton />
return (
<div>
<UserAvatar user={user} />
</div>
)
}
```
@@ -0,0 +1,40 @@
---
title: Use Transitions for Non-Urgent Updates
impact: MEDIUM
impactDescription: maintains UI responsiveness
tags: rerender, transitions, startTransition, performance
---
## Use Transitions for Non-Urgent Updates
Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
**Incorrect (blocks UI on every scroll):**
```tsx
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handler = () => setScrollY(window.scrollY)
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
}
```
**Correct (non-blocking updates):**
```tsx
import { startTransition } from 'react'
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handler = () => {
startTransition(() => setScrollY(window.scrollY))
}
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
}
```
@@ -0,0 +1,37 @@
---
title: Cross-Request LRU Caching
impact: HIGH
impactDescription: caches across requests
tags: server, cache, lru, cross-request
---
## Cross-Request LRU Caching
`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.
**Implementation:**
```typescript
import { LRUCache } from 'lru-cache'
const cache = new LRUCache<string, any>({
max: 1000,
ttl: 5 * 60 * 1000 // 5 minutes
})
export async function getUser(id: string) {
const cached = cache.get(id)
if (cached) return cached
const user = await db.user.findUnique({ where: { id } })
cache.set(id, user)
return user
}
// Request 1: DB query, result cached
// Request 2: cache hit, no DB query
```
Use when sequential user actions hit multiple endpoints needing the same data within seconds. In serverless, consider Redis for cross-process caching.
Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
@@ -0,0 +1,26 @@
---
title: Per-Request Deduplication with React.cache()
impact: HIGH
impactDescription: deduplicates within request
tags: server, cache, react-cache, deduplication
---
## Per-Request Deduplication with React.cache()
Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most.
**Usage:**
```typescript
import { cache } from 'react'
export const getCurrentUser = cache(async () => {
const session = await auth()
if (!session?.user?.id) return null
return await db.user.findUnique({
where: { id: session.user.id }
})
})
```
Within a single request, multiple calls to `getCurrentUser()` execute the query only once.
@@ -0,0 +1,79 @@
---
title: Parallel Data Fetching with Component Composition
impact: HIGH
impactDescription: eliminates server-side waterfalls
tags: server, rsc, parallel-fetching, composition
---
## Parallel Data Fetching with Component Composition
React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.
**Incorrect (Sidebar waits for Page's fetch to complete):**
```tsx
export default async function Page() {
const header = await fetchHeader()
return (
<div>
<div>{header}</div>
<Sidebar />
</div>
)
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
}
```
**Correct (both fetch simultaneously):**
```tsx
async function Header() {
const data = await fetchHeader()
return <div>{data}</div>
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
}
export default function Page() {
return (
<div>
<Header />
<Sidebar />
</div>
)
}
```
**Alternative with children prop:**
```tsx
async function Layout({ children }: { children: ReactNode }) {
const header = await fetchHeader()
return (
<div>
<div>{header}</div>
{children}
</div>
)
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
}
export default function Page() {
return (
<Layout>
<Sidebar />
</Layout>
)
}
```
@@ -0,0 +1,38 @@
---
title: Minimize Serialization at RSC Boundaries
impact: HIGH
impactDescription: reduces data transfer size
tags: server, rsc, serialization, props
---
## Minimize Serialization at RSC Boundaries
The React Server/Client boundary serializes all object properties. Only pass fields that the client actually uses.
**Incorrect (serializes all 50 fields):**
```tsx
async function Page() {
const user = await fetchUser() // 50 fields
return <Profile user={user} />
}
'use client'
function Profile({ user }: { user: User }) {
return <div>{user.name}</div> // uses 1 field
}
```
**Correct (serializes only 1 field):**
```tsx
async function Page() {
const user = await fetchUser()
return <Profile name={user.name} />
}
'use client'
function Profile({ name }: { name: string }) {
return <div>{name}</div>
}
```
@@ -0,0 +1,135 @@
---
name: react-component-performance
description: Diagnose slow React components and suggest targeted performance fixes.
risk: safe
source: "Dimillian/Skills (MIT)"
date_added: "2026-03-25"
---
# React Component Performance
## Overview
Identify render hotspots, isolate expensive updates, and apply targeted optimizations without changing UI behavior.
## When to Use
- When the user asks to profile or improve a slow React component.
- When you need to reduce re-renders, list lag, or expensive render work in React UI.
## Workflow
1. Reproduce or describe the slowdown.
2. Identify what triggers re-renders (state updates, props churn, effects).
3. Isolate fast-changing state from heavy subtrees.
4. Stabilize props and handlers; memoize where it pays off.
5. Reduce expensive work (computation, DOM size, list length).
6. **Validate**: open React DevTools Profiler → record the interaction → inspect the Flamegraph for components rendering longer than ~16 ms → compare against a pre-optimization baseline recording.
## Checklist
- Measure: use React DevTools Profiler or log renders; capture baseline.
- Find churn: identify state updated on a timer, scroll, input, or animation.
- Split: move ticking state into a child; keep heavy lists static.
- Memoize: wrap leaf rows with `memo` only when props are stable.
- Stabilize props: use `useCallback`/`useMemo` for handlers and derived values.
- Avoid derived work in render: precompute, or compute inside memoized helpers.
- Control list size: window/virtualize long lists; avoid rendering hidden items.
- Keys: ensure stable keys; avoid index when order can change.
- Effects: verify dependency arrays; avoid effects that re-run on every render.
- Style/layout: watch for expensive layout thrash or large Markdown/diff renders.
## Optimization Patterns
### Isolate ticking state
Move a timer or animation counter into a child so the parent list never re-renders on each tick.
```tsx
// ❌ Before entire parent (and list) re-renders every second
function Dashboard({ items }: { items: Item[] }) {
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(id);
}, []);
return (
<>
<Clock tick={tick} />
<ExpensiveList items={items} /> {/* re-renders every second */}
</>
);
}
// ✅ After only <Clock> re-renders; list is untouched
function Clock() {
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(id);
}, []);
return <span>{tick}s</span>;
}
function Dashboard({ items }: { items: Item[] }) {
return (
<>
<Clock />
<ExpensiveList items={items} />
</>
);
}
```
### Stabilize callbacks with `useCallback` + `memo`
```tsx
// ❌ Before new handler reference on every render busts Row memo
function List({ items }: { items: Item[] }) {
const handleClick = (id: string) => console.log(id); // new ref each render
return items.map(item => <Row key={item.id} item={item} onClick={handleClick} />);
}
// ✅ After stable handler; Row only re-renders when its own item changes
const Row = memo(({ item, onClick }: RowProps) => (
<li onClick={() => onClick(item.id)}>{item.name}</li>
));
function List({ items }: { items: Item[] }) {
const handleClick = useCallback((id: string) => console.log(id), []);
return items.map(item => <Row key={item.id} item={item} onClick={handleClick} />);
}
```
### Prefer derived data outside render
```tsx
// ❌ Before recomputes on every render
function Summary({ orders }: { orders: Order[] }) {
const total = orders.reduce((sum, o) => sum + o.amount, 0); // runs every render
return <p>Total: {total}</p>;
}
// ✅ After recomputes only when orders changes
function Summary({ orders }: { orders: Order[] }) {
const total = useMemo(() => orders.reduce((sum, o) => sum + o.amount, 0), [orders]);
return <p>Total: {total}</p>;
}
```
### Additional patterns
- **Split rows**: extract list rows into memoized components with narrow props.
- **Defer heavy rendering**: lazy-render or collapse expensive content until expanded.
## Profiling Validation Steps
1. Open **React DevTools → Profiler** tab.
2. Click **Record**, perform the slow interaction, then **Stop**.
3. Switch to **Flamegraph** view; any bar labeled with a component and time > ~16 ms is a candidate.
4. Use **Ranked chart** to sort by self render time and target the top offenders.
5. Apply one optimization at a time, re-record, and compare render counts and durations against the baseline.
## Example Reference
Load `references/examples.md` when the user wants a concrete refactor example.
@@ -0,0 +1,4 @@
interface:
display_name: "React Component Performance"
short_description: "Profile and fix React render issues"
default_prompt: "Use $react-component-performance to analyze and improve this React component's rendering performance."
@@ -0,0 +1,88 @@
# Examples
## Isolate a ticking timer from a long list
**Scenario:** A message list re-renders every second because a timer (`elapsedMs`) lives in the parent component. This causes visible jank on large lists.
**Goal:** Keep UI identical but limit re-renders to the timer area.
**Before (problematic pattern):**
```tsx
function Messages({ items, isThinking, processingStartedAt }) {
const [elapsedMs, setElapsedMs] = useState(0);
useEffect(() => {
if (!isThinking || !processingStartedAt) {
setElapsedMs(0);
return;
}
setElapsedMs(Date.now() - processingStartedAt);
const interval = window.setInterval(() => {
setElapsedMs(Date.now() - processingStartedAt);
}, 1000);
return () => window.clearInterval(interval);
}, [isThinking, processingStartedAt]);
return (
<div>
{items.map((item) => (
<MessageRow key={item.id} item={item} />
))}
<div>{formatDurationMs(elapsedMs)}</div>
</div>
);
}
```
**After (isolated ticking state):**
```tsx
type WorkingIndicatorProps = {
isThinking: boolean;
processingStartedAt?: number | null;
};
const WorkingIndicator = memo(function WorkingIndicator({
isThinking,
processingStartedAt = null,
}: WorkingIndicatorProps) {
const [elapsedMs, setElapsedMs] = useState(0);
useEffect(() => {
if (!isThinking || !processingStartedAt) {
setElapsedMs(0);
return;
}
setElapsedMs(Date.now() - processingStartedAt);
const interval = window.setInterval(() => {
setElapsedMs(Date.now() - processingStartedAt);
}, 1000);
return () => window.clearInterval(interval);
}, [isThinking, processingStartedAt]);
return <div>{formatDurationMs(elapsedMs)}</div>;
});
function Messages({ items, isThinking, processingStartedAt }) {
return (
<div>
{items.map((item) => (
<MessageRow key={item.id} item={item} />
))}
<WorkingIndicator
isThinking={isThinking}
processingStartedAt={processingStartedAt}
/>
</div>
);
}
```
**Why it helps:** Only the `WorkingIndicator` subtree re-renders every second. The list remains stable unless its props change.
**Optional follow-ups:**
- Wrap `MessageRow` in `memo` if props are stable.
- Use `useCallback` for handlers passed to rows to avoid re-render churn.
- Consider list virtualization if the list is very large.
@@ -0,0 +1,36 @@
---
name: react-native-architecture
description: "Production-ready patterns for React Native development with Expo, including navigation, state management, native modules, and offline-first architecture."
risk: safe
source: community
date_added: "2026-02-27"
---
# React Native Architecture
Production-ready patterns for React Native development with Expo, including navigation, state management, native modules, and offline-first architecture.
## Use this skill when
- Starting a new React Native or Expo project
- Implementing complex navigation patterns
- Integrating native modules and platform APIs
- Building offline-first mobile applications
- Optimizing React Native performance
- Setting up CI/CD for mobile releases
## Do not use this skill when
- The task is unrelated to react native architecture
- You need a different domain or tool outside this scope
## Instructions
- Clarify goals, constraints, and required inputs.
- Apply relevant best practices and validate outcomes.
- Provide actionable steps and verification.
- If detailed examples are required, open `resources/implementation-playbook.md`.
## Resources
- `resources/implementation-playbook.md` for detailed patterns and examples.
@@ -0,0 +1,670 @@
# React Native Architecture Implementation Playbook
This file contains detailed patterns, checklists, and code samples referenced by the skill.
# React Native Architecture
Production-ready patterns for React Native development with Expo, including navigation, state management, native modules, and offline-first architecture.
## When to Use This Skill
- Starting a new React Native or Expo project
- Implementing complex navigation patterns
- Integrating native modules and platform APIs
- Building offline-first mobile applications
- Optimizing React Native performance
- Setting up CI/CD for mobile releases
## Core Concepts
### 1. Project Structure
```
src/
├── app/ # Expo Router screens
│ ├── (auth)/ # Auth group
│ ├── (tabs)/ # Tab navigation
│ └── _layout.tsx # Root layout
├── components/
│ ├── ui/ # Reusable UI components
│ └── features/ # Feature-specific components
├── hooks/ # Custom hooks
├── services/ # API and native services
├── stores/ # State management
├── utils/ # Utilities
└── types/ # TypeScript types
```
### 2. Expo vs Bare React Native
| Feature | Expo | Bare RN |
|---------|------|---------|
| Setup complexity | Low | High |
| Native modules | EAS Build | Manual linking |
| OTA updates | Built-in | Manual setup |
| Build service | EAS | Custom CI |
| Custom native code | Config plugins | Direct access |
## Quick Start
```bash
# Create new Expo project
npx create-expo-app@latest my-app -t expo-template-blank-typescript
# Install essential dependencies
npx expo install expo-router expo-status-bar react-native-safe-area-context
npx expo install @react-native-async-storage/async-storage
npx expo install expo-secure-store expo-haptics
```
```typescript
// app/_layout.tsx
import { Stack } from 'expo-router'
import { ThemeProvider } from '@/providers/ThemeProvider'
import { QueryProvider } from '@/providers/QueryProvider'
export default function RootLayout() {
return (
<QueryProvider>
<ThemeProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
</Stack>
</ThemeProvider>
</QueryProvider>
)
}
```
## Patterns
### Pattern 1: Expo Router Navigation
```typescript
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router'
import { Home, Search, User, Settings } from 'lucide-react-native'
import { useTheme } from '@/hooks/useTheme'
export default function TabLayout() {
const { colors } = useTheme()
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textMuted,
tabBarStyle: { backgroundColor: colors.background },
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => <Home size={size} color={color} />,
}}
/>
<Tabs.Screen
name="search"
options={{
title: 'Search',
tabBarIcon: ({ color, size }) => <Search size={size} color={color} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => <User size={size} color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
}}
/>
</Tabs>
)
}
// app/(tabs)/profile/[id].tsx - Dynamic route
import { useLocalSearchParams } from 'expo-router'
export default function ProfileScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
return <UserProfile userId={id} />
}
// Navigation from anywhere
import { router } from 'expo-router'
// Programmatic navigation
router.push('/profile/123')
router.replace('/login')
router.back()
// With params
router.push({
pathname: '/product/[id]',
params: { id: '123', referrer: 'home' },
})
```
### Pattern 2: Authentication Flow
```typescript
// providers/AuthProvider.tsx
import { createContext, useContext, useEffect, useState } from 'react'
import { useRouter, useSegments } from 'expo-router'
import * as SecureStore from 'expo-secure-store'
interface AuthContextType {
user: User | null
isLoading: boolean
signIn: (credentials: Credentials) => Promise<void>
signOut: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const segments = useSegments()
const router = useRouter()
// Check authentication on mount
useEffect(() => {
checkAuth()
}, [])
// Protect routes
useEffect(() => {
if (isLoading) return
const inAuthGroup = segments[0] === '(auth)'
if (!user && !inAuthGroup) {
router.replace('/login')
} else if (user && inAuthGroup) {
router.replace('/(tabs)')
}
}, [user, segments, isLoading])
async function checkAuth() {
try {
const token = await SecureStore.getItemAsync('authToken')
if (token) {
const userData = await api.getUser(token)
setUser(userData)
}
} catch (error) {
await SecureStore.deleteItemAsync('authToken')
} finally {
setIsLoading(false)
}
}
async function signIn(credentials: Credentials) {
const { token, user } = await api.login(credentials)
await SecureStore.setItemAsync('authToken', token)
setUser(user)
}
async function signOut() {
await SecureStore.deleteItemAsync('authToken')
setUser(null)
}
if (isLoading) {
return <SplashScreen />
}
return (
<AuthContext.Provider value={{ user, isLoading, signIn, signOut }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth must be used within AuthProvider')
return context
}
```
### Pattern 3: Offline-First with React Query
```typescript
// providers/QueryProvider.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import AsyncStorage from '@react-native-async-storage/async-storage'
import NetInfo from '@react-native-community/netinfo'
import { onlineManager } from '@tanstack/react-query'
// Sync online status
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected)
})
})
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 2,
networkMode: 'offlineFirst',
},
mutations: {
networkMode: 'offlineFirst',
},
},
})
const asyncStoragePersister = createAsyncStoragePersister({
storage: AsyncStorage,
key: 'REACT_QUERY_OFFLINE_CACHE',
})
export function QueryProvider({ children }: { children: React.ReactNode }) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: asyncStoragePersister }}
>
{children}
</PersistQueryClientProvider>
)
}
// hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
export function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: api.getProducts,
// Use stale data while revalidating
placeholderData: (previousData) => previousData,
})
}
export function useCreateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: api.createProduct,
// Optimistic update
onMutate: async (newProduct) => {
await queryClient.cancelQueries({ queryKey: ['products'] })
const previous = queryClient.getQueryData(['products'])
queryClient.setQueryData(['products'], (old: Product[]) => [
...old,
{ ...newProduct, id: 'temp-' + Date.now() },
])
return { previous }
},
onError: (err, newProduct, context) => {
queryClient.setQueryData(['products'], context?.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
},
})
}
```
### Pattern 4: Native Module Integration
```typescript
// services/haptics.ts
import * as Haptics from 'expo-haptics'
import { Platform } from 'react-native'
export const haptics = {
light: () => {
if (Platform.OS !== 'web') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}
},
medium: () => {
if (Platform.OS !== 'web') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
}
},
heavy: () => {
if (Platform.OS !== 'web') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
}
},
success: () => {
if (Platform.OS !== 'web') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
}
},
error: () => {
if (Platform.OS !== 'web') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error)
}
},
}
// services/biometrics.ts
import * as LocalAuthentication from 'expo-local-authentication'
export async function authenticateWithBiometrics(): Promise<boolean> {
const hasHardware = await LocalAuthentication.hasHardwareAsync()
if (!hasHardware) return false
const isEnrolled = await LocalAuthentication.isEnrolledAsync()
if (!isEnrolled) return false
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Authenticate to continue',
fallbackLabel: 'Use passcode',
disableDeviceFallback: false,
})
return result.success
}
// services/notifications.ts
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import Constants from 'expo-constants'
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
})
export async function registerForPushNotifications() {
let token: string | undefined
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
})
}
const { status: existingStatus } = await Notifications.getPermissionsAsync()
let finalStatus = existingStatus
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync()
finalStatus = status
}
if (finalStatus !== 'granted') {
return null
}
const projectId = Constants.expoConfig?.extra?.eas?.projectId
token = (await Notifications.getExpoPushTokenAsync({ projectId })).data
return token
}
```
### Pattern 5: Platform-Specific Code
```typescript
// components/ui/Button.tsx
import { Platform, Pressable, StyleSheet, Text, ViewStyle } from 'react-native'
import * as Haptics from 'expo-haptics'
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated'
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
interface ButtonProps {
title: string
onPress: () => void
variant?: 'primary' | 'secondary' | 'outline'
disabled?: boolean
}
export function Button({
title,
onPress,
variant = 'primary',
disabled = false,
}: ButtonProps) {
const scale = useSharedValue(1)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}))
const handlePressIn = () => {
scale.value = withSpring(0.95)
if (Platform.OS !== 'web') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}
}
const handlePressOut = () => {
scale.value = withSpring(1)
}
return (
<AnimatedPressable
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
style={[
styles.button,
styles[variant],
disabled && styles.disabled,
animatedStyle,
]}
>
<Text style={[styles.text, styles[`${variant}Text`]]}>{title}</Text>
</AnimatedPressable>
)
}
// Platform-specific files
// Button.ios.tsx - iOS-specific implementation
// Button.android.tsx - Android-specific implementation
// Button.web.tsx - Web-specific implementation
// Or use Platform.select
const styles = StyleSheet.create({
button: {
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
alignItems: 'center',
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 4,
},
}),
},
primary: {
backgroundColor: '#007AFF',
},
secondary: {
backgroundColor: '#5856D6',
},
outline: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#007AFF',
},
disabled: {
opacity: 0.5,
},
text: {
fontSize: 16,
fontWeight: '600',
},
primaryText: {
color: '#FFFFFF',
},
secondaryText: {
color: '#FFFFFF',
},
outlineText: {
color: '#007AFF',
},
})
```
### Pattern 6: Performance Optimization
```typescript
// components/ProductList.tsx
import { FlashList } from '@shopify/flash-list'
import { memo, useCallback } from 'react'
interface ProductListProps {
products: Product[]
onProductPress: (id: string) => void
}
// Memoize list item
const ProductItem = memo(function ProductItem({
item,
onPress,
}: {
item: Product
onPress: (id: string) => void
}) {
const handlePress = useCallback(() => onPress(item.id), [item.id, onPress])
return (
<Pressable onPress={handlePress} style={styles.item}>
<FastImage
source={{ uri: item.image }}
style={styles.image}
resizeMode="cover"
/>
<Text style={styles.title}>{item.name}</Text>
<Text style={styles.price}>${item.price}</Text>
</Pressable>
)
})
export function ProductList({ products, onProductPress }: ProductListProps) {
const renderItem = useCallback(
({ item }: { item: Product }) => (
<ProductItem item={item} onPress={onProductPress} />
),
[onProductPress]
)
const keyExtractor = useCallback((item: Product) => item.id, [])
return (
<FlashList
data={products}
renderItem={renderItem}
keyExtractor={keyExtractor}
estimatedItemSize={100}
// Performance optimizations
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={5}
// Pull to refresh
onRefresh={onRefresh}
refreshing={isRefreshing}
/>
)
}
```
## EAS Build & Submit
```json
// eas.json
{
"cli": { "version": ">= 5.0.0" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": { "simulator": true }
},
"preview": {
"distribution": "internal",
"android": { "buildType": "apk" }
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": { "appleId": "your@email.com", "ascAppId": "123456789" },
"android": { "serviceAccountKeyPath": "./google-services.json" }
}
}
}
```
```bash
# Build commands
eas build --platform ios --profile development
eas build --platform android --profile preview
eas build --platform all --profile production
# Submit to stores
eas submit --platform ios
eas submit --platform android
# OTA updates
eas update --branch production --message "Bug fixes"
```
## Best Practices
### Do's
- **Use Expo** - Faster development, OTA updates, managed native code
- **FlashList over FlatList** - Better performance for long lists
- **Memoize components** - Prevent unnecessary re-renders
- **Use Reanimated** - 60fps animations on native thread
- **Test on real devices** - Simulators miss real-world issues
### Don'ts
- **Don't inline styles** - Use StyleSheet.create for performance
- **Don't fetch in render** - Use useEffect or React Query
- **Don't ignore platform differences** - Test on both iOS and Android
- **Don't store secrets in code** - Use environment variables
- **Don't skip error boundaries** - Mobile crashes are unforgiving
## Resources
- [Expo Documentation](https://docs.expo.dev/)
- [Expo Router](https://docs.expo.dev/router/introduction/)
- [React Native Performance](https://reactnative.dev/docs/performance)
- [FlashList](https://shopify.github.io/flash-list/)
@@ -0,0 +1,444 @@
---
name: react-state-management
description: "Master modern React state management with Redux Toolkit, Zustand, Jotai, and React Query. Use when setting up global state, managing server state, or choosing between state management solutions."
risk: unknown
source: community
date_added: "2026-02-27"
---
# React State Management
Comprehensive guide to modern React state management patterns, from local component state to global stores and server state synchronization.
## Do not use this skill when
- The task is unrelated to react state management
- You need a different domain or tool outside this scope
## Instructions
- Clarify goals, constraints, and required inputs.
- Apply relevant best practices and validate outcomes.
- Provide actionable steps and verification.
- If detailed examples are required, open `resources/implementation-playbook.md`.
## Use this skill when
- Setting up global state management in a React app
- Choosing between Redux Toolkit, Zustand, or Jotai
- Managing server state with React Query or SWR
- Implementing optimistic updates
- Debugging state-related issues
- Migrating from legacy Redux to modern patterns
## Core Concepts
### 1. State Categories
| Type | Description | Solutions |
|------|-------------|-----------|
| **Local State** | Component-specific, UI state | useState, useReducer |
| **Global State** | Shared across components | Redux Toolkit, Zustand, Jotai |
| **Server State** | Remote data, caching | React Query, SWR, RTK Query |
| **URL State** | Route parameters, search | React Router, nuqs |
| **Form State** | Input values, validation | React Hook Form, Formik |
### 2. Selection Criteria
```
Small app, simple state → Zustand or Jotai
Large app, complex state → Redux Toolkit
Heavy server interaction → React Query + light client state
Atomic/granular updates → Jotai
```
## Quick Start
### Zustand (Simplest)
```typescript
// store/useStore.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
interface AppState {
user: User | null
theme: 'light' | 'dark'
setUser: (user: User | null) => void
toggleTheme: () => void
}
export const useStore = create<AppState>()(
devtools(
persist(
(set) => ({
user: null,
theme: 'light',
setUser: (user) => set({ user }),
toggleTheme: () => set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light'
})),
}),
{ name: 'app-storage' }
)
)
)
// Usage in component
function Header() {
const { user, theme, toggleTheme } = useStore()
return (
<header className={theme}>
{user?.name}
<button onClick={toggleTheme}>Toggle Theme</button>
</header>
)
}
```
## Patterns
### Pattern 1: Redux Toolkit with TypeScript
```typescript
// store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import userReducer from './slices/userSlice'
import cartReducer from './slices/cartSlice'
export const store = configureStore({
reducer: {
user: userReducer,
cart: cartReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST'],
},
}),
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
// Typed hooks
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
```
```typescript
// store/slices/userSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
interface User {
id: string
email: string
name: string
}
interface UserState {
current: User | null
status: 'idle' | 'loading' | 'succeeded' | 'failed'
error: string | null
}
const initialState: UserState = {
current: null,
status: 'idle',
error: null,
}
export const fetchUser = createAsyncThunk(
'user/fetchUser',
async (userId: string, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) throw new Error('Failed to fetch user')
return await response.json()
} catch (error) {
return rejectWithValue((error as Error).message)
}
}
)
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setUser: (state, action: PayloadAction<User>) => {
state.current = action.payload
state.status = 'succeeded'
},
clearUser: (state) => {
state.current = null
state.status = 'idle'
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.status = 'loading'
state.error = null
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'succeeded'
state.current = action.payload
})
.addCase(fetchUser.rejected, (state, action) => {
state.status = 'failed'
state.error = action.payload as string
})
},
})
export const { setUser, clearUser } = userSlice.actions
export default userSlice.reducer
```
### Pattern 2: Zustand with Slices (Scalable)
```typescript
// store/slices/createUserSlice.ts
import { StateCreator } from 'zustand'
export interface UserSlice {
user: User | null
isAuthenticated: boolean
login: (credentials: Credentials) => Promise<void>
logout: () => void
}
export const createUserSlice: StateCreator<
UserSlice & CartSlice, // Combined store type
[],
[],
UserSlice
> = (set, get) => ({
user: null,
isAuthenticated: false,
login: async (credentials) => {
const user = await authApi.login(credentials)
set({ user, isAuthenticated: true })
},
logout: () => {
set({ user: null, isAuthenticated: false })
// Can access other slices
// get().clearCart()
},
})
// store/index.ts
import { create } from 'zustand'
import { createUserSlice, UserSlice } from './slices/createUserSlice'
import { createCartSlice, CartSlice } from './slices/createCartSlice'
type StoreState = UserSlice & CartSlice
export const useStore = create<StoreState>()((...args) => ({
...createUserSlice(...args),
...createCartSlice(...args),
}))
// Selective subscriptions (prevents unnecessary re-renders)
export const useUser = () => useStore((state) => state.user)
export const useCart = () => useStore((state) => state.cart)
```
### Pattern 3: Jotai for Atomic State
```typescript
// atoms/userAtoms.ts
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
// Basic atom
export const userAtom = atom<User | null>(null)
// Derived atom (computed)
export const isAuthenticatedAtom = atom((get) => get(userAtom) !== null)
// Atom with localStorage persistence
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light')
// Async atom
export const userProfileAtom = atom(async (get) => {
const user = get(userAtom)
if (!user) return null
const response = await fetch(`/api/users/${user.id}/profile`)
return response.json()
})
// Write-only atom (action)
export const logoutAtom = atom(null, (get, set) => {
set(userAtom, null)
set(cartAtom, [])
localStorage.removeItem('token')
})
// Usage
function Profile() {
const [user] = useAtom(userAtom)
const [, logout] = useAtom(logoutAtom)
const [profile] = useAtom(userProfileAtom) // Suspense-enabled
return (
<Suspense fallback={<Skeleton />}>
<ProfileContent profile={profile} onLogout={logout} />
</Suspense>
)
}
```
### Pattern 4: React Query for Server State
```typescript
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
// Query keys factory
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
}
// Fetch hook
export function useUsers(filters: UserFilters) {
return useQuery({
queryKey: userKeys.list(filters),
queryFn: () => fetchUsers(filters),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
})
}
// Single user hook
export function useUser(id: string) {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: () => fetchUser(id),
enabled: !!id, // Don't fetch if no id
})
}
// Mutation with optimistic update
export function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateUser,
onMutate: async (newUser) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: userKeys.detail(newUser.id) })
// Snapshot previous value
const previousUser = queryClient.getQueryData(userKeys.detail(newUser.id))
// Optimistically update
queryClient.setQueryData(userKeys.detail(newUser.id), newUser)
return { previousUser }
},
onError: (err, newUser, context) => {
// Rollback on error
queryClient.setQueryData(
userKeys.detail(newUser.id),
context?.previousUser
)
},
onSettled: (data, error, variables) => {
// Refetch after mutation
queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) })
},
})
}
```
### Pattern 5: Combining Client + Server State
```typescript
// Zustand for client state
const useUIStore = create<UIState>((set) => ({
sidebarOpen: true,
modal: null,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
openModal: (modal) => set({ modal }),
closeModal: () => set({ modal: null }),
}))
// React Query for server state
function Dashboard() {
const { sidebarOpen, toggleSidebar } = useUIStore()
const { data: users, isLoading } = useUsers({ active: true })
const { data: stats } = useStats()
if (isLoading) return <DashboardSkeleton />
return (
<div className={sidebarOpen ? 'with-sidebar' : ''}>
<Sidebar open={sidebarOpen} onToggle={toggleSidebar} />
<main>
<StatsCards stats={stats} />
<UserTable users={users} />
</main>
</div>
)
}
```
## Best Practices
### Do's
- **Colocate state** - Keep state as close to where it's used as possible
- **Use selectors** - Prevent unnecessary re-renders with selective subscriptions
- **Normalize data** - Flatten nested structures for easier updates
- **Type everything** - Full TypeScript coverage prevents runtime errors
- **Separate concerns** - Server state (React Query) vs client state (Zustand)
### Don'ts
- **Don't over-globalize** - Not everything needs to be in global state
- **Don't duplicate server state** - Let React Query manage it
- **Don't mutate directly** - Always use immutable updates
- **Don't store derived data** - Compute it instead
- **Don't mix paradigms** - Pick one primary solution per category
## Migration Guides
### From Legacy Redux to RTK
```typescript
// Before (legacy Redux)
const ADD_TODO = 'ADD_TODO'
const addTodo = (text) => ({ type: ADD_TODO, payload: text })
function todosReducer(state = [], action) {
switch (action.type) {
case ADD_TODO:
return [...state, { text: action.payload, completed: false }]
default:
return state
}
}
// After (Redux Toolkit)
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
// Immer allows "mutations"
state.push({ text: action.payload, completed: false })
},
},
})
```
## Resources
- [Redux Toolkit Documentation](https://redux-toolkit.js.org/)
- [Zustand GitHub](https://github.com/pmndrs/zustand)
- [Jotai Documentation](https://jotai.org/)
- [TanStack Query](https://tanstack.com/query)
@@ -0,0 +1,458 @@
---
name: roier-seo
description: Technical SEO auditor and fixer. Runs Lighthouse/PageSpeed audits on websites or local dev servers, analyzes SEO/performance/accessibility scores, and automatically implements fixes for meta tags, structured data, Core Web Vitals, and accessibility issues.
version: 1.0.0
author: Kemeny Studio
license: MIT
tags: [SEO, Lighthouse, PageSpeed, Accessibility, Performance, Meta Tags, Structured Data, Core Web Vitals, WCAG, Next.js, React, Vue]
dependencies: [lighthouse, chrome-launcher]
---
# Roier SEO - Technical SEO Auditor & Fixer
AI-powered SEO optimization skill that audits websites and automatically implements fixes.
## When to use this skill
**Use Roier SEO when:**
- User asks to "audit my site" or "check SEO"
- User wants to "improve performance" or "fix SEO issues"
- User mentions "lighthouse", "pagespeed", or "core web vitals"
- User wants to add/fix meta tags, structured data, or accessibility
- User has a local dev server and wants SEO analysis
**Key features:**
- **Full Audits**: Lighthouse audits on any URL (localhost or live)
- **Auto-Fix**: Implements fixes directly in the codebase
- **Framework Aware**: Detects Next.js, React, Vue, Nuxt, plain HTML
- **Core Web Vitals**: Track FCP, LCP, TBT, CLS metrics
- **Structured Data**: JSON-LD schemas for rich snippets
- **Accessibility**: WCAG compliance fixes
**Use alternatives instead:**
- **React Best Practices**: For general React performance optimization
- **Manual Lighthouse**: For one-off audits without auto-fixing
## Quick start
### Installation
After installing the skill, install the audit dependencies:
```bash
cd ~/.claude/skills/roier-seo/scripts
npm install
```
### Running an Audit
For a **live website**:
```bash
node ~/.claude/skills/roier-seo/scripts/audit.js https://example.com
```
For a **local dev server** (must be running):
```bash
node ~/.claude/skills/roier-seo/scripts/audit.js http://localhost:3000
```
Output formats:
```bash
# JSON output (default, for programmatic use)
node ~/.claude/skills/roier-seo/scripts/audit.js https://example.com
# Human-readable summary
node ~/.claude/skills/roier-seo/scripts/audit.js https://example.com --output=summary
# Save to file
node ~/.claude/skills/roier-seo/scripts/audit.js https://example.com --save=results.json
```
## Audit categories
The audit returns scores (0-100) for five categories:
| Category | Description | Weight |
|----------|-------------|--------|
| **Performance** | Page load speed, Core Web Vitals | High |
| **Accessibility** | WCAG compliance, screen reader support | High |
| **Best Practices** | Security, modern web standards | Medium |
| **SEO** | Search engine optimization, crawlability | High |
| **PWA** | Progressive Web App compliance | Low |
## Technical SEO fix patterns
### Meta tags (HTML Head)
#### Title tag
```html
<!-- Bad -->
<title>Home</title>
<!-- Good -->
<title>Primary Keyword - Secondary Keyword | Brand Name</title>
```
**Rules:**
- 50-60 characters max
- Include primary keyword near the beginning
- Unique per page
- Include brand name at end
#### Meta description
```html
<meta name="description" content="Compelling description with keywords. 150-160 characters that encourages clicks from search results.">
```
**Rules:**
- 150-160 characters
- Include primary and secondary keywords naturally
- Compelling call-to-action
- Unique per page
#### Essential meta tags
```html
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<html lang="en">
```
### Open Graph tags (social sharing)
```html
<meta property="og:title" content="Page Title">
<meta property="og:description" content="Page description">
<meta property="og:image" content="https://example.com/image.jpg">
<meta property="og:url" content="https://example.com/page">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Brand Name">
```
### Twitter Card tags
```html
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Page Title">
<meta name="twitter:description" content="Page description">
<meta name="twitter:image" content="https://example.com/image.jpg">
```
### Canonical URL
```html
<link rel="canonical" href="https://example.com/canonical-page">
```
### Robots meta
```html
<!-- Allow indexing (default) -->
<meta name="robots" content="index, follow">
<!-- Prevent indexing (for staging, admin pages) -->
<meta name="robots" content="noindex, nofollow">
```
## Structured data (JSON-LD)
### Website schema
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Site Name",
"url": "https://example.com",
"potentialAction": {
"@type": "SearchAction",
"target": "https://example.com/search?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
```
### Organization schema
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Company Name",
"url": "https://example.com",
"logo": "https://example.com/logo.png",
"sameAs": [
"https://twitter.com/company",
"https://linkedin.com/company/company"
]
}
</script>
```
### BreadcrumbList schema
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://example.com"},
{"@type": "ListItem", "position": 2, "name": "Category", "item": "https://example.com/category"},
{"@type": "ListItem", "position": 3, "name": "Page"}
]
}
</script>
```
### Article schema (for blog posts)
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Article Title",
"author": {"@type": "Person", "name": "Author Name"},
"datePublished": "2024-01-15",
"dateModified": "2024-01-20",
"image": "https://example.com/article-image.jpg",
"publisher": {
"@type": "Organization",
"name": "Publisher Name",
"logo": {"@type": "ImageObject", "url": "https://example.com/logo.png"}
}
}
</script>
```
## Performance optimizations
### Image optimization
```html
<!-- Add width/height to prevent CLS -->
<img src="image.jpg" alt="Description" width="800" height="600">
<!-- Add lazy loading -->
<img src="image.jpg" alt="Description" loading="lazy">
<!-- Use modern formats -->
<picture>
<source srcset="image.avif" type="image/avif">
<source srcset="image.webp" type="image/webp">
<img src="image.jpg" alt="Description">
</picture>
```
### Font optimization
```html
<!-- Preload critical fonts -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
```
```css
@font-face {
font-family: 'Custom Font';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
}
```
### Resource hints
```html
<!-- Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://cdn.example.com">
<!-- DNS prefetch for non-critical origins -->
<link rel="dns-prefetch" href="https://analytics.example.com">
<!-- Preload critical resources -->
<link rel="preload" href="/critical.css" as="style">
```
## Accessibility fixes
### Alt text
```html
<!-- Good (descriptive) -->
<img src="photo.jpg" alt="Team members collaborating in the office">
<!-- Good (decorative) -->
<img src="decoration.jpg" alt="" role="presentation">
```
### Color contrast
- **4.5:1** contrast ratio for normal text
- **3:1** contrast ratio for large text (18px+ or 14px+ bold)
### Form labels
```html
<label for="email">Email Address</label>
<input type="email" id="email" name="email">
```
### Skip link
```html
<a href="#main-content" class="skip-link">Skip to main content</a>
<style>
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: 0;
top: 0;
z-index: 9999;
background: #000;
color: #fff;
padding: 8px 16px;
}
</style>
```
### Button accessibility
```html
<!-- Icon button needs aria-label -->
<button aria-label="Close menu">
<svg>...</svg>
</button>
```
## Framework-specific patterns
### Next.js (App Router)
```tsx
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
default: 'Site Name',
template: '%s | Site Name'
},
description: 'Site description',
openGraph: {
title: 'Site Name',
description: 'Site description',
url: 'https://example.com',
siteName: 'Site Name',
type: 'website',
},
}
```
### Next.js (Pages Router)
```jsx
import Head from 'next/head';
export default function Page() {
return (
<>
<Head>
<title>Page Title | Brand</title>
<meta name="description" content="Page description" />
<link rel="canonical" href="https://example.com/page" />
</Head>
<main>...</main>
</>
);
}
```
### React (with react-helmet)
```jsx
import { Helmet } from 'react-helmet';
function Page() {
return (
<>
<Helmet>
<title>Page Title | Brand</title>
<meta name="description" content="Page description" />
</Helmet>
<main>...</main>
</>
);
}
```
### Vue.js (with useHead)
```vue
<script setup>
useHead({
title: 'Page Title | Brand',
meta: [
{ name: 'description', content: 'Page description' }
],
link: [
{ rel: 'canonical', href: 'https://example.com/page' }
]
})
</script>
```
### Nuxt.js
```vue
<script setup>
useSeoMeta({
title: 'Page Title | Brand',
description: 'Page description',
ogTitle: 'Page Title',
ogDescription: 'Page description',
ogImage: 'https://example.com/og-image.jpg'
})
</script>
```
## Workflow
### Step 1: Audit
Run the audit script on the target URL:
```bash
node ~/.claude/skills/roier-seo/scripts/audit.js <URL>
```
### Step 2: Identify framework
Check `package.json` dependencies and framework-specific files.
### Step 3: Prioritize fixes
1. **Critical** (red): Fix immediately
2. **Serious** (orange): Fix soon
3. **Moderate** (yellow): Fix when possible
4. **Minor** (gray): Nice to have
### Step 4: Implement
Use the fix patterns above, adapted to the user's framework.
### Step 5: Re-audit
Run the audit again to verify improvements.
## Requirements
- **Node.js 18+**
- **Chrome/Chromium** browser (for Lighthouse)
- Audit script dependencies (installed via npm)
## Resources
- [Google Lighthouse](https://developer.chrome.com/docs/lighthouse/)
- [PageSpeed Insights](https://pagespeed.web.dev/)
- [Schema.org](https://schema.org/)
- [WCAG Guidelines](https://www.w3.org/WAI/standards-guidelines/wcag/)
- [Core Web Vitals](https://web.dev/vitals/)
## Version history
**v1.0.0** (January 2026)
- Initial release
- Lighthouse audit integration
- 50+ SEO fix patterns
- Framework support for Next.js, React, Vue, Nuxt
@@ -0,0 +1,176 @@
#!/usr/bin/env node
/**
* Roier SEO - PageSpeed Insights API Audit
*
* Uses Google's PageSpeed Insights API (no local Chrome needed).
* Works for any publicly accessible URL.
*
* Usage:
* node audit-api.js <URL> [--key=API_KEY]
*
* Note: Without an API key, you're limited to ~5 requests/minute.
* Get a free API key at: https://developers.google.com/speed/docs/insights/v5/get-started
*
* Requirements: Node.js 18+ (uses native fetch)
*/
// Check Node.js version - native fetch requires Node 18+
const nodeVersion = parseInt(process.versions.node.split('.')[0], 10);
if (nodeVersion < 18) {
console.error('Error: This script requires Node.js 18 or higher (for native fetch support).');
console.error(`Current version: ${process.versions.node}`);
process.exit(1);
}
const API_URL = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed';
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
cyan: '\x1b[36m',
bold: '\x1b[1m',
dim: '\x1b[2m'
};
async function runAudit(url, apiKey = null) {
console.error(`${colors.cyan}🔍 Running PageSpeed Insights audit for: ${url}${colors.reset}\n`);
const categories = ['performance', 'accessibility', 'best-practices', 'seo', 'pwa'];
const params = new URLSearchParams({
url,
strategy: 'mobile',
...Object.fromEntries(categories.map(c => [`category`, c]))
});
// Add all categories
const categoryParams = categories.map(c => `category=${c}`).join('&');
let apiUrl = `${API_URL}?url=${encodeURIComponent(url)}&strategy=mobile&${categoryParams}`;
if (apiKey) {
apiUrl += `&key=${apiKey}`;
}
try {
const response = await fetch(apiUrl);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || `API error: ${response.status}`);
}
const data = await response.json();
const lhr = data.lighthouseResult;
// Extract scores
const scores = {
performance: Math.round((lhr.categories.performance?.score || 0) * 100),
accessibility: Math.round((lhr.categories.accessibility?.score || 0) * 100),
bestPractices: Math.round((lhr.categories['best-practices']?.score || 0) * 100),
seo: Math.round((lhr.categories.seo?.score || 0) * 100),
pwa: Math.round((lhr.categories.pwa?.score || 0) * 100)
};
// Extract failed audits
const issues = {
critical: [],
serious: [],
moderate: [],
minor: []
};
for (const [auditId, audit] of Object.entries(lhr.audits)) {
if (audit.score !== null && audit.score < 1) {
const issue = {
id: auditId,
title: audit.title,
description: audit.description,
score: audit.score,
displayValue: audit.displayValue || null
};
if (audit.details?.items) {
issue.items = audit.details.items.slice(0, 3);
}
if (audit.score === 0) {
issues.critical.push(issue);
} else if (audit.score < 0.5) {
issues.serious.push(issue);
} else if (audit.score < 0.9) {
issues.moderate.push(issue);
} else {
issues.minor.push(issue);
}
}
}
// Core Web Vitals
const webVitals = {
FCP: lhr.audits['first-contentful-paint']?.numericValue,
LCP: lhr.audits['largest-contentful-paint']?.numericValue,
TBT: lhr.audits['total-blocking-time']?.numericValue,
CLS: lhr.audits['cumulative-layout-shift']?.numericValue,
SI: lhr.audits['speed-index']?.numericValue
};
const result = {
url,
fetchTime: lhr.fetchTime,
scores,
webVitals,
issues,
summary: {
totalIssues: issues.critical.length + issues.serious.length + issues.moderate.length + issues.minor.length,
critical: issues.critical.length,
serious: issues.serious.length,
moderate: issues.moderate.length,
minor: issues.minor.length
}
};
// Output JSON to stdout
console.log(JSON.stringify(result, null, 2));
return result;
} catch (error) {
console.error(`${colors.red}❌ Audit failed: ${error.message}${colors.reset}`);
process.exit(1);
}
}
// Parse arguments
const args = process.argv.slice(2);
const url = args.find(arg => !arg.startsWith('--'));
const keyFlag = args.find(arg => arg.startsWith('--key='));
const apiKey = keyFlag ? keyFlag.split('=')[1] : null;
if (!url) {
console.log(`
${colors.cyan}Roier SEO - PageSpeed Insights API Audit${colors.reset}
${colors.bold}Usage:${colors.reset}
node audit-api.js <URL> [--key=API_KEY]
${colors.bold}Examples:${colors.reset}
node audit-api.js https://example.com
node audit-api.js https://example.com --key=YOUR_API_KEY
${colors.bold}Note:${colors.reset}
- Only works for publicly accessible URLs
- Without an API key, limited to ~5 requests/minute
- Get a free API key at: https://developers.google.com/speed/docs/insights/v5/get-started
`);
process.exit(0);
}
if (url.includes('localhost') || url.includes('127.0.0.1')) {
console.error(`${colors.yellow}⚠️ Warning: PageSpeed Insights API cannot audit localhost URLs.`);
console.error(` Use 'node audit.js' (with local Chrome) for localhost audits.${colors.reset}\n`);
process.exit(1);
}
runAudit(url, apiKey);
@@ -0,0 +1,268 @@
#!/usr/bin/env node
/**
* Roier SEO - Lighthouse Audit Script
*
* Runs a Lighthouse audit on a given URL and outputs structured results.
*
* Usage:
* node audit.js <URL> [--output=json|summary] [--save=filename]
*
* Examples:
* node audit.js https://example.com
* node audit.js http://localhost:3000 --output=summary
* node audit.js https://example.com --save=audit-results.json
*/
import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';
import fs from 'fs';
import path from 'path';
// ANSI colors for terminal output
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
dim: '\x1b[2m',
bold: '\x1b[1m'
};
function getScoreColor(score) {
if (score >= 90) return colors.green;
if (score >= 50) return colors.yellow;
return colors.red;
}
function formatScore(score) {
const numScore = Math.round(score * 100);
const color = getScoreColor(numScore);
return `${color}${numScore}${colors.reset}`;
}
async function runAudit(url, options = {}) {
const { output = 'json', save } = options;
// Use stderr for status messages to avoid polluting JSON output on stdout
console.error(`${colors.cyan}🔍 Starting Lighthouse audit for: ${url}${colors.reset}\n`);
let chrome;
let exitCode = 0;
try {
// Launch Chrome
chrome = await chromeLauncher.launch({
chromeFlags: ['--headless', '--disable-gpu', '--no-sandbox']
});
// Run Lighthouse
const result = await lighthouse(url, {
port: chrome.port,
output: 'json',
logLevel: 'error',
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo', 'pwa']
});
const { lhr } = result;
// Extract scores (with safe access for optional categories)
const scores = {
performance: Math.round((lhr.categories.performance?.score || 0) * 100),
accessibility: Math.round((lhr.categories.accessibility?.score || 0) * 100),
bestPractices: Math.round((lhr.categories['best-practices']?.score || 0) * 100),
seo: Math.round((lhr.categories.seo?.score || 0) * 100),
pwa: Math.round((lhr.categories.pwa?.score || 0) * 100)
};
// Extract failed audits with details
const issues = {
critical: [],
serious: [],
moderate: [],
minor: []
};
// Process all audits
for (const [auditId, audit] of Object.entries(lhr.audits)) {
if (audit.score !== null && audit.score < 1) {
const issue = {
id: auditId,
title: audit.title,
description: audit.description,
score: audit.score,
displayValue: audit.displayValue || null,
category: getAuditCategory(auditId, lhr.categories)
};
// Add details if available
if (audit.details) {
if (audit.details.items && audit.details.items.length > 0) {
issue.items = audit.details.items.slice(0, 5).map(item => ({
...item,
// Simplify large objects
node: item.node ? { selector: item.node.selector, snippet: item.node.snippet } : undefined
}));
}
}
// Categorize by severity
if (audit.score === 0) {
issues.critical.push(issue);
} else if (audit.score < 0.5) {
issues.serious.push(issue);
} else if (audit.score < 0.9) {
issues.moderate.push(issue);
} else {
issues.minor.push(issue);
}
}
}
// Extract Core Web Vitals
const webVitals = {
FCP: lhr.audits['first-contentful-paint']?.numericValue,
LCP: lhr.audits['largest-contentful-paint']?.numericValue,
TBT: lhr.audits['total-blocking-time']?.numericValue,
CLS: lhr.audits['cumulative-layout-shift']?.numericValue,
SI: lhr.audits['speed-index']?.numericValue
};
// Build result object
const auditResult = {
url,
fetchTime: lhr.fetchTime,
scores,
webVitals,
issues,
summary: {
totalIssues: issues.critical.length + issues.serious.length + issues.moderate.length + issues.minor.length,
critical: issues.critical.length,
serious: issues.serious.length,
moderate: issues.moderate.length,
minor: issues.minor.length
}
};
// Output based on format
if (output === 'summary') {
printSummary(auditResult);
} else {
// JSON output - print to stdout for Claude to parse
console.log(JSON.stringify(auditResult, null, 2));
}
// Save to file if requested
if (save) {
const savePath = path.resolve(save);
fs.writeFileSync(savePath, JSON.stringify(auditResult, null, 2));
console.error(`\n${colors.green}✅ Results saved to: ${savePath}${colors.reset}`);
}
return auditResult;
} catch (error) {
console.error(`${colors.red}❌ Audit failed: ${error.message}${colors.reset}`);
exitCode = 1;
} finally {
if (chrome) {
await chrome.kill();
}
}
if (exitCode !== 0) {
process.exit(exitCode);
}
}
function getAuditCategory(auditId, categories) {
for (const [catId, category] of Object.entries(categories)) {
if (category.auditRefs?.some(ref => ref.id === auditId)) {
return catId;
}
}
return 'other';
}
function printSummary(result) {
console.log(`${colors.bold}═══════════════════════════════════════════════════${colors.reset}`);
console.log(`${colors.bold} LIGHTHOUSE AUDIT RESULTS${colors.reset}`);
console.log(`${colors.bold}═══════════════════════════════════════════════════${colors.reset}`);
console.log(`${colors.dim}URL: ${result.url}${colors.reset}`);
console.log(`${colors.dim}Time: ${result.fetchTime}${colors.reset}\n`);
// Scores
console.log(`${colors.bold}SCORES${colors.reset}`);
console.log(` Performance: ${formatScore(result.scores.performance / 100)}`);
console.log(` Accessibility: ${formatScore(result.scores.accessibility / 100)}`);
console.log(` Best Practices: ${formatScore(result.scores.bestPractices / 100)}`);
console.log(` SEO: ${formatScore(result.scores.seo / 100)}`);
console.log(` PWA: ${formatScore(result.scores.pwa / 100)}`);
// Web Vitals
console.log(`\n${colors.bold}CORE WEB VITALS${colors.reset}`);
if (result.webVitals.FCP) console.log(` FCP: ${(result.webVitals.FCP / 1000).toFixed(2)}s`);
if (result.webVitals.LCP) console.log(` LCP: ${(result.webVitals.LCP / 1000).toFixed(2)}s`);
if (result.webVitals.TBT) console.log(` TBT: ${result.webVitals.TBT.toFixed(0)}ms`);
if (result.webVitals.CLS) console.log(` CLS: ${result.webVitals.CLS.toFixed(3)}`);
// Issues summary
console.log(`\n${colors.bold}ISSUES FOUND${colors.reset}`);
console.log(` ${colors.red}Critical: ${result.summary.critical}${colors.reset}`);
console.log(` ${colors.yellow}Serious: ${result.summary.serious}${colors.reset}`);
console.log(` ${colors.blue}Moderate: ${result.summary.moderate}${colors.reset}`);
console.log(` ${colors.dim}Minor: ${result.summary.minor}${colors.reset}`);
// List critical and serious issues
if (result.issues.critical.length > 0) {
console.log(`\n${colors.red}${colors.bold}CRITICAL ISSUES${colors.reset}`);
result.issues.critical.forEach(issue => {
console.log(`${issue.title}`);
if (issue.displayValue) console.log(` ${colors.dim}${issue.displayValue}${colors.reset}`);
});
}
if (result.issues.serious.length > 0) {
console.log(`\n${colors.yellow}${colors.bold}SERIOUS ISSUES${colors.reset}`);
result.issues.serious.forEach(issue => {
console.log(`${issue.title}`);
if (issue.displayValue) console.log(` ${colors.dim}${issue.displayValue}${colors.reset}`);
});
}
console.log(`\n${colors.bold}═══════════════════════════════════════════════════${colors.reset}`);
}
// Parse command line arguments
const args = process.argv.slice(2);
const url = args.find(arg => !arg.startsWith('--'));
const outputFlag = args.find(arg => arg.startsWith('--output='));
const saveFlag = args.find(arg => arg.startsWith('--save='));
if (!url) {
console.log(`
${colors.cyan}Roier SEO - Lighthouse Audit Tool${colors.reset}
${colors.bold}Usage:${colors.reset}
node audit.js <URL> [options]
${colors.bold}Options:${colors.reset}
--output=json|summary Output format (default: json)
--save=<filename> Save results to file
${colors.bold}Examples:${colors.reset}
node audit.js https://example.com
node audit.js http://localhost:3000 --output=summary
node audit.js https://example.com --save=results.json
`);
process.exit(0);
}
const options = {
output: outputFlag ? outputFlag.split('=')[1] : 'json',
save: saveFlag ? saveFlag.split('=')[1] : null
};
runAudit(url, options);
@@ -0,0 +1,16 @@
{
"name": "roier-seo-scripts",
"version": "1.0.0",
"description": "Roier SEO Skill - Lighthouse audit scripts",
"type": "module",
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"audit": "node audit.js"
},
"dependencies": {
"lighthouse": "^12.0.0",
"chrome-launcher": "^1.1.0"
}
}
@@ -0,0 +1,50 @@
---
name: segment-cdp
description: "Expert patterns for Segment Customer Data Platform including Analytics.js, server-side tracking, tracking plans with Protocols, identity resolution, destinations configuration, and data governance best practices. Use when: segment, analytics.js, customer data platform, cdp, tracking plan."
source: vibeship-spawner-skills (Apache 2.0)
---
# Segment CDP
## Patterns
### Analytics.js Browser Integration
Client-side tracking with Analytics.js. Include track, identify, page,
and group calls. Anonymous ID persists until identify merges with user.
### Server-Side Tracking with Node.js
High-performance server-side tracking using @segment/analytics-node.
Non-blocking with internal batching. Essential for backend events,
webhooks, and sensitive data.
### Tracking Plan Design
Design event schemas using Object + Action naming convention.
Define required properties, types, and validation rules.
Connect to Protocols for enforcement.
## Anti-Patterns
### ❌ Dynamic Event Names
### ❌ Tracking Properties as Events
### ❌ Missing Identify Before Track
## ⚠️ Sharp Edges
| Issue | Severity | Solution |
|-------|----------|----------|
| Issue | medium | See docs |
| Issue | high | See docs |
| Issue | medium | See docs |
| Issue | high | See docs |
| Issue | low | See docs |
| Issue | medium | See docs |
| Issue | medium | See docs |
| Issue | high | See docs |
@@ -0,0 +1,249 @@
---
name: shadcn
description: Manages shadcn/ui components and projects, providing context, documentation, and usage patterns for building modern design systems.
user-invocable: false
risk: safe
source: https://github.com/shadcn-ui/ui/tree/main/skills/shadcn
date_added: "2026-03-07"
---
# shadcn/ui
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
## When to Use
- Use when adding new components from shadcn/ui or community registries.
- Use when styling, composing, or debugging existing shadcn/ui components.
- Use when initializing a new project or switching design system presets.
- Use to retrieve component documentation, examples, and API references.
## Current Project Context
```json
!`npx shadcn@latest info --json 2>/dev/null || echo '{"error": "No shadcn project found. Run shadcn init first."}'`
```
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
## Principles
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
## Critical Rules
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
### Styling & Tailwind → [styling.md](./rules/styling.md)
- **`className` for layout, not styling.** Never override component colors or typography.
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
### Forms & Inputs → [forms.md](./rules/forms.md)
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
- **Option sets (27 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
### Component Structure → [composition.md](./rules/composition.md)
- **Items always inside their Group.** `SelectItem``SelectGroup`. `DropdownMenuItem``DropdownMenuGroup`. `CommandItem``CommandGroup`.
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
- **Callouts use `Alert`.** Don't build custom styled divs.
- **Empty states use `Empty`.** Don't build custom empty state markup.
- **Toast via `sonner`.** Use `toast()` from `sonner`.
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
- **Use `Badge`** instead of custom styled spans.
### Icons → [icons.md](./rules/icons.md)
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
### CLI
- **Never decode or fetch preset codes manually.** Pass them directly to `npx shadcn@latest init --preset <code>`.
## Key Patterns
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
```tsx
// Form layout: FieldGroup + Field, not div + Label.
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" />
</Field>
</FieldGroup>
// Validation: data-invalid on Field, aria-invalid on the control.
<Field data-invalid>
<FieldLabel>Email</FieldLabel>
<Input aria-invalid />
<FieldDescription>Invalid email.</FieldDescription>
</Field>
// Icons in buttons: data-icon, no sizing classes.
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
// Spacing: gap-*, not space-y-*.
<div className="flex flex-col gap-4"> // correct
<div className="space-y-4"> // wrong
// Equal dimensions: size-*, not w-* h-*.
<Avatar className="size-10"> // correct
<Avatar className="w-10 h-10"> // wrong
// Status colors: Badge variants or semantic tokens, not raw colors.
<Badge variant="secondary">+20.1%</Badge> // correct
<span className="text-emerald-600">+20.1%</span> // wrong
```
## Component Selection
| Need | Use |
| -------------------------- | --------------------------------------------------------------------------------------------------- |
| Button/action | `Button` with appropriate variant |
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
| Toggle between 25 options | `ToggleGroup` + `ToggleGroupItem` |
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` |
| Command palette | `Command` inside `Dialog` |
| Charts | `Chart` (wraps Recharts) |
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
| Empty states | `Empty` |
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
## Key Fields
The injected project context contains these key fields:
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
See [cli.md — `info` command](./cli.md) for the full field reference.
## Component Docs, Examples, and Usage
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
```bash
npx shadcn@latest docs button dialog select
```
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
## Workflow
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
3. **Find components**`npx shadcn@latest search`.
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
5. **Install or update**`npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
9. **Switching presets** — Ask the user first: **reinstall**, **merge**, or **skip**?
- **Reinstall**: `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all components.
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
## Updating Components
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
3. Decide per file based on the diff:
- No local changes → safe to overwrite.
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
- User says "just update everything" → use `--overwrite`, but confirm first.
4. **Never use `--overwrite` without the user's explicit approval.**
## Quick Reference
```bash
# Create a new project.
npx shadcn@latest init --name my-app --preset base-nova
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
# Create a monorepo project.
npx shadcn@latest init --name my-app --preset base-nova --monorepo
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
# Initialize existing project.
npx shadcn@latest init --preset base-nova
npx shadcn@latest init --defaults # shortcut: --template=next --preset=base-nova
# Add components.
npx shadcn@latest add button card dialog
npx shadcn@latest add @magicui/shimmer-button
npx shadcn@latest add --all
# Preview changes before adding/updating.
npx shadcn@latest add button --dry-run
npx shadcn@latest add button --diff button.tsx
npx shadcn@latest add @acme/form --view button.tsx
# Search registries.
npx shadcn@latest search @shadcn -q "sidebar"
npx shadcn@latest search @tailark -q "stats"
# Get component docs and example URLs.
npx shadcn@latest docs button dialog select
# View registry item details (for items not yet installed).
npx shadcn@latest view @shadcn/button
```
**Named presets:** `base-nova`, `radix-nova`
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
**Preset codes:** Base62 strings starting with `a` (e.g. `a2r6bw`), from [ui.shadcn.com](https://ui.shadcn.com).
## Detailed References
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
- [cli.md](./cli.md) — Commands, flags, presets, templates
- [customization.md](./customization.md) — Theming, CSS variables, extending components
@@ -0,0 +1,5 @@
interface:
display_name: "shadcn/ui"
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
icon_small: "./assets/shadcn-small.png"
icon_large: "./assets/shadcn.png"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,255 @@
# shadcn CLI Reference
Configuration is read from `components.json`.
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
## Contents
- Commands: init, add (dry-run, smart merge), search, view, docs, info, build
- Templates: next, vite, start, react-router, astro
- Presets: named, code, URL formats and fields
- Switching presets
---
## Commands
### `init` — Initialize or create a project
```bash
npx shadcn@latest init [components...] [options]
```
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
| Flag | Short | Description | Default |
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
| `--yes` | `-y` | Skip confirmation prompt | `true` |
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
| `--force` | `-f` | Force overwrite existing configuration | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--name <name>` | `-n` | Name for new project | — |
| `--silent` | `-s` | Mute output | `false` |
| `--rtl` | | Enable RTL support | — |
| `--reinstall` | | Re-install existing UI components | `false` |
| `--monorepo` | | Scaffold a monorepo project | — |
| `--no-monorepo` | | Skip the monorepo prompt | — |
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
### `add` — Add components
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
```bash
npx shadcn@latest add [components...] [options]
```
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`), URLs, or local paths.
| Flag | Short | Description | Default |
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
| `--yes` | `-y` | Skip confirmation prompt | `false` |
| `--overwrite` | `-o` | Overwrite existing files | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--all` | `-a` | Add all available components | `false` |
| `--path <path>` | `-p` | Target path for the component | — |
| `--silent` | `-s` | Mute output | `false` |
| `--dry-run` | | Preview all changes without writing files | `false` |
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
#### Dry-Run Mode
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
```bash
# Preview all changes.
npx shadcn@latest add button --dry-run
# Show diffs for all files (top 5).
npx shadcn@latest add button --diff
# Show the diff for a specific file.
npx shadcn@latest add button --diff button.tsx
# Show contents for all files (top 5).
npx shadcn@latest add button --view
# Show the full content of a specific file.
npx shadcn@latest add button --view button.tsx
# Works with URLs too.
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
# CSS diffs.
npx shadcn@latest add button --diff globals.css
```
**When to use dry-run:**
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
- Before overwriting existing components — use `--diff` to preview the changes first.
- When the user wants to inspect component source code without installing — use `--view`.
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
#### Smart Merge from Upstream
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
### `search` — Search registries
```bash
npx shadcn@latest search <registries...> [options]
```
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Without `-q`, lists all items.
| Flag | Short | Description | Default |
| ------------------- | ----- | ---------------------- | ------- |
| `--query <query>` | `-q` | Search query | — |
| `--limit <number>` | `-l` | Max items per registry | `100` |
| `--offset <number>` | `-o` | Items to skip | `0` |
| `--cwd <cwd>` | `-c` | Working directory | current |
### `view` — View item details
```bash
npx shadcn@latest view <items...> [options]
```
Displays item info including file contents. Example: `npx shadcn@latest view @shadcn/button`.
### `docs` — Get component documentation URLs
```bash
npx shadcn@latest docs <components...> [options]
```
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
Example output for `npx shadcn@latest docs input button`:
```
base radix
input
docs https://ui.shadcn.com/docs/components/radix/input
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
button
docs https://ui.shadcn.com/docs/components/radix/button
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
```
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
### `diff` — Check for updates
Do not use this command. Use `npx shadcn@latest add --diff` instead.
### `info` — Project information
```bash
npx shadcn@latest info [options]
```
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
| Flag | Short | Description | Default |
| ------------- | ----- | ----------------- | ------- |
| `--cwd <cwd>` | `-c` | Working directory | current |
**Project Info fields:**
| Field | Type | Meaning |
| -------------------- | --------- | ------------------------------------------------------------------ |
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
| `isRSC` | `boolean` | Whether React Server Components are enabled |
| `isTsx` | `boolean` | Whether the project uses TypeScript |
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
| `tailwindCssFile` | `string` | Path to the global CSS file |
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
**Components.json fields:**
| Field | Type | Meaning |
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
| `rsc` | `boolean` | RSC flag from config |
| `tsx` | `boolean` | TypeScript flag |
| `tailwind.config` | `string` | Tailwind config path |
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
| `registries` | `object` | Configured custom registries |
**Links fields:**
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
### `build` — Build a custom registry
```bash
npx shadcn@latest build [registry] [options]
```
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
| Flag | Short | Description | Default |
| ----------------- | ----- | ----------------- | ------------ |
| `--output <path>` | `-o` | Output directory | `./public/r` |
| `--cwd <cwd>` | `-c` | Working directory | current |
---
## Templates
| Value | Framework | Monorepo support |
| -------------- | -------------- | ---------------- |
| `next` | Next.js | Yes |
| `vite` | Vite | Yes |
| `start` | TanStack Start | Yes |
| `react-router` | React Router | Yes |
| `astro` | Astro | Yes |
| `laravel` | Laravel | No |
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
---
## Presets
Three ways to specify a preset via `--preset`:
1. **Named:** `--preset base-nova` or `--preset radix-nova`
2. **Code:** `--preset a2r6bw` (base62 string, starts with lowercase `a`)
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
## Switching Presets
Ask the user first: **reinstall**, **merge**, or **skip** existing components?
- **Re-install** → `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all component files with the new preset styles. Use when the user hasn't customized components.
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
@@ -0,0 +1,202 @@
# Customization & Theming
Components reference semantic CSS variable tokens. Change the variables to change every component.
## Contents
- How it works (CSS variables → Tailwind utilities → components)
- Color variables and OKLCH format
- Dark mode setup
- Changing the theme (presets, CSS variables)
- Adding custom colors (Tailwind v3 and v4)
- Border radius
- Customizing components (variants, className, wrappers)
- Checking for updates
---
## How It Works
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
3. Components use these utilities — changing a variable changes all components that reference it.
---
## Color Variables
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
| Variable | Purpose |
| -------------------------------------------- | -------------------------------- |
| `--background` / `--foreground` | Page background and default text |
| `--card` / `--card-foreground` | Card surfaces |
| `--primary` / `--primary-foreground` | Primary buttons and actions |
| `--secondary` / `--secondary-foreground` | Secondary actions |
| `--muted` / `--muted-foreground` | Muted/disabled states |
| `--accent` / `--accent-foreground` | Hover and accent states |
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
| `--border` | Default border color |
| `--input` | Form input borders |
| `--ring` | Focus ring color |
| `--chart-1` through `--chart-5` | Chart/data visualization |
| `--sidebar-*` | Sidebar-specific colors |
| `--surface` / `--surface-foreground` | Secondary surface |
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (01), chroma (0 = gray), and hue (0360).
---
## Dark Mode
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
```tsx
import { ThemeProvider } from "next-themes"
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
```
---
## Changing the Theme
```bash
# Apply a preset code from ui.shadcn.com.
npx shadcn@latest init --preset a2r6bw --force
# Switch to a named preset.
npx shadcn@latest init --preset radix-nova --force
npx shadcn@latest init --reinstall # update existing components to match
# Use a custom theme URL.
npx shadcn@latest init --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..." --force
```
Or edit CSS variables directly in `globals.css`.
---
## Adding Custom Colors
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
```css
/* 1. Define in the global CSS file. */
:root {
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
}
.dark {
--warning: oklch(0.41 0.11 46);
--warning-foreground: oklch(0.99 0.02 95);
}
```
```css
/* 2a. Register with Tailwind v4 (@theme inline). */
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
```
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
```js
// 2b. Register with Tailwind v3 (tailwind.config.js).
module.exports = {
theme: {
extend: {
colors: {
warning: "oklch(var(--warning) / <alpha-value>)",
"warning-foreground":
"oklch(var(--warning-foreground) / <alpha-value>)",
},
},
},
}
```
```tsx
// 3. Use in components.
<div className="bg-warning text-warning-foreground">Warning</div>
```
---
## Border Radius
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
---
## Customizing Components
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
Prefer these approaches in order:
### 1. Built-in variants
```tsx
<Button variant="outline" size="sm">Click</Button>
```
### 2. Tailwind classes via `className`
```tsx
<Card className="max-w-md mx-auto">...</Card>
```
### 3. Add a new variant
Edit the component source to add a variant via `cva`:
```tsx
// components/ui/button.tsx
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
```
### 4. Wrapper components
Compose shadcn/ui primitives into higher-level components:
```tsx
export function ConfirmDialog({ title, description, onConfirm, children }) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
```
---
## Checking for Updates
```bash
npx shadcn@latest add button --diff
```
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
```bash
npx shadcn@latest add button --dry-run # see all affected files
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
```
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.
@@ -0,0 +1,47 @@
{
"skill_name": "shadcn",
"evals": [
{
"id": 1,
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
"expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
"files": [],
"expectations": [
"Uses FieldGroup and Field components for form layout instead of raw div with space-y",
"Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
"Uses data-invalid on Field and aria-invalid on the input control for validation states",
"Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
"Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
"No manual dark: color overrides"
]
},
{
"id": 2,
"prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.",
"expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.",
"files": [],
"expectations": [
"Includes DialogTitle for accessibility (visible or with sr-only class)",
"Avatar component includes AvatarFallback",
"Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
"No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
"Uses tabler icons (@tabler/icons-react) instead of lucide-react",
"Uses asChild for custom triggers (radix preset)"
]
},
{
"id": 3,
"prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.",
"expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
"files": [],
"expectations": [
"Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)",
"Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
"Uses Badge component for percentage change instead of custom styled spans",
"Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
"Uses gap-* instead of space-y-* or space-x-* for spacing",
"Uses size-* when width and height are equal instead of separate w-* h-*"
]
}
]
}
@@ -0,0 +1,94 @@
# shadcn MCP Server
The CLI includes an MCP server that lets AI assistants search, browse, view, and install components from registries.
---
## Setup
```bash
shadcn mcp # start the MCP server (stdio)
shadcn mcp init # write config for your editor
```
Editor config files:
| Editor | Config file |
|--------|------------|
| Claude Code | `.mcp.json` |
| Cursor | `.cursor/mcp.json` |
| VS Code | `.vscode/mcp.json` |
| OpenCode | `opencode.json` |
| Codex | `~/.codex/config.toml` (manual) |
---
## Tools
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
### `shadcn:get_project_registries`
Returns registry names from `components.json`. Errors if no `components.json` exists.
**Input:** none
### `shadcn:list_items_in_registries`
Lists all items from one or more registries.
**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
### `shadcn:search_items_in_registries`
Fuzzy search across registries.
**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
### `shadcn:view_items_in_registries`
View item details including full file contents.
**Input:** `items` (string[]) — e.g. `["@shadcn/button", "@shadcn/card"]`
### `shadcn:get_item_examples_from_registries`
Find usage examples and demos with source code.
**Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
### `shadcn:get_add_command_for_items`
Returns the CLI install command.
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
### `shadcn:get_audit_checklist`
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
**Input:** none
---
## Configuring Registries
Registries are set in `components.json`. The `@shadcn` registry is always built-in.
```json
{
"registries": {
"@acme": "https://acme.com/r/{name}.json",
"@private": {
"url": "https://private.com/r/{name}.json",
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
}
}
}
```
- Names must start with `@`.
- URLs must contain `{name}`.
- `${VAR}` references are resolved from environment variables.
Community registry index: `https://ui.shadcn.com/r/registries.json`
@@ -0,0 +1,306 @@
# Base vs Radix
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
## Contents
- Composition: asChild vs render
- Button / trigger as non-button element
- Select (items prop, placeholder, positioning, multiple, object values)
- ToggleGroup (type vs multiple)
- Slider (scalar vs array)
- Accordion (type and defaultValue)
---
## Composition: asChild (radix) vs render (base)
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
**Incorrect:**
```tsx
<DialogTrigger>
<div>
<Button>Open</Button>
</div>
</DialogTrigger>
```
**Correct (radix):**
```tsx
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
```
**Correct (base):**
```tsx
<DialogTrigger render={<Button />}>Open</DialogTrigger>
```
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
---
## Button / trigger as non-button element (base only)
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
**Incorrect (base):** missing `nativeButton={false}`.
```tsx
<Button render={<a href="/docs" />}>Read the docs</Button>
```
**Correct (base):**
```tsx
<Button render={<a href="/docs" />} nativeButton={false}>
Read the docs
</Button>
```
**Correct (radix):**
```tsx
<Button asChild>
<a href="/docs">Read the docs</a>
</Button>
```
Same for triggers whose `render` is not a `Button`:
```tsx
// base.
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
Pick date
</PopoverTrigger>
```
---
## Select
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
**Incorrect (base):**
```tsx
<Select>
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
</Select>
```
**Correct (base):**
```tsx
const items = [
{ label: "Select a fruit", value: null },
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
]
<Select items={items}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
```
**Correct (radix):**
```tsx
<Select>
<SelectTrigger>
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
```
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
```tsx
// base.
<SelectContent alignItemWithTrigger={false} side="bottom">
// radix.
<SelectContent position="popper">
```
---
## Select — multiple selection and object values (base only)
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
**Correct (base — multiple selection):**
```tsx
<Select items={items} multiple defaultValue={[]}>
<SelectTrigger>
<SelectValue>
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
</SelectValue>
</SelectTrigger>
...
</Select>
```
**Correct (base — object values):**
```tsx
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
<SelectTrigger>
<SelectValue>{(value) => value.name}</SelectValue>
</SelectTrigger>
...
</Select>
```
---
## ToggleGroup
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
**Incorrect (base):**
```tsx
<ToggleGroup type="single" defaultValue="daily">
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
</ToggleGroup>
```
**Correct (base):**
```tsx
// Single (no prop needed), defaultValue is always an array.
<ToggleGroup defaultValue={["daily"]} spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup multiple>
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
```
**Correct (radix):**
```tsx
// Single, defaultValue is a string.
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup type="multiple">
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
```
**Controlled single value:**
```tsx
// base — wrap/unwrap arrays.
const [value, setValue] = React.useState("normal")
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
// radix — plain string.
const [value, setValue] = React.useState("normal")
<ToggleGroup type="single" value={value} onValueChange={setValue}>
```
---
## Slider
Base accepts a plain number for a single thumb. Radix always requires an array.
**Incorrect (base):**
```tsx
<Slider defaultValue={[50]} max={100} step={1} />
```
**Correct (base):**
```tsx
<Slider defaultValue={50} max={100} step={1} />
```
**Correct (radix):**
```tsx
<Slider defaultValue={[50]} max={100} step={1} />
```
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
```tsx
// base.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
// radix.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={setValue} />
```
---
## Accordion
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
**Incorrect (base):**
```tsx
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
```
**Correct (base):**
```tsx
<Accordion defaultValue={["item-1"]}>
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
// Multi-select.
<Accordion multiple defaultValue={["item-1", "item-2"]}>
<AccordionItem value="item-1">...</AccordionItem>
<AccordionItem value="item-2">...</AccordionItem>
</Accordion>
```
**Correct (radix):**
```tsx
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
```
@@ -0,0 +1,195 @@
# Component Composition
## Contents
- Items always inside their Group component
- Callouts use Alert
- Empty states use Empty component
- Toast notifications use sonner
- Choosing between overlay components
- Dialog, Sheet, and Drawer always need a Title
- Card structure
- Button has no isPending or isLoading prop
- TabsTrigger must be inside TabsList
- Avatar always needs AvatarFallback
- Use Separator instead of raw hr or border divs
- Use Skeleton for loading placeholders
- Use Badge instead of custom styled spans
---
## Items always inside their Group component
Never render items directly inside the content container.
**Incorrect:**
```tsx
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectContent>
```
**Correct:**
```tsx
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
```
This applies to all group-based components:
| Item | Group |
|------|-------|
| `SelectItem`, `SelectLabel` | `SelectGroup` |
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
| `MenubarItem` | `MenubarGroup` |
| `ContextMenuItem` | `ContextMenuGroup` |
| `CommandItem` | `CommandGroup` |
---
## Callouts use Alert
```tsx
<Alert>
<AlertTitle>Warning</AlertTitle>
<AlertDescription>Something needs attention.</AlertDescription>
</Alert>
```
---
## Empty states use Empty component
```tsx
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
<EmptyTitle>No projects yet</EmptyTitle>
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>Create Project</Button>
</EmptyContent>
</Empty>
```
---
## Toast notifications use sonner
```tsx
import { toast } from "sonner"
toast.success("Changes saved.")
toast.error("Something went wrong.")
toast("File deleted.", {
action: { label: "Undo", onClick: () => undoDelete() },
})
```
---
## Choosing between overlay components
| Use case | Component |
|----------|-----------|
| Focused task that requires input | `Dialog` |
| Destructive action confirmation | `AlertDialog` |
| Side panel with details or filters | `Sheet` |
| Mobile-first bottom panel | `Drawer` |
| Quick info on hover | `HoverCard` |
| Small contextual content on click | `Popover` |
---
## Dialog, Sheet, and Drawer always need a Title
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
```tsx
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Update your profile.</DialogDescription>
</DialogHeader>
...
</DialogContent>
```
---
## Card structure
Use full composition — don't dump everything into `CardContent`:
```tsx
<Card>
<CardHeader>
<CardTitle>Team Members</CardTitle>
<CardDescription>Manage your team.</CardDescription>
</CardHeader>
<CardContent>...</CardContent>
<CardFooter>
<Button>Invite</Button>
</CardFooter>
</Card>
```
---
## Button has no isPending or isLoading prop
Compose with `Spinner` + `data-icon` + `disabled`:
```tsx
<Button disabled>
<Spinner data-icon="inline-start" />
Saving...
</Button>
```
---
## TabsTrigger must be inside TabsList
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
```tsx
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">...</TabsContent>
</Tabs>
```
---
## Avatar always needs AvatarFallback
Always include `AvatarFallback` for when the image fails to load:
```tsx
<Avatar>
<AvatarImage src="/avatar.png" alt="User" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
```
---
## Use existing components instead of custom markup
| Instead of | Use |
|---|---|
| `<hr>` or `<div className="border-t">` | `<Separator />` |
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |
@@ -0,0 +1,192 @@
# Forms & Inputs
## Contents
- Forms use FieldGroup + Field
- InputGroup requires InputGroupInput/InputGroupTextarea
- Buttons inside inputs use InputGroup + InputGroupAddon
- Option sets (27 choices) use ToggleGroup
- FieldSet + FieldLegend for grouping related fields
- Field validation and disabled states
---
## Forms use FieldGroup + Field
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
```tsx
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" />
</Field>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input id="password" type="password" />
</Field>
</FieldGroup>
```
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
**Choosing form controls:**
- Simple text input → `Input`
- Dropdown with predefined options → `Select`
- Searchable dropdown → `Combobox`
- Native HTML select (no JS) → `native-select`
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
- Single choice from few options → `RadioGroup`
- Toggle between 25 options → `ToggleGroup` + `ToggleGroupItem`
- OTP/verification code → `InputOTP`
- Multi-line text → `Textarea`
---
## InputGroup requires InputGroupInput/InputGroupTextarea
Never use raw `Input` or `Textarea` inside an `InputGroup`.
**Incorrect:**
```tsx
<InputGroup>
<Input placeholder="Search..." />
</InputGroup>
```
**Correct:**
```tsx
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
</InputGroup>
```
---
## Buttons inside inputs use InputGroup + InputGroupAddon
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
**Incorrect:**
```tsx
<div className="relative">
<Input placeholder="Search..." className="pr-10" />
<Button className="absolute right-0 top-0" size="icon">
<SearchIcon />
</Button>
</div>
```
**Correct:**
```tsx
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
<InputGroupAddon>
<Button size="icon">
<SearchIcon data-icon="inline-start" />
</Button>
</InputGroupAddon>
</InputGroup>
```
---
## Option sets (27 choices) use ToggleGroup
Don't manually loop `Button` components with active state.
**Incorrect:**
```tsx
const [selected, setSelected] = useState("daily")
<div className="flex gap-2">
{["daily", "weekly", "monthly"].map((option) => (
<Button
key={option}
variant={selected === option ? "default" : "outline"}
onClick={() => setSelected(option)}
>
{option}
</Button>
))}
</div>
```
**Correct:**
```tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
<ToggleGroup spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
</ToggleGroup>
```
Combine with `Field` for labelled toggle groups:
```tsx
<Field orientation="horizontal">
<FieldTitle id="theme-label">Theme</FieldTitle>
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
<ToggleGroupItem value="light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
<ToggleGroupItem value="system">System</ToggleGroupItem>
</ToggleGroup>
</Field>
```
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
---
## FieldSet + FieldLegend for grouping related fields
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
```tsx
<FieldSet>
<FieldLegend variant="label">Preferences</FieldLegend>
<FieldDescription>Select all that apply.</FieldDescription>
<FieldGroup className="gap-3">
<Field orientation="horizontal">
<Checkbox id="dark" />
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
</Field>
</FieldGroup>
</FieldSet>
```
---
## Field validation and disabled states
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
```tsx
// Invalid.
<Field data-invalid>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" aria-invalid />
<FieldDescription>Invalid email address.</FieldDescription>
</Field>
// Disabled.
<Field data-disabled>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" disabled />
</Field>
```
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.
@@ -0,0 +1,101 @@
# Icons
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide``lucide-react`, `tabler``@tabler/icons-react`, etc. Never assume `lucide-react`.
---
## Icons in Button use data-icon attribute
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
**Incorrect:**
```tsx
<Button>
<SearchIcon className="mr-2 size-4" />
Search
</Button>
```
**Correct:**
```tsx
<Button>
<SearchIcon data-icon="inline-start"/>
Search
</Button>
<Button>
Next
<ArrowRightIcon data-icon="inline-end"/>
</Button>
```
---
## No sizing classes on icons inside components
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
**Incorrect:**
```tsx
<Button>
<SearchIcon className="size-4" data-icon="inline-start" />
Search
</Button>
<DropdownMenuItem>
<SettingsIcon className="mr-2 size-4" />
Settings
</DropdownMenuItem>
```
**Correct:**
```tsx
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
<DropdownMenuItem>
<SettingsIcon />
Settings
</DropdownMenuItem>
```
---
## Pass icons as component objects, not string keys
Use `icon={CheckIcon}`, not a string key to a lookup map.
**Incorrect:**
```tsx
const iconMap = {
check: CheckIcon,
alert: AlertIcon,
}
function StatusBadge({ icon }: { icon: string }) {
const Icon = iconMap[icon]
return <Icon />
}
<StatusBadge icon="check" />
```
**Correct:**
```tsx
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
import { CheckIcon } from "lucide-react"
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
return <Icon />
}
<StatusBadge icon={CheckIcon} />
```
@@ -0,0 +1,162 @@
# Styling & Customization
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
## Contents
- Semantic colors
- Built-in variants first
- className for layout only
- No space-x-* / space-y-*
- Prefer size-* over w-* h-* when equal
- Prefer truncate shorthand
- No manual dark: color overrides
- Use cn() for conditional classes
- No manual z-index on overlay components
---
## Semantic colors
**Incorrect:**
```tsx
<div className="bg-blue-500 text-white">
<p className="text-gray-600">Secondary text</p>
</div>
```
**Correct:**
```tsx
<div className="bg-primary text-primary-foreground">
<p className="text-muted-foreground">Secondary text</p>
</div>
```
---
## No raw color values for status/state indicators
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
**Incorrect:**
```tsx
<span className="text-emerald-600">+20.1%</span>
<span className="text-green-500">Active</span>
<span className="text-red-600">-3.2%</span>
```
**Correct:**
```tsx
<Badge variant="secondary">+20.1%</Badge>
<Badge>Active</Badge>
<span className="text-destructive">-3.2%</span>
```
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
---
## Built-in variants first
**Incorrect:**
```tsx
<Button className="border border-input bg-transparent hover:bg-accent">
Click me
</Button>
```
**Correct:**
```tsx
<Button variant="outline">Click me</Button>
```
---
## className for layout only
Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
**Incorrect:**
```tsx
<Card className="bg-blue-100 text-blue-900 font-bold">
<CardContent>Dashboard</CardContent>
</Card>
```
**Correct:**
```tsx
<Card className="max-w-md mx-auto">
<CardContent>Dashboard</CardContent>
</Card>
```
To customize a component's appearance, prefer these approaches in order:
1. **Built-in variants**`variant="outline"`, `variant="destructive"`, etc.
2. **Semantic color tokens**`bg-primary`, `text-muted-foreground`.
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
---
## No space-x-* / space-y-*
Use `gap-*` instead. `space-y-4``flex flex-col gap-4`. `space-x-2``flex gap-2`.
```tsx
<div className="flex flex-col gap-4">
<Input />
<Input />
<Button>Submit</Button>
</div>
```
---
## Prefer size-* over w-* h-* when equal
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
---
## Prefer truncate shorthand
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
---
## No manual dark: color overrides
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
---
## Use cn() for conditional classes
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
**Incorrect:**
```tsx
<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
```
**Correct:**
```tsx
import { cn } from "@/lib/utils"
<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
```
---
## No manual z-index on overlay components
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.
@@ -0,0 +1,42 @@
---
name: shopify-apps
description: "Expert patterns for Shopify app development including Remix/React Router apps, embedded apps with App Bridge, webhook handling, GraphQL Admin API, Polaris components, billing, and app extensions. Use when: shopify app, shopify, embedded app, polaris, app bridge."
source: vibeship-spawner-skills (Apache 2.0)
---
# Shopify Apps
## Patterns
### React Router App Setup
Modern Shopify app template with React Router
### Embedded App with App Bridge
Render app embedded in Shopify Admin
### Webhook Handling
Secure webhook processing with HMAC verification
## Anti-Patterns
### ❌ REST API for New Apps
### ❌ Webhook Processing Before Response
### ❌ Polling Instead of Webhooks
## ⚠️ Sharp Edges
| Issue | Severity | Solution |
|-------|----------|----------|
| Issue | high | ## Respond immediately, process asynchronously |
| Issue | high | ## Check rate limit headers |
| Issue | high | ## Request protected customer data access |
| Issue | medium | ## Use TOML only (recommended) |
| Issue | medium | ## Handle both URL formats |
| Issue | high | ## Use GraphQL for all new code |
| Issue | high | ## Use latest App Bridge via script tag |
| Issue | high | ## Implement all GDPR handlers |
@@ -0,0 +1,60 @@
# Shopify Development Skill
Comprehensive skill for building on Shopify platform: apps, extensions, themes, and API integrations.
## Features
- **App Development** - OAuth authentication, GraphQL Admin API, webhooks, billing integration
- **UI Extensions** - Checkout, Admin, POS customizations with Polaris components
- **Theme Development** - Liquid templating, sections, snippets
- **Shopify Functions** - Custom discounts, payment, delivery rules
## Structure
```
shopify-development/
├── SKILL.md # Main skill file (AI-optimized)
├── README.md # This file
├── references/
│ ├── app-development.md # OAuth, API, webhooks, billing
│ ├── extensions.md # UI extensions, Functions
│ └── themes.md # Liquid, theme architecture
└── scripts/
├── shopify_init.py # Interactive project scaffolding
├── shopify_graphql.py # GraphQL utilities & templates
└── tests/ # Unit tests
```
## Validated GraphQL
All GraphQL queries and mutations in this skill have been validated against Shopify Admin API 2026-01 schema using the official Shopify MCP.
## Quick Start
```bash
# Install Shopify CLI
npm install -g @shopify/cli@latest
# Create new app
shopify app init
# Start development
shopify app dev
```
## Usage Triggers
This skill activates when the user mentions:
- "shopify app", "shopify extension", "shopify theme"
- "checkout extension", "admin extension", "POS extension"
- "liquid template", "polaris", "shopify graphql"
- "shopify webhook", "shopify billing", "metafields"
## API Version
Current: **2026-01** (Quarterly releases with 12-month support)
## License
MIT
@@ -0,0 +1,366 @@
---
name: shopify-development
description: |
Build Shopify apps, extensions, themes using GraphQL Admin API, Shopify CLI, Polaris UI, and Liquid.
TRIGGER: "shopify", "shopify app", "checkout extension", "admin extension", "POS extension",
"shopify theme", "liquid template", "polaris", "shopify graphql", "shopify webhook",
"shopify billing", "app subscription", "metafields", "shopify functions"
---
# Shopify Development Skill
Use this skill when the user asks about:
- Building Shopify apps or extensions
- Creating checkout/admin/POS UI customizations
- Developing themes with Liquid templating
- Integrating with Shopify GraphQL or REST APIs
- Implementing webhooks or billing
- Working with metafields or Shopify Functions
---
## ROUTING: What to Build
**IF user wants to integrate external services OR build merchant tools OR charge for features:**
→ Build an **App** (see `references/app-development.md`)
**IF user wants to customize checkout OR add admin UI OR create POS actions OR implement discount rules:**
→ Build an **Extension** (see `references/extensions.md`)
**IF user wants to customize storefront design OR modify product/collection pages:**
→ Build a **Theme** (see `references/themes.md`)
**IF user needs both backend logic AND storefront UI:**
→ Build **App + Theme Extension** combination
---
## Shopify CLI Commands
Install CLI:
```bash
npm install -g @shopify/cli@latest
```
Create and run app:
```bash
shopify app init # Create new app
shopify app dev # Start dev server with tunnel
shopify app deploy # Build and upload to Shopify
```
Generate extension:
```bash
shopify app generate extension --type checkout_ui_extension
shopify app generate extension --type admin_action
shopify app generate extension --type admin_block
shopify app generate extension --type pos_ui_extension
shopify app generate extension --type function
```
Theme development:
```bash
shopify theme init # Create new theme
shopify theme dev # Start local preview at localhost:9292
shopify theme pull --live # Pull live theme
shopify theme push --development # Push to dev theme
```
---
## Access Scopes
Configure in `shopify.app.toml`:
```toml
[access_scopes]
scopes = "read_products,write_products,read_orders,write_orders,read_customers"
```
Common scopes:
- `read_products`, `write_products` - Product catalog access
- `read_orders`, `write_orders` - Order management
- `read_customers`, `write_customers` - Customer data
- `read_inventory`, `write_inventory` - Stock levels
- `read_fulfillments`, `write_fulfillments` - Order fulfillment
---
## GraphQL Patterns (Validated against API 2026-01)
### Query Products
```graphql
query GetProducts($first: Int!, $query: String) {
products(first: $first, query: $query) {
edges {
node {
id
title
handle
status
variants(first: 5) {
edges {
node {
id
price
inventoryQuantity
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
```
### Query Orders
```graphql
query GetOrders($first: Int!) {
orders(first: $first) {
edges {
node {
id
name
createdAt
displayFinancialStatus
totalPriceSet {
shopMoney {
amount
currencyCode
}
}
}
}
}
}
```
### Set Metafields
```graphql
mutation SetMetafields($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields {
id
namespace
key
value
}
userErrors {
field
message
}
}
}
```
Variables example:
```json
{
"metafields": [
{
"ownerId": "gid://shopify/Product/123",
"namespace": "custom",
"key": "care_instructions",
"value": "Handle with care",
"type": "single_line_text_field"
}
]
}
```
---
## Checkout Extension Example
```tsx
import {
reactExtension,
BlockStack,
TextField,
Checkbox,
useApplyAttributeChange,
} from "@shopify/ui-extensions-react/checkout";
export default reactExtension("purchase.checkout.block.render", () => (
<GiftMessage />
));
function GiftMessage() {
const [isGift, setIsGift] = useState(false);
const [message, setMessage] = useState("");
const applyAttributeChange = useApplyAttributeChange();
useEffect(() => {
if (isGift && message) {
applyAttributeChange({
type: "updateAttribute",
key: "gift_message",
value: message,
});
}
}, [isGift, message]);
return (
<BlockStack spacing="loose">
<Checkbox checked={isGift} onChange={setIsGift}>
This is a gift
</Checkbox>
{isGift && (
<TextField
label="Gift Message"
value={message}
onChange={setMessage}
multiline={3}
/>
)}
</BlockStack>
);
}
```
---
## Liquid Template Example
```liquid
{% comment %} Product Card Snippet {% endcomment %}
<div class="product-card">
<a href="{{ product.url }}">
{% if product.featured_image %}
<img
src="{{ product.featured_image | img_url: 'medium' }}"
alt="{{ product.title | escape }}"
loading="lazy"
>
{% endif %}
<h3>{{ product.title }}</h3>
<p class="price">{{ product.price | money }}</p>
{% if product.compare_at_price > product.price %}
<p class="sale-badge">Sale</p>
{% endif %}
</a>
</div>
```
---
## Webhook Configuration
In `shopify.app.toml`:
```toml
[webhooks]
api_version = "2026-01"
[[webhooks.subscriptions]]
topics = ["orders/create", "orders/updated"]
uri = "/webhooks/orders"
[[webhooks.subscriptions]]
topics = ["products/update"]
uri = "/webhooks/products"
# GDPR mandatory webhooks (required for app approval)
[webhooks.privacy_compliance]
customer_data_request_url = "/webhooks/gdpr/data-request"
customer_deletion_url = "/webhooks/gdpr/customer-deletion"
shop_deletion_url = "/webhooks/gdpr/shop-deletion"
```
---
## Best Practices
### API Usage
- Use GraphQL over REST for new development
- Request only fields you need (reduces query cost)
- Implement cursor-based pagination with `pageInfo.endCursor`
- Use bulk operations for processing more than 250 items
- Handle rate limits with exponential backoff
### Security
- Store API credentials in environment variables
- Always verify webhook HMAC signatures before processing
- Validate OAuth state parameter to prevent CSRF
- Request minimal access scopes
- Use session tokens for embedded apps
### Performance
- Cache API responses when data doesn't change frequently
- Use lazy loading in extensions
- Optimize images in themes using `img_url` filter
- Monitor GraphQL query costs via response headers
---
## Troubleshooting
**IF you see rate limit errors:**
→ Implement exponential backoff retry logic
→ Switch to bulk operations for large datasets
→ Monitor `X-Shopify-Shop-Api-Call-Limit` header
**IF authentication fails:**
→ Verify the access token is still valid
→ Check that all required scopes were granted
→ Ensure OAuth flow completed successfully
**IF extension is not appearing:**
→ Verify the extension target is correct
→ Check that extension is published via `shopify app deploy`
→ Confirm the app is installed on the test store
**IF webhook is not receiving events:**
→ Verify the webhook URL is publicly accessible
→ Check HMAC signature validation logic
→ Review webhook logs in Partner Dashboard
**IF GraphQL query fails:**
→ Validate query against schema (use GraphiQL explorer)
→ Check for deprecated fields in error message
→ Verify you have required access scopes
---
## Reference Files
For detailed implementation guides, read these files:
- `references/app-development.md` - OAuth authentication flow, GraphQL mutations for products/orders/billing, webhook handlers, billing API integration
- `references/extensions.md` - Checkout UI components, Admin UI extensions, POS extensions, Shopify Functions for discounts/payment/delivery
- `references/themes.md` - Liquid syntax reference, theme directory structure, sections and snippets, common patterns
---
## Scripts
- `scripts/shopify_init.py` - Interactive project scaffolding. Run: `python scripts/shopify_init.py`
- `scripts/shopify_graphql.py` - GraphQL utilities with query templates, pagination, rate limiting. Import: `from shopify_graphql import ShopifyGraphQL`
---
## Official Documentation Links
- Shopify Developer Docs: https://shopify.dev/docs
- GraphQL Admin API Reference: https://shopify.dev/docs/api/admin-graphql
- Shopify CLI Reference: https://shopify.dev/docs/api/shopify-cli
- Polaris Design System: https://polaris.shopify.com
API Version: 2026-01 (quarterly releases, 12-month deprecation window)
@@ -0,0 +1,578 @@
# App Development Reference
Guide for building Shopify apps with OAuth, GraphQL/REST APIs, webhooks, and billing.
## OAuth Authentication
### OAuth 2.0 Flow
**1. Redirect to Authorization URL:**
```
https://{shop}.myshopify.com/admin/oauth/authorize?
client_id={api_key}&
scope={scopes}&
redirect_uri={redirect_uri}&
state={nonce}
```
**2. Handle Callback:**
```javascript
app.get("/auth/callback", async (req, res) => {
const { code, shop, state } = req.query;
// Verify state to prevent CSRF
if (state !== storedState) {
return res.status(403).send("Invalid state");
}
// Exchange code for access token
const accessToken = await exchangeCodeForToken(shop, code);
// Store token securely
await storeAccessToken(shop, accessToken);
res.redirect(`https://${shop}/admin/apps/${appHandle}`);
});
```
**3. Exchange Code for Token:**
```javascript
async function exchangeCodeForToken(shop, code) {
const response = await fetch(`https://${shop}/admin/oauth/access_token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: process.env.SHOPIFY_API_KEY,
client_secret: process.env.SHOPIFY_API_SECRET,
code,
}),
});
const { access_token } = await response.json();
return access_token;
}
```
### Access Scopes
**Common Scopes:**
- `read_products`, `write_products` - Product catalog
- `read_orders`, `write_orders` - Order management
- `read_customers`, `write_customers` - Customer data
- `read_inventory`, `write_inventory` - Stock levels
- `read_fulfillments`, `write_fulfillments` - Order fulfillment
- `read_shipping`, `write_shipping` - Shipping rates
- `read_analytics` - Store analytics
- `read_checkouts`, `write_checkouts` - Checkout data
Full list: https://shopify.dev/api/usage/access-scopes
### Session Tokens (Embedded Apps)
For embedded apps using App Bridge:
```javascript
import { getSessionToken } from '@shopify/app-bridge/utilities';
async function authenticatedFetch(url, options = {}) {
const app = createApp({ ... });
const token = await getSessionToken(app);
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
});
}
```
## GraphQL Admin API
### Making Requests
```javascript
async function graphqlRequest(shop, accessToken, query, variables = {}) {
const response = await fetch(
`https://${shop}/admin/api/2026-01/graphql.json`,
{
method: "POST",
headers: {
"X-Shopify-Access-Token": accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables }),
},
);
const data = await response.json();
if (data.errors) {
throw new Error(`GraphQL errors: ${JSON.stringify(data.errors)}`);
}
return data.data;
}
```
### Product Operations
**Create Product:**
```graphql
mutation CreateProduct($input: ProductInput!) {
productCreate(input: $input) {
product {
id
title
handle
}
userErrors {
field
message
}
}
}
```
Variables:
```json
{
"input": {
"title": "New Product",
"productType": "Apparel",
"vendor": "Brand",
"status": "ACTIVE",
"variants": [
{ "price": "29.99", "sku": "SKU-001", "inventoryQuantity": 100 }
]
}
}
```
**Update Product:**
```graphql
mutation UpdateProduct($input: ProductInput!) {
productUpdate(input: $input) {
product {
id
title
}
userErrors {
field
message
}
}
}
```
**Query Products:**
```graphql
query GetProducts($first: Int!, $query: String) {
products(first: $first, query: $query) {
edges {
node {
id
title
status
variants(first: 5) {
edges {
node {
id
price
inventoryQuantity
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
```
### Order Operations
**Query Orders:**
```graphql
query GetOrders($first: Int!) {
orders(first: $first) {
edges {
node {
id
name
createdAt
displayFinancialStatus
totalPriceSet {
shopMoney {
amount
currencyCode
}
}
customer {
email
firstName
lastName
}
}
}
}
}
```
**Fulfill Order:**
```graphql
mutation FulfillOrder($fulfillment: FulfillmentInput!) {
fulfillmentCreate(fulfillment: $fulfillment) {
fulfillment {
id
status
trackingInfo {
number
url
}
}
userErrors {
field
message
}
}
}
```
## Webhooks
### Configuration
In `shopify.app.toml`:
```toml
[webhooks]
api_version = "2025-01"
[[webhooks.subscriptions]]
topics = ["orders/create"]
uri = "/webhooks/orders/create"
[[webhooks.subscriptions]]
topics = ["products/update"]
uri = "/webhooks/products/update"
[[webhooks.subscriptions]]
topics = ["app/uninstalled"]
uri = "/webhooks/app/uninstalled"
# GDPR mandatory webhooks
[webhooks.privacy_compliance]
customer_data_request_url = "/webhooks/gdpr/data-request"
customer_deletion_url = "/webhooks/gdpr/customer-deletion"
shop_deletion_url = "/webhooks/gdpr/shop-deletion"
```
### Webhook Handler
```javascript
import crypto from "crypto";
function verifyWebhook(req) {
const hmac = req.headers["x-shopify-hmac-sha256"];
const body = req.rawBody; // Raw body buffer
const hash = crypto
.createHmac("sha256", process.env.SHOPIFY_API_SECRET)
.update(body, "utf8")
.digest("base64");
return hmac === hash;
}
app.post("/webhooks/orders/create", async (req, res) => {
if (!verifyWebhook(req)) {
return res.status(401).send("Unauthorized");
}
const order = req.body;
console.log("New order:", order.id, order.name);
// Process order...
res.status(200).send("OK");
});
```
### Common Webhook Topics
**Orders:**
- `orders/create`, `orders/updated`, `orders/delete`
- `orders/paid`, `orders/cancelled`, `orders/fulfilled`
**Products:**
- `products/create`, `products/update`, `products/delete`
**Customers:**
- `customers/create`, `customers/update`, `customers/delete`
**Inventory:**
- `inventory_levels/update`
**App:**
- `app/uninstalled` (critical for cleanup)
## Billing Integration
### App Charges
**One-time Charge:**
```graphql
mutation CreateCharge($input: AppPurchaseOneTimeInput!) {
appPurchaseOneTimeCreate(input: $input) {
appPurchaseOneTime {
id
name
price {
amount
}
status
confirmationUrl
}
userErrors {
field
message
}
}
}
```
Variables:
```json
{
"input": {
"name": "Premium Feature",
"price": { "amount": 49.99, "currencyCode": "USD" },
"returnUrl": "https://your-app.com/billing/callback"
}
}
```
**Recurring Charge (Subscription):**
```graphql
mutation CreateSubscription(
$name: String!
$returnUrl: URL!
$lineItems: [AppSubscriptionLineItemInput!]!
$trialDays: Int
) {
appSubscriptionCreate(
name: $name
returnUrl: $returnUrl
lineItems: $lineItems
trialDays: $trialDays
) {
appSubscription {
id
name
status
}
confirmationUrl
userErrors {
field
message
}
}
}
```
Variables:
```json
{
"name": "Monthly Subscription",
"returnUrl": "https://your-app.com/billing/callback",
"trialDays": 7,
"lineItems": [
{
"plan": {
"appRecurringPricingDetails": {
"price": { "amount": 29.99, "currencyCode": "USD" },
"interval": "EVERY_30_DAYS"
}
}
}
]
}
```
**Usage-based Billing:**
```graphql
mutation CreateUsageCharge(
$subscriptionLineItemId: ID!
$price: MoneyInput!
$description: String!
) {
appUsageRecordCreate(
subscriptionLineItemId: $subscriptionLineItemId
price: $price
description: $description
) {
appUsageRecord {
id
price {
amount
currencyCode
}
description
}
userErrors {
field
message
}
}
}
```
Variables:
```json
{
"subscriptionLineItemId": "gid://shopify/AppSubscriptionLineItem/123",
"price": { "amount": "5.00", "currencyCode": "USD" },
"description": "100 API calls used"
}
```
## Metafields
### Create/Update Metafields
```graphql
mutation SetMetafields($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields {
id
namespace
key
value
}
userErrors {
field
message
}
}
}
```
Variables:
```json
{
"metafields": [
{
"ownerId": "gid://shopify/Product/123",
"namespace": "custom",
"key": "instructions",
"value": "Handle with care",
"type": "single_line_text_field"
}
]
}
```
**Metafield Types:**
- `single_line_text_field`, `multi_line_text_field`
- `number_integer`, `number_decimal`
- `date`, `date_time`
- `url`, `json`
- `file_reference`, `product_reference`
## Rate Limiting
### GraphQL Cost-Based Limits
**Limits:**
- Available points: 2000
- Restore rate: 100 points/second
- Max query cost: 2000
**Check Cost:**
```javascript
const response = await graphqlRequest(shop, token, query);
const cost = response.extensions?.cost;
console.log(
`Cost: ${cost.actualQueryCost}/${cost.throttleStatus.maximumAvailable}`,
);
```
**Handle Throttling:**
```javascript
async function graphqlWithRetry(shop, token, query, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await graphqlRequest(shop, token, query);
} catch (error) {
if (error.message.includes("Throttled") && i < retries - 1) {
await sleep(Math.pow(2, i) * 1000); // Exponential backoff
continue;
}
throw error;
}
}
}
```
## Best Practices
**Security:**
- Store credentials in environment variables
- Verify webhook HMAC signatures
- Validate OAuth state parameter
- Use HTTPS for all endpoints
- Implement rate limiting on your endpoints
**Performance:**
- Cache access tokens securely
- Use bulk operations for large datasets
- Implement pagination for queries
- Monitor GraphQL query costs
**Reliability:**
- Implement exponential backoff for retries
- Handle webhook delivery failures
- Log errors for debugging
- Monitor app health metrics
**Compliance:**
- Implement GDPR webhooks (mandatory)
- Handle customer data deletion requests
- Provide data export functionality
- Follow data retention policies
@@ -0,0 +1,555 @@
# Extensions Reference
Guide for building UI extensions and Shopify Functions.
## Checkout UI Extensions
Customize checkout and thank-you pages with native-rendered components.
### Extension Points
**Block Targets (Merchant-Configurable):**
- `purchase.checkout.block.render` - Main checkout
- `purchase.thank-you.block.render` - Thank you page
**Static Targets (Fixed Position):**
- `purchase.checkout.header.render-after`
- `purchase.checkout.contact.render-before`
- `purchase.checkout.shipping-option-list.render-after`
- `purchase.checkout.payment-method-list.render-after`
- `purchase.checkout.footer.render-before`
### Setup
```bash
shopify app generate extension --type checkout_ui_extension
```
Configuration (`shopify.extension.toml`):
```toml
api_version = "2026-01"
name = "gift-message"
type = "ui_extension"
[[extensions.targeting]]
target = "purchase.checkout.block.render"
[capabilities]
network_access = true
api_access = true
```
### Basic Example
```javascript
import {
reactExtension,
BlockStack,
TextField,
Checkbox,
useApi,
} from "@shopify/ui-extensions-react/checkout";
export default reactExtension("purchase.checkout.block.render", () => (
<Extension />
));
function Extension() {
const [message, setMessage] = useState("");
const [isGift, setIsGift] = useState(false);
const { applyAttributeChange } = useApi();
useEffect(() => {
if (isGift) {
applyAttributeChange({
type: "updateAttribute",
key: "gift_message",
value: message,
});
}
}, [message, isGift]);
return (
<BlockStack spacing="loose">
<Checkbox checked={isGift} onChange={setIsGift}>
This is a gift
</Checkbox>
{isGift && (
<TextField
label="Gift Message"
value={message}
onChange={setMessage}
multiline={3}
/>
)}
</BlockStack>
);
}
```
### Common Hooks
**useApi:**
```javascript
const { extensionPoint, shop, storefront, i18n, sessionToken } = useApi();
```
**useCartLines:**
```javascript
const lines = useCartLines();
lines.forEach((line) => {
console.log(line.merchandise.product.title, line.quantity);
});
```
**useShippingAddress:**
```javascript
const address = useShippingAddress();
console.log(address.city, address.countryCode);
```
**useApplyCartLinesChange:**
```javascript
const applyChange = useApplyCartLinesChange();
async function addItem() {
await applyChange({
type: "addCartLine",
merchandiseId: "gid://shopify/ProductVariant/123",
quantity: 1,
});
}
```
### Core Components
**Layout:**
- `BlockStack` - Vertical stacking
- `InlineStack` - Horizontal layout
- `Grid`, `GridItem` - Grid layout
- `View` - Container
- `Divider` - Separator
**Input:**
- `TextField` - Text input
- `Checkbox` - Boolean
- `Select` - Dropdown
- `DatePicker` - Date selection
- `Form` - Form wrapper
**Display:**
- `Text`, `Heading` - Typography
- `Banner` - Messages
- `Badge` - Status
- `Image` - Images
- `Link` - Hyperlinks
- `List`, `ListItem` - Lists
**Interactive:**
- `Button` - Actions
- `Modal` - Overlays
- `Pressable` - Click areas
## Admin UI Extensions
Extend Shopify admin interface.
### Admin Action
Custom actions on resource pages.
```bash
shopify app generate extension --type admin_action
```
```javascript
import {
reactExtension,
AdminAction,
Button,
} from "@shopify/ui-extensions-react/admin";
export default reactExtension("admin.product-details.action.render", () => (
<Extension />
));
function Extension() {
const { data } = useData();
async function handleExport() {
const response = await fetch("/api/export", {
method: "POST",
body: JSON.stringify({ productId: data.product.id }),
});
console.log("Exported:", await response.json());
}
return (
<AdminAction
title="Export Product"
primaryAction={<Button onPress={handleExport}>Export</Button>}
/>
);
}
```
**Targets:**
- `admin.product-details.action.render`
- `admin.order-details.action.render`
- `admin.customer-details.action.render`
### Admin Block
Embedded content in admin pages.
```javascript
import {
reactExtension,
BlockStack,
Text,
Badge,
} from "@shopify/ui-extensions-react/admin";
export default reactExtension("admin.product-details.block.render", () => (
<Extension />
));
function Extension() {
const { data } = useData();
const [analytics, setAnalytics] = useState(null);
useEffect(() => {
fetchAnalytics(data.product.id).then(setAnalytics);
}, []);
return (
<BlockStack>
<Text variant="headingMd">Product Analytics</Text>
<Text>Views: {analytics?.views || 0}</Text>
<Text>Conversions: {analytics?.conversions || 0}</Text>
<Badge tone={analytics?.trending ? "success" : "info"}>
{analytics?.trending ? "Trending" : "Normal"}
</Badge>
</BlockStack>
);
}
```
**Targets:**
- `admin.product-details.block.render`
- `admin.order-details.block.render`
- `admin.customer-details.block.render`
## POS UI Extensions
Customize Point of Sale experience.
### Smart Grid Tile
Quick access action on POS home screen.
```javascript
import {
reactExtension,
SmartGridTile,
} from "@shopify/ui-extensions-react/pos";
export default reactExtension("pos.home.tile.render", () => <Extension />);
function Extension() {
function handlePress() {
// Navigate to custom workflow
}
return (
<SmartGridTile
title="Gift Cards"
subtitle="Manage gift cards"
onPress={handlePress}
/>
);
}
```
### POS Modal
Full-screen workflow.
```javascript
import {
reactExtension,
Screen,
BlockStack,
Button,
TextField,
} from "@shopify/ui-extensions-react/pos";
export default reactExtension("pos.home.modal.render", () => <Extension />);
function Extension() {
const { navigation } = useApi();
const [amount, setAmount] = useState("");
function handleIssue() {
// Issue gift card
navigation.pop();
}
return (
<Screen name="Gift Card" title="Issue Gift Card">
<BlockStack>
<TextField label="Amount" value={amount} onChange={setAmount} />
<TextField label="Recipient Email" />
<Button onPress={handleIssue}>Issue</Button>
</BlockStack>
</Screen>
);
}
```
## Customer Account Extensions
Customize customer account pages.
### Order Status Extension
```javascript
import {
reactExtension,
BlockStack,
Text,
Button,
} from "@shopify/ui-extensions-react/customer-account";
export default reactExtension(
"customer-account.order-status.block.render",
() => <Extension />,
);
function Extension() {
const { order } = useApi();
function handleReturn() {
// Initiate return
}
return (
<BlockStack>
<Text variant="headingMd">Need to return?</Text>
<Text>Start return for order {order.name}</Text>
<Button onPress={handleReturn}>Start Return</Button>
</BlockStack>
);
}
```
**Targets:**
- `customer-account.order-status.block.render`
- `customer-account.order-index.block.render`
- `customer-account.profile.block.render`
## Shopify Functions
Serverless backend customization.
### Function Types
**Discounts:**
- `order_discount` - Order-level discounts
- `product_discount` - Product-specific discounts
- `shipping_discount` - Shipping discounts
**Payment Customization:**
- Hide/rename/reorder payment methods
**Delivery Customization:**
- Custom shipping options
- Delivery rules
**Validation:**
- Cart validation rules
- Checkout validation
### Create Function
```bash
shopify app generate extension --type function
```
### Order Discount Function
```javascript
// input.graphql
query Input {
cart {
lines {
quantity
merchandise {
... on ProductVariant {
product {
hasTag(tag: "bulk-discount")
}
}
}
}
}
}
// function.js
export default function orderDiscount(input) {
const targets = input.cart.lines
.filter(line => line.merchandise.product.hasTag)
.map(line => ({
productVariant: { id: line.merchandise.id }
}));
if (targets.length === 0) {
return { discounts: [] };
}
return {
discounts: [{
targets,
value: {
percentage: {
value: 10 // 10% discount
}
}
}]
};
}
```
### Payment Customization Function
```javascript
export default function paymentCustomization(input) {
const hidePaymentMethods = input.cart.lines.some(
(line) => line.merchandise.product.hasTag,
);
if (!hidePaymentMethods) {
return { operations: [] };
}
return {
operations: [
{
hide: {
paymentMethodId: "gid://shopify/PaymentMethod/123",
},
},
],
};
}
```
### Validation Function
```javascript
export default function cartValidation(input) {
const errors = [];
// Max 5 items per cart
if (input.cart.lines.length > 5) {
errors.push({
localizedMessage: "Maximum 5 items allowed per order",
target: "cart",
});
}
// Min $50 for wholesale
const isWholesale = input.cart.lines.some(
(line) => line.merchandise.product.hasTag,
);
if (isWholesale && input.cart.cost.totalAmount.amount < 50) {
errors.push({
localizedMessage: "Wholesale orders require $50 minimum",
target: "cart",
});
}
return { errors };
}
```
## Network Requests
Extensions can call external APIs.
```javascript
import { useApi } from "@shopify/ui-extensions-react/checkout";
function Extension() {
const { sessionToken } = useApi();
async function fetchData() {
const token = await sessionToken.get();
const response = await fetch("https://your-app.com/api/data", {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
return await response.json();
}
}
```
## Best Practices
**Performance:**
- Lazy load data
- Memoize expensive computations
- Use loading states
- Minimize re-renders
**UX:**
- Provide clear error messages
- Show loading indicators
- Validate inputs
- Support keyboard navigation
**Security:**
- Verify session tokens on backend
- Sanitize user input
- Use HTTPS for all requests
- Don't expose sensitive data
**Testing:**
- Test on development stores
- Verify mobile/desktop
- Check accessibility
- Test edge cases
## Resources
- Checkout Extensions: https://shopify.dev/docs/api/checkout-extensions
- Admin Extensions: https://shopify.dev/docs/apps/admin/extensions
- Functions: https://shopify.dev/docs/apps/functions
- Components: https://shopify.dev/docs/api/checkout-ui-extensions/components
@@ -0,0 +1,498 @@
# Themes Reference
Guide for developing Shopify themes with Liquid templating.
## Liquid Templating
### Syntax Basics
**Objects (Output):**
```liquid
{{ product.title }}
{{ product.price | money }}
{{ customer.email }}
```
**Tags (Logic):**
```liquid
{% if product.available %}
<button>Add to Cart</button>
{% else %}
<p>Sold Out</p>
{% endif %}
{% for product in collection.products %}
{{ product.title }}
{% endfor %}
{% case product.type %}
{% when 'Clothing' %}
<span>Apparel</span>
{% when 'Shoes' %}
<span>Footwear</span>
{% else %}
<span>Other</span>
{% endcase %}
```
**Filters (Transform):**
```liquid
{{ product.title | upcase }}
{{ product.price | money }}
{{ product.description | strip_html | truncate: 100 }}
{{ product.image | img_url: 'medium' }}
{{ 'now' | date: '%B %d, %Y' }}
```
### Common Objects
**Product:**
```liquid
{{ product.id }}
{{ product.title }}
{{ product.handle }}
{{ product.description }}
{{ product.price }}
{{ product.compare_at_price }}
{{ product.available }}
{{ product.type }}
{{ product.vendor }}
{{ product.tags }}
{{ product.images }}
{{ product.variants }}
{{ product.featured_image }}
{{ product.url }}
```
**Collection:**
```liquid
{{ collection.title }}
{{ collection.handle }}
{{ collection.description }}
{{ collection.products }}
{{ collection.products_count }}
{{ collection.image }}
{{ collection.url }}
```
**Cart:**
```liquid
{{ cart.item_count }}
{{ cart.total_price }}
{{ cart.items }}
{{ cart.note }}
{{ cart.attributes }}
```
**Customer:**
```liquid
{{ customer.email }}
{{ customer.first_name }}
{{ customer.last_name }}
{{ customer.orders_count }}
{{ customer.total_spent }}
{{ customer.addresses }}
{{ customer.default_address }}
```
**Shop:**
```liquid
{{ shop.name }}
{{ shop.email }}
{{ shop.domain }}
{{ shop.currency }}
{{ shop.money_format }}
{{ shop.enabled_payment_types }}
```
### Common Filters
**String:**
- `upcase`, `downcase`, `capitalize`
- `strip_html`, `strip_newlines`
- `truncate: 100`, `truncatewords: 20`
- `replace: 'old', 'new'`
**Number:**
- `money` - Format currency
- `round`, `ceil`, `floor`
- `times`, `divided_by`, `plus`, `minus`
**Array:**
- `join: ', '`
- `first`, `last`
- `size`
- `map: 'property'`
- `where: 'property', 'value'`
**URL:**
- `img_url: 'size'` - Image URL
- `url_for_type`, `url_for_vendor`
- `link_to`, `link_to_type`
**Date:**
- `date: '%B %d, %Y'`
## Theme Architecture
### Directory Structure
```
theme/
├── assets/ # CSS, JS, images
├── config/ # Theme settings
│ ├── settings_schema.json
│ └── settings_data.json
├── layout/ # Base templates
│ └── theme.liquid
├── locales/ # Translations
│ └── en.default.json
├── sections/ # Reusable blocks
│ ├── header.liquid
│ ├── footer.liquid
│ └── product-grid.liquid
├── snippets/ # Small components
│ ├── product-card.liquid
│ └── icon.liquid
└── templates/ # Page templates
├── index.json
├── product.json
├── collection.json
└── cart.liquid
```
### Layout
Base template wrapping all pages (`layout/theme.liquid`):
```liquid
<!DOCTYPE html>
<html lang="{{ request.locale.iso_code }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ page_title }}</title>
{{ content_for_header }}
<link rel="stylesheet" href="{{ 'theme.css' | asset_url }}">
</head>
<body>
{% section 'header' %}
<main>
{{ content_for_layout }}
</main>
{% section 'footer' %}
<script src="{{ 'theme.js' | asset_url }}"></script>
</body>
</html>
```
### Templates
Page-specific structures (`templates/product.json`):
```json
{
"sections": {
"main": {
"type": "product-template",
"settings": {
"show_vendor": true,
"show_quantity_selector": true
}
},
"recommendations": {
"type": "product-recommendations"
}
},
"order": ["main", "recommendations"]
}
```
Legacy format (`templates/product.liquid`):
```liquid
<div class="product">
<div class="product-images">
<img src="{{ product.featured_image | img_url: 'large' }}" alt="{{ product.title }}">
</div>
<div class="product-details">
<h1>{{ product.title }}</h1>
<p class="price">{{ product.price | money }}</p>
{% form 'product', product %}
<select name="id">
{% for variant in product.variants %}
<option value="{{ variant.id }}">{{ variant.title }} - {{ variant.price | money }}</option>
{% endfor %}
</select>
<button type="submit">Add to Cart</button>
{% endform %}
</div>
</div>
```
### Sections
Reusable content blocks (`sections/product-grid.liquid`):
```liquid
<div class="product-grid">
{% for product in section.settings.collection.products %}
<div class="product-card">
<a href="{{ product.url }}">
<img src="{{ product.featured_image | img_url: 'medium' }}" alt="{{ product.title }}">
<h3>{{ product.title }}</h3>
<p>{{ product.price | money }}</p>
</a>
</div>
{% endfor %}
</div>
{% schema %}
{
"name": "Product Grid",
"settings": [
{
"type": "collection",
"id": "collection",
"label": "Collection"
},
{
"type": "range",
"id": "products_per_row",
"min": 2,
"max": 5,
"step": 1,
"default": 4,
"label": "Products per row"
}
],
"presets": [
{
"name": "Product Grid"
}
]
}
{% endschema %}
```
### Snippets
Small reusable components (`snippets/product-card.liquid`):
```liquid
<div class="product-card">
<a href="{{ product.url }}">
{% if product.featured_image %}
<img src="{{ product.featured_image | img_url: 'medium' }}" alt="{{ product.title }}">
{% endif %}
<h3>{{ product.title }}</h3>
<p class="price">{{ product.price | money }}</p>
{% if product.compare_at_price > product.price %}
<p class="sale-price">{{ product.compare_at_price | money }}</p>
{% endif %}
</a>
</div>
```
Include snippet:
```liquid
{% render 'product-card', product: product %}
```
## Development Workflow
### Setup
```bash
# Initialize new theme
shopify theme init
# Choose Dawn (reference theme) or blank
```
### Local Development
```bash
# Start local server
shopify theme dev
# Preview at http://localhost:9292
# Changes auto-sync to development theme
```
### Pull Theme
```bash
# Pull live theme
shopify theme pull --live
# Pull specific theme
shopify theme pull --theme=123456789
# Pull only templates
shopify theme pull --only=templates
```
### Push Theme
```bash
# Push to development theme
shopify theme push --development
# Create new unpublished theme
shopify theme push --unpublished
# Push specific files
shopify theme push --only=sections,snippets
```
### Theme Check
Lint theme code:
```bash
shopify theme check
shopify theme check --auto-correct
```
## Common Patterns
### Product Form with Variants
```liquid
{% form 'product', product %}
{% unless product.has_only_default_variant %}
{% for option in product.options_with_values %}
<div class="product-option">
<label>{{ option.name }}</label>
<select name="options[{{ option.name }}]">
{% for value in option.values %}
<option value="{{ value }}">{{ value }}</option>
{% endfor %}
</select>
</div>
{% endfor %}
{% endunless %}
<input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
<input type="number" name="quantity" value="1" min="1">
<button type="submit" {% unless product.available %}disabled{% endunless %}>
{% if product.available %}Add to Cart{% else %}Sold Out{% endif %}
</button>
{% endform %}
```
### Pagination
```liquid
{% paginate collection.products by 12 %}
{% for product in collection.products %}
{% render 'product-card', product: product %}
{% endfor %}
{% if paginate.pages > 1 %}
<div class="pagination">
{% if paginate.previous %}
<a href="{{ paginate.previous.url }}">Previous</a>
{% endif %}
{% for part in paginate.parts %}
{% if part.is_link %}
<a href="{{ part.url }}">{{ part.title }}</a>
{% else %}
<span class="current">{{ part.title }}</span>
{% endif %}
{% endfor %}
{% if paginate.next %}
<a href="{{ paginate.next.url }}">Next</a>
{% endif %}
</div>
{% endif %}
{% endpaginate %}
```
### Cart AJAX
```javascript
// Add to cart
fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: variantId,
quantity: 1
})
})
.then(res => res.json())
.then(item => console.log('Added:', item));
// Get cart
fetch('/cart.js')
.then(res => res.json())
.then(cart => console.log('Cart:', cart));
// Update cart
fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: lineItemKey,
quantity: 2
})
})
.then(res => res.json());
```
## Metafields in Themes
Access custom data:
```liquid
{{ product.metafields.custom.care_instructions }}
{{ product.metafields.custom.material.value }}
{% if product.metafields.custom.featured %}
<span class="badge">Featured</span>
{% endif %}
```
## Best Practices
**Performance:**
- Optimize images (use appropriate sizes)
- Minimize Liquid logic complexity
- Use lazy loading for images
- Defer non-critical JavaScript
**Accessibility:**
- Use semantic HTML
- Include alt text for images
- Support keyboard navigation
- Ensure sufficient color contrast
**SEO:**
- Use descriptive page titles
- Include meta descriptions
- Structure content with headings
- Implement schema markup
**Code Quality:**
- Follow Shopify theme guidelines
- Use consistent naming conventions
- Comment complex logic
- Keep sections focused and reusable
## Resources
- Theme Development: https://shopify.dev/docs/themes
- Liquid Reference: https://shopify.dev/docs/api/liquid
- Dawn Theme: https://github.com/Shopify/dawn
- Theme Check: https://shopify.dev/docs/themes/tools/theme-check
@@ -0,0 +1,49 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Testing
.coverage
.pytest_cache/
htmlcov/
.tox/
.nox/
coverage.xml
*.cover
*.py,cover
# Environments
.env
.venv
env/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
@@ -0,0 +1,19 @@
# Shopify Skill Dependencies
# Python 3.10+ required
# No Python package dependencies - uses only standard library
# Testing dependencies (dev)
pytest>=8.0.0
pytest-cov>=4.1.0
pytest-mock>=3.12.0
# Note: This script requires the Shopify CLI tool
# Install Shopify CLI:
# npm install -g @shopify/cli @shopify/theme
# or via Homebrew (macOS):
# brew tap shopify/shopify
# brew install shopify-cli
#
# Authenticate with:
# shopify auth login
@@ -0,0 +1,428 @@
#!/usr/bin/env python3
"""
Shopify GraphQL Utilities
Helper functions for common Shopify GraphQL operations.
Provides query templates, pagination helpers, and rate limit handling.
Usage:
from shopify_graphql import ShopifyGraphQL
client = ShopifyGraphQL(shop_domain, access_token)
products = client.get_products(first=10)
"""
import os
import time
import json
from typing import Dict, List, Optional, Any, Generator
from dataclasses import dataclass
from urllib.request import Request, urlopen
from urllib.error import HTTPError
# API Configuration
API_VERSION = "2026-01"
MAX_RETRIES = 3
RETRY_DELAY = 1.0 # seconds
@dataclass
class GraphQLResponse:
"""Container for GraphQL response data."""
data: Optional[Dict[str, Any]] = None
errors: Optional[List[Dict[str, Any]]] = None
extensions: Optional[Dict[str, Any]] = None
@property
def is_success(self) -> bool:
return self.errors is None or len(self.errors) == 0
@property
def query_cost(self) -> Optional[int]:
"""Get the actual query cost from extensions."""
if self.extensions and 'cost' in self.extensions:
return self.extensions['cost'].get('actualQueryCost')
return None
class ShopifyGraphQL:
"""
Shopify GraphQL API client with built-in utilities.
Features:
- Query templates for common operations
- Automatic pagination
- Rate limit handling with exponential backoff
- Response parsing helpers
"""
def __init__(self, shop_domain: str, access_token: str):
"""
Initialize the GraphQL client.
Args:
shop_domain: Store domain (e.g., 'my-store.myshopify.com')
access_token: Admin API access token
"""
self.shop_domain = shop_domain.replace('https://', '').replace('http://', '')
self.access_token = access_token
self.base_url = f"https://{self.shop_domain}/admin/api/{API_VERSION}/graphql.json"
def execute(self, query: str, variables: Optional[Dict] = None) -> GraphQLResponse:
"""
Execute a GraphQL query/mutation.
Args:
query: GraphQL query string
variables: Query variables
Returns:
GraphQLResponse object
"""
payload = {"query": query}
if variables:
payload["variables"] = variables
headers = {
"Content-Type": "application/json",
"X-Shopify-Access-Token": self.access_token
}
for attempt in range(MAX_RETRIES):
try:
request = Request(
self.base_url,
data=json.dumps(payload).encode('utf-8'),
headers=headers,
method='POST'
)
with urlopen(request, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
return GraphQLResponse(
data=result.get('data'),
errors=result.get('errors'),
extensions=result.get('extensions')
)
except HTTPError as e:
if e.code == 429: # Rate limited
delay = RETRY_DELAY * (2 ** attempt)
print(f"Rate limited. Retrying in {delay}s...")
time.sleep(delay)
continue
raise
except Exception as e:
if attempt == MAX_RETRIES - 1:
raise
time.sleep(RETRY_DELAY)
return GraphQLResponse(errors=[{"message": "Max retries exceeded"}])
# ==================== Query Templates ====================
def get_products(
self,
first: int = 10,
query: Optional[str] = None,
after: Optional[str] = None
) -> GraphQLResponse:
"""
Query products with pagination.
Args:
first: Number of products to fetch (max 250)
query: Optional search query
after: Cursor for pagination
"""
gql = """
query GetProducts($first: Int!, $query: String, $after: String) {
products(first: $first, query: $query, after: $after) {
edges {
node {
id
title
handle
status
totalInventory
variants(first: 5) {
edges {
node {
id
title
price
inventoryQuantity
sku
}
}
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
return self.execute(gql, {"first": first, "query": query, "after": after})
def get_orders(
self,
first: int = 10,
query: Optional[str] = None,
after: Optional[str] = None
) -> GraphQLResponse:
"""
Query orders with pagination.
Args:
first: Number of orders to fetch (max 250)
query: Optional search query (e.g., "financial_status:paid")
after: Cursor for pagination
"""
gql = """
query GetOrders($first: Int!, $query: String, $after: String) {
orders(first: $first, query: $query, after: $after) {
edges {
node {
id
name
createdAt
displayFinancialStatus
displayFulfillmentStatus
totalPriceSet {
shopMoney { amount currencyCode }
}
customer {
id
firstName
lastName
}
lineItems(first: 5) {
edges {
node {
title
quantity
}
}
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
return self.execute(gql, {"first": first, "query": query, "after": after})
def get_customers(
self,
first: int = 10,
query: Optional[str] = None,
after: Optional[str] = None
) -> GraphQLResponse:
"""
Query customers with pagination.
Args:
first: Number of customers to fetch (max 250)
query: Optional search query
after: Cursor for pagination
"""
gql = """
query GetCustomers($first: Int!, $query: String, $after: String) {
customers(first: $first, query: $query, after: $after) {
edges {
node {
id
firstName
lastName
displayName
defaultEmailAddress {
emailAddress
}
numberOfOrders
amountSpent {
amount
currencyCode
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
return self.execute(gql, {"first": first, "query": query, "after": after})
def set_metafields(self, metafields: List[Dict]) -> GraphQLResponse:
"""
Set metafields on resources.
Args:
metafields: List of metafield inputs, each containing:
- ownerId: Resource GID
- namespace: Metafield namespace
- key: Metafield key
- value: Metafield value
- type: Metafield type
"""
gql = """
mutation SetMetafields($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields {
id
namespace
key
value
}
userErrors {
field
message
}
}
}
"""
return self.execute(gql, {"metafields": metafields})
# ==================== Pagination Helpers ====================
def paginate_products(
self,
batch_size: int = 50,
query: Optional[str] = None
) -> Generator[Dict, None, None]:
"""
Generator that yields all products with automatic pagination.
Args:
batch_size: Products per request (max 250)
query: Optional search query
Yields:
Product dictionaries
"""
cursor = None
while True:
response = self.get_products(first=batch_size, query=query, after=cursor)
if not response.is_success or not response.data:
break
products = response.data.get('products', {})
edges = products.get('edges', [])
for edge in edges:
yield edge['node']
page_info = products.get('pageInfo', {})
if not page_info.get('hasNextPage'):
break
cursor = page_info.get('endCursor')
def paginate_orders(
self,
batch_size: int = 50,
query: Optional[str] = None
) -> Generator[Dict, None, None]:
"""
Generator that yields all orders with automatic pagination.
Args:
batch_size: Orders per request (max 250)
query: Optional search query
Yields:
Order dictionaries
"""
cursor = None
while True:
response = self.get_orders(first=batch_size, query=query, after=cursor)
if not response.is_success or not response.data:
break
orders = response.data.get('orders', {})
edges = orders.get('edges', [])
for edge in edges:
yield edge['node']
page_info = orders.get('pageInfo', {})
if not page_info.get('hasNextPage'):
break
cursor = page_info.get('endCursor')
# ==================== Utility Functions ====================
def extract_id(gid: str) -> str:
"""
Extract numeric ID from Shopify GID.
Args:
gid: Global ID (e.g., 'gid://shopify/Product/123')
Returns:
Numeric ID string (e.g., '123')
"""
return gid.split('/')[-1] if gid else ''
def build_gid(resource_type: str, id: str) -> str:
"""
Build Shopify GID from resource type and ID.
Args:
resource_type: Resource type (e.g., 'Product', 'Order')
id: Numeric ID
Returns:
Global ID (e.g., 'gid://shopify/Product/123')
"""
return f"gid://shopify/{resource_type}/{id}"
# ==================== Example Usage ====================
def main():
"""Example usage of ShopifyGraphQL client."""
import os
# Load from environment
shop = os.environ.get('SHOP_DOMAIN', 'your-store.myshopify.com')
token = os.environ.get('SHOPIFY_ACCESS_TOKEN', '')
if not token:
print("Set SHOPIFY_ACCESS_TOKEN environment variable")
return
client = ShopifyGraphQL(shop, token)
# Example: Get first 5 products
print("Fetching products...")
response = client.get_products(first=5)
if response.is_success:
products = response.data['products']['edges']
for edge in products:
product = edge['node']
print(f" - {product['title']} ({product['status']})")
print(f"\nQuery cost: {response.query_cost}")
else:
print(f"Errors: {response.errors}")
if __name__ == '__main__':
main()
@@ -0,0 +1,441 @@
#!/usr/bin/env python3
"""
Shopify Project Initialization Script
Interactive script to scaffold Shopify apps, extensions, or themes.
Supports environment variable loading from multiple locations.
"""
import os
import sys
import json
import subprocess
from pathlib import Path
from typing import Dict, Optional, List
from dataclasses import dataclass
@dataclass
class EnvConfig:
"""Environment configuration container."""
shopify_api_key: Optional[str] = None
shopify_api_secret: Optional[str] = None
shop_domain: Optional[str] = None
scopes: Optional[str] = None
class EnvLoader:
"""Load environment variables from multiple sources in priority order."""
@staticmethod
def load_env_file(filepath: Path) -> Dict[str, str]:
"""
Load environment variables from .env file.
Args:
filepath: Path to .env file
Returns:
Dictionary of environment variables
"""
env_vars = {}
if not filepath.exists():
return env_vars
try:
with open(filepath, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
env_vars[key.strip()] = value.strip().strip('"').strip("'")
except Exception as e:
print(f"Warning: Failed to load {filepath}: {e}")
return env_vars
@staticmethod
def get_env_paths(skill_dir: Path) -> List[Path]:
"""
Get list of .env file paths in priority order.
Works with any AI tool directory structure:
- .agent/skills/ (universal)
- .claude/skills/ (Claude Code)
- .gemini/skills/ (Gemini CLI)
- .cursor/skills/ (Cursor)
Priority: process.env > skill/.env > skills/.env > agent_dir/.env
Args:
skill_dir: Path to skill directory
Returns:
List of .env file paths
"""
paths = []
# skill/.env
skill_env = skill_dir / '.env'
if skill_env.exists():
paths.append(skill_env)
# skills/.env
skills_env = skill_dir.parent / '.env'
if skills_env.exists():
paths.append(skills_env)
# agent_dir/.env (e.g., .agent, .claude, .gemini, .cursor)
agent_env = skill_dir.parent.parent / '.env'
if agent_env.exists():
paths.append(agent_env)
return paths
@staticmethod
def load_config(skill_dir: Path) -> EnvConfig:
"""
Load configuration from environment variables.
Works with any AI tool directory structure.
Priority: process.env > skill/.env > skills/.env > agent_dir/.env
Args:
skill_dir: Path to skill directory
Returns:
EnvConfig object
"""
config = EnvConfig()
# Load from .env files (reverse priority order)
for env_path in reversed(EnvLoader.get_env_paths(skill_dir)):
env_vars = EnvLoader.load_env_file(env_path)
if 'SHOPIFY_API_KEY' in env_vars:
config.shopify_api_key = env_vars['SHOPIFY_API_KEY']
if 'SHOPIFY_API_SECRET' in env_vars:
config.shopify_api_secret = env_vars['SHOPIFY_API_SECRET']
if 'SHOP_DOMAIN' in env_vars:
config.shop_domain = env_vars['SHOP_DOMAIN']
if 'SCOPES' in env_vars:
config.scopes = env_vars['SCOPES']
# Override with process environment (highest priority)
if 'SHOPIFY_API_KEY' in os.environ:
config.shopify_api_key = os.environ['SHOPIFY_API_KEY']
if 'SHOPIFY_API_SECRET' in os.environ:
config.shopify_api_secret = os.environ['SHOPIFY_API_SECRET']
if 'SHOP_DOMAIN' in os.environ:
config.shop_domain = os.environ['SHOP_DOMAIN']
if 'SCOPES' in os.environ:
config.scopes = os.environ['SCOPES']
return config
class ShopifyInitializer:
"""Initialize Shopify projects."""
def __init__(self, config: EnvConfig):
"""
Initialize ShopifyInitializer.
Args:
config: Environment configuration
"""
self.config = config
def prompt(self, message: str, default: Optional[str] = None) -> str:
"""
Prompt user for input.
Args:
message: Prompt message
default: Default value
Returns:
User input or default
"""
if default:
message = f"{message} [{default}]"
user_input = input(f"{message}: ").strip()
return user_input if user_input else (default or '')
def select_option(self, message: str, options: List[str]) -> str:
"""
Prompt user to select from options.
Args:
message: Prompt message
options: List of options
Returns:
Selected option
"""
print(f"\n{message}")
for i, option in enumerate(options, 1):
print(f"{i}. {option}")
while True:
try:
choice = int(input("Select option: ").strip())
if 1 <= choice <= len(options):
return options[choice - 1]
print(f"Please select 1-{len(options)}")
except (ValueError, KeyboardInterrupt):
print("Invalid input")
def check_cli_installed(self) -> bool:
"""
Check if Shopify CLI is installed.
Returns:
True if installed, False otherwise
"""
try:
result = subprocess.run(
['shopify', 'version'],
capture_output=True,
text=True,
timeout=5
)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
def create_app_config(self, project_dir: Path, app_name: str, scopes: str) -> None:
"""
Create shopify.app.toml configuration file.
Args:
project_dir: Project directory
app_name: Application name
scopes: Access scopes
"""
config_content = f"""# Shopify App Configuration
name = "{app_name}"
client_id = "{self.config.shopify_api_key or 'YOUR_API_KEY'}"
application_url = "https://your-app.com"
embedded = true
[build]
automatically_update_urls_on_dev = true
dev_store_url = "{self.config.shop_domain or 'your-store.myshopify.com'}"
[access_scopes]
scopes = "{scopes}"
[webhooks]
api_version = "2026-01"
[[webhooks.subscriptions]]
topics = ["app/uninstalled"]
uri = "/webhooks/app/uninstalled"
[webhooks.privacy_compliance]
customer_data_request_url = "/webhooks/gdpr/data-request"
customer_deletion_url = "/webhooks/gdpr/customer-deletion"
shop_deletion_url = "/webhooks/gdpr/shop-deletion"
"""
config_path = project_dir / 'shopify.app.toml'
config_path.write_text(config_content)
print(f"✓ Created {config_path}")
def create_extension_config(self, project_dir: Path, extension_name: str, extension_type: str) -> None:
"""
Create shopify.extension.toml configuration file.
Args:
project_dir: Project directory
extension_name: Extension name
extension_type: Extension type
"""
target_map = {
'checkout': 'purchase.checkout.block.render',
'admin_action': 'admin.product-details.action.render',
'admin_block': 'admin.product-details.block.render',
'pos': 'pos.home.tile.render',
'function': 'function',
'customer_account': 'customer-account.order-status.block.render',
'theme_app': 'theme-app-extension'
}
config_content = f"""name = "{extension_name}"
type = "ui_extension"
handle = "{extension_name.lower().replace(' ', '-')}"
[extension_points]
api_version = "2026-01"
[[extension_points.targets]]
target = "{target_map.get(extension_type, 'purchase.checkout.block.render')}"
[capabilities]
network_access = true
api_access = true
"""
config_path = project_dir / 'shopify.extension.toml'
config_path.write_text(config_content)
print(f"✓ Created {config_path}")
def create_readme(self, project_dir: Path, project_type: str, project_name: str) -> None:
"""
Create README.md file.
Args:
project_dir: Project directory
project_type: Project type (app/extension/theme)
project_name: Project name
"""
content = f"""# {project_name}
Shopify {project_type.capitalize()} project.
## Setup
```bash
# Install dependencies
npm install
# Start development
shopify {project_type} dev
```
## Deployment
```bash
# Deploy to Shopify
shopify {project_type} deploy
```
## Resources
- [Shopify Documentation](https://shopify.dev/docs)
- [Shopify CLI](https://shopify.dev/docs/api/shopify-cli)
"""
readme_path = project_dir / 'README.md'
readme_path.write_text(content)
print(f"✓ Created {readme_path}")
def init_app(self) -> None:
"""Initialize Shopify app project."""
print("\n=== Shopify App Initialization ===\n")
app_name = self.prompt("App name", "my-shopify-app")
scopes = self.prompt("Access scopes", self.config.scopes or "read_products,write_products")
project_dir = Path.cwd() / app_name
project_dir.mkdir(exist_ok=True)
print(f"\nCreating app in {project_dir}...")
self.create_app_config(project_dir, app_name, scopes)
self.create_readme(project_dir, "app", app_name)
# Create basic package.json
package_json = {
"name": app_name.lower().replace(' ', '-'),
"version": "1.0.0",
"scripts": {
"dev": "shopify app dev",
"deploy": "shopify app deploy"
}
}
(project_dir / 'package.json').write_text(json.dumps(package_json, indent=2))
print(f"✓ Created package.json")
print(f"\n✓ App '{app_name}' initialized successfully!")
print(f"\nNext steps:")
print(f" cd {app_name}")
print(f" npm install")
print(f" shopify app dev")
def init_extension(self) -> None:
"""Initialize Shopify extension project."""
print("\n=== Shopify Extension Initialization ===\n")
extension_types = [
'checkout',
'admin_action',
'admin_block',
'pos',
'function',
'customer_account',
'theme_app'
]
extension_type = self.select_option("Select extension type", extension_types)
extension_name = self.prompt("Extension name", "my-extension")
project_dir = Path.cwd() / extension_name
project_dir.mkdir(exist_ok=True)
print(f"\nCreating extension in {project_dir}...")
self.create_extension_config(project_dir, extension_name, extension_type)
self.create_readme(project_dir, "extension", extension_name)
print(f"\n✓ Extension '{extension_name}' initialized successfully!")
print(f"\nNext steps:")
print(f" cd {extension_name}")
print(f" shopify app dev")
def init_theme(self) -> None:
"""Initialize Shopify theme project."""
print("\n=== Shopify Theme Initialization ===\n")
theme_name = self.prompt("Theme name", "my-theme")
print(f"\nInitializing theme '{theme_name}'...")
print("\nRecommended: Use 'shopify theme init' for full theme scaffolding")
print(f"\nRun: shopify theme init {theme_name}")
def run(self) -> None:
"""Run interactive initialization."""
print("=" * 60)
print("Shopify Project Initializer")
print("=" * 60)
# Check CLI
if not self.check_cli_installed():
print("\n⚠ Shopify CLI not found!")
print("Install: npm install -g @shopify/cli@latest")
sys.exit(1)
# Select project type
project_types = ['app', 'extension', 'theme']
project_type = self.select_option("Select project type", project_types)
# Initialize based on type
if project_type == 'app':
self.init_app()
elif project_type == 'extension':
self.init_extension()
elif project_type == 'theme':
self.init_theme()
def main() -> None:
"""Main entry point."""
try:
# Get skill directory
script_dir = Path(__file__).parent
skill_dir = script_dir.parent
# Load configuration
config = EnvLoader.load_config(skill_dir)
# Initialize project
initializer = ShopifyInitializer(config)
initializer.run()
except KeyboardInterrupt:
print("\n\nAborted.")
sys.exit(0)
except Exception as e:
print(f"\n✗ Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
@@ -0,0 +1,379 @@
"""
Tests for shopify_init.py
Run with: pytest test_shopify_init.py -v --cov=shopify_init --cov-report=term-missing
"""
import os
import sys
import json
import pytest
import subprocess
from pathlib import Path
from unittest.mock import Mock, patch, mock_open, MagicMock
sys.path.insert(0, str(Path(__file__).parent.parent))
from shopify_init import EnvLoader, EnvConfig, ShopifyInitializer
class TestEnvLoader:
"""Test EnvLoader class."""
def test_load_env_file_success(self, tmp_path):
"""Test loading valid .env file."""
env_file = tmp_path / ".env"
env_file.write_text("""
SHOPIFY_API_KEY=test_key
SHOPIFY_API_SECRET=test_secret
SHOP_DOMAIN=test.myshopify.com
# Comment line
SCOPES=read_products,write_products
""")
result = EnvLoader.load_env_file(env_file)
assert result['SHOPIFY_API_KEY'] == 'test_key'
assert result['SHOPIFY_API_SECRET'] == 'test_secret'
assert result['SHOP_DOMAIN'] == 'test.myshopify.com'
assert result['SCOPES'] == 'read_products,write_products'
def test_load_env_file_with_quotes(self, tmp_path):
"""Test loading .env file with quoted values."""
env_file = tmp_path / ".env"
env_file.write_text("""
SHOPIFY_API_KEY="test_key"
SHOPIFY_API_SECRET='test_secret'
""")
result = EnvLoader.load_env_file(env_file)
assert result['SHOPIFY_API_KEY'] == 'test_key'
assert result['SHOPIFY_API_SECRET'] == 'test_secret'
def test_load_env_file_nonexistent(self, tmp_path):
"""Test loading non-existent .env file."""
result = EnvLoader.load_env_file(tmp_path / "nonexistent.env")
assert result == {}
def test_load_env_file_invalid_format(self, tmp_path):
"""Test loading .env file with invalid lines."""
env_file = tmp_path / ".env"
env_file.write_text("""
VALID_KEY=value
INVALID_LINE_NO_EQUALS
ANOTHER_VALID=test
""")
result = EnvLoader.load_env_file(env_file)
assert result['VALID_KEY'] == 'value'
assert result['ANOTHER_VALID'] == 'test'
assert 'INVALID_LINE_NO_EQUALS' not in result
def test_get_env_paths(self, tmp_path):
"""Test getting .env file paths from universal directory structure."""
# Create directory structure (works with .agent, .claude, .gemini, .cursor)
agent_dir = tmp_path / ".agent"
skills_dir = agent_dir / "skills"
skill_dir = skills_dir / "shopify"
skill_dir.mkdir(parents=True)
# Create .env files at each level
(skill_dir / ".env").write_text("SKILL=1")
(skills_dir / ".env").write_text("SKILLS=1")
(agent_dir / ".env").write_text("AGENT=1")
paths = EnvLoader.get_env_paths(skill_dir)
assert len(paths) == 3
assert skill_dir / ".env" in paths
assert skills_dir / ".env" in paths
assert agent_dir / ".env" in paths
def test_load_config_priority(self, tmp_path, monkeypatch):
"""Test configuration loading priority across different AI tool directories."""
skill_dir = tmp_path / "skill"
skills_dir = tmp_path
agent_dir = tmp_path.parent # Could be .agent, .claude, .gemini, .cursor
skill_dir.mkdir(parents=True)
(skill_dir / ".env").write_text("SHOPIFY_API_KEY=skill_key")
(skills_dir / ".env").write_text("SHOPIFY_API_KEY=skills_key\nSHOP_DOMAIN=skills.myshopify.com")
monkeypatch.setenv("SHOPIFY_API_KEY", "process_key")
config = EnvLoader.load_config(skill_dir)
assert config.shopify_api_key == "process_key"
# Shop domain from skills/.env
assert config.shop_domain == "skills.myshopify.com"
def test_load_config_no_files(self, tmp_path):
"""Test configuration loading with no .env files."""
config = EnvLoader.load_config(tmp_path)
assert config.shopify_api_key is None
assert config.shopify_api_secret is None
assert config.shop_domain is None
assert config.scopes is None
class TestShopifyInitializer:
"""Test ShopifyInitializer class."""
@pytest.fixture
def config(self):
"""Create test config."""
return EnvConfig(
shopify_api_key="test_key",
shopify_api_secret="test_secret",
shop_domain="test.myshopify.com",
scopes="read_products,write_products"
)
@pytest.fixture
def initializer(self, config):
"""Create initializer instance."""
return ShopifyInitializer(config)
def test_prompt_with_default(self, initializer):
"""Test prompt with default value."""
with patch('builtins.input', return_value=''):
result = initializer.prompt("Test", "default_value")
assert result == "default_value"
def test_prompt_with_input(self, initializer):
"""Test prompt with user input."""
with patch('builtins.input', return_value='user_input'):
result = initializer.prompt("Test", "default_value")
assert result == "user_input"
def test_select_option_valid(self, initializer):
"""Test select option with valid choice."""
options = ['app', 'extension', 'theme']
with patch('builtins.input', return_value='2'):
result = initializer.select_option("Choose", options)
assert result == 'extension'
def test_select_option_invalid_then_valid(self, initializer):
"""Test select option with invalid then valid choice."""
options = ['app', 'extension']
with patch('builtins.input', side_effect=['5', 'invalid', '1']):
result = initializer.select_option("Choose", options)
assert result == 'app'
def test_check_cli_installed_success(self, initializer):
"""Test CLI installed check - success."""
mock_result = Mock()
mock_result.returncode = 0
with patch('subprocess.run', return_value=mock_result):
assert initializer.check_cli_installed() is True
def test_check_cli_installed_failure(self, initializer):
"""Test CLI installed check - failure."""
with patch('subprocess.run', side_effect=FileNotFoundError):
assert initializer.check_cli_installed() is False
def test_create_app_config(self, initializer, tmp_path):
"""Test creating app configuration file."""
initializer.create_app_config(tmp_path, "test-app", "read_products")
config_file = tmp_path / "shopify.app.toml"
assert config_file.exists()
content = config_file.read_text()
assert 'name = "test-app"' in content
assert 'scopes = "read_products"' in content
assert 'client_id = "test_key"' in content
def test_create_extension_config(self, initializer, tmp_path):
"""Test creating extension configuration file."""
initializer.create_extension_config(tmp_path, "test-ext", "checkout")
config_file = tmp_path / "shopify.extension.toml"
assert config_file.exists()
content = config_file.read_text()
assert 'name = "test-ext"' in content
assert 'purchase.checkout.block.render' in content
def test_create_extension_config_admin_action(self, initializer, tmp_path):
"""Test creating admin action extension config."""
initializer.create_extension_config(tmp_path, "admin-ext", "admin_action")
config_file = tmp_path / "shopify.extension.toml"
content = config_file.read_text()
assert 'admin.product-details.action.render' in content
def test_create_readme(self, initializer, tmp_path):
"""Test creating README file."""
initializer.create_readme(tmp_path, "app", "Test App")
readme_file = tmp_path / "README.md"
assert readme_file.exists()
content = readme_file.read_text()
assert '# Test App' in content
assert 'shopify app dev' in content
@patch('builtins.input')
@patch('builtins.print')
def test_init_app(self, mock_print, mock_input, initializer, tmp_path, monkeypatch):
"""Test app initialization."""
monkeypatch.chdir(tmp_path)
# Mock user inputs
mock_input.side_effect = ['my-app', 'read_products,write_products']
initializer.init_app()
# Check directory created
app_dir = tmp_path / "my-app"
assert app_dir.exists()
# Check files created
assert (app_dir / "shopify.app.toml").exists()
assert (app_dir / "README.md").exists()
assert (app_dir / "package.json").exists()
# Check package.json content
package_json = json.loads((app_dir / "package.json").read_text())
assert package_json['name'] == 'my-app'
assert 'dev' in package_json['scripts']
@patch('builtins.input')
@patch('builtins.print')
def test_init_extension(self, mock_print, mock_input, initializer, tmp_path, monkeypatch):
"""Test extension initialization."""
monkeypatch.chdir(tmp_path)
# Mock user inputs: type selection (1 = checkout), name
mock_input.side_effect = ['1', 'my-extension']
initializer.init_extension()
# Check directory and files created
ext_dir = tmp_path / "my-extension"
assert ext_dir.exists()
assert (ext_dir / "shopify.extension.toml").exists()
assert (ext_dir / "README.md").exists()
@patch('builtins.input')
@patch('builtins.print')
def test_init_theme(self, mock_print, mock_input, initializer):
"""Test theme initialization."""
mock_input.return_value = 'my-theme'
initializer.init_theme()
assert mock_print.called
@patch('builtins.print')
def test_run_no_cli(self, mock_print, initializer):
"""Test run when CLI not installed."""
with patch.object(initializer, 'check_cli_installed', return_value=False):
with pytest.raises(SystemExit) as exc_info:
initializer.run()
assert exc_info.value.code == 1
@patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True)
@patch.object(ShopifyInitializer, 'init_app')
@patch('builtins.input')
@patch('builtins.print')
def test_run_app_selected(self, mock_print, mock_input, mock_init_app, mock_cli_check, initializer):
"""Test run with app selection."""
mock_input.return_value = '1' # Select app
initializer.run()
mock_init_app.assert_called_once()
@patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True)
@patch.object(ShopifyInitializer, 'init_extension')
@patch('builtins.input')
@patch('builtins.print')
def test_run_extension_selected(self, mock_print, mock_input, mock_init_ext, mock_cli_check, initializer):
"""Test run with extension selection."""
mock_input.return_value = '2' # Select extension
initializer.run()
mock_init_ext.assert_called_once()
class TestMain:
"""Test main function."""
@patch('shopify_init.ShopifyInitializer')
@patch('shopify_init.EnvLoader')
def test_main_success(self, mock_loader, mock_initializer):
"""Test main function success path."""
from shopify_init import main
mock_config = Mock()
mock_loader.load_config.return_value = mock_config
mock_init_instance = Mock()
mock_initializer.return_value = mock_init_instance
with patch('builtins.print'):
main()
mock_init_instance.run.assert_called_once()
@patch('shopify_init.ShopifyInitializer')
@patch('sys.exit')
def test_main_keyboard_interrupt(self, mock_exit, mock_initializer):
"""Test main function with keyboard interrupt."""
from shopify_init import main
mock_initializer.return_value.run.side_effect = KeyboardInterrupt
with patch('builtins.print'):
main()
mock_exit.assert_called_with(0)
@patch('shopify_init.ShopifyInitializer')
@patch('sys.exit')
def test_main_exception(self, mock_exit, mock_initializer):
"""Test main function with exception."""
from shopify_init import main
mock_initializer.return_value.run.side_effect = Exception("Test error")
with patch('builtins.print'):
main()
mock_exit.assert_called_with(1)
class TestEnvConfig:
"""Test EnvConfig dataclass."""
def test_env_config_defaults(self):
"""Test EnvConfig default values."""
config = EnvConfig()
assert config.shopify_api_key is None
assert config.shopify_api_secret is None
assert config.shop_domain is None
assert config.scopes is None
def test_env_config_with_values(self):
"""Test EnvConfig with values."""
config = EnvConfig(
shopify_api_key="key",
shopify_api_secret="secret",
shop_domain="test.myshopify.com",
scopes="read_products"
)
assert config.shopify_api_key == "key"
assert config.shopify_api_secret == "secret"
assert config.shop_domain == "test.myshopify.com"
assert config.scopes == "read_products"
@@ -0,0 +1,286 @@
---
name: sveltekit
description: "Build full-stack web applications with SvelteKit — file-based routing, SSR, SSG, API routes, and form actions in one framework."
category: frontend
risk: safe
source: community
date_added: "2026-03-18"
author: suhaibjanjua
tags: [svelte, sveltekit, fullstack, ssr, ssg, typescript]
tools: [claude, cursor, gemini]
---
# SvelteKit Full-Stack Development
## Overview
SvelteKit is the official full-stack framework built on top of Svelte. It provides file-based routing, server-side rendering (SSR), static site generation (SSG), API routes, and progressive form actions — all with Svelte's compile-time reactivity model that ships zero runtime overhead to the browser. Use this skill when building fast, modern web apps where both DX and performance matter.
## When to Use This Skill
- Use when building a new full-stack web application with Svelte
- Use when you need SSR or SSG with fine-grained control per route
- Use when migrating a SPA to a framework with server capabilities
- Use when working on a project that needs file-based routing and collocated API endpoints
- Use when the user asks about `+page.svelte`, `+layout.svelte`, `load` functions, or form actions
## How It Works
### Step 1: Project Setup
```bash
npm create svelte@latest my-app
cd my-app
npm install
npm run dev
```
Choose **Skeleton project** + **TypeScript** + **ESLint/Prettier** when prompted.
Directory structure after scaffolding:
```
src/
routes/
+page.svelte ← Root page component
+layout.svelte ← Root layout (wraps all pages)
+error.svelte ← Error boundary
lib/
server/ ← Server-only code (never bundled to client)
components/ ← Shared components
app.html ← HTML shell
static/ ← Static assets
```
### Step 2: File-Based Routing
Every `+page.svelte` file in `src/routes/` maps directly to a URL:
```
src/routes/+page.svelte → /
src/routes/about/+page.svelte → /about
src/routes/blog/[slug]/+page.svelte → /blog/:slug
src/routes/shop/[...path]/+page.svelte → /shop/* (catch-all)
```
**Route groups** (no URL segment): wrap in `(group)/` folder.
**Private routes** (not accessible as URLs): prefix with `_` or `(group)`.
### Step 3: Loading Data with `load` Functions
Use a `+page.ts` (universal) or `+page.server.ts` (server-only) file alongside the page:
```typescript
// src/routes/blog/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, fetch }) => {
const post = await fetch(`/api/posts/${params.slug}`).then(r => r.json());
if (!post) {
error(404, 'Post not found');
}
return { post };
};
```
```svelte
<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<h1>{data.post.title}</h1>
<article>{@html data.post.content}</article>
```
### Step 4: API Routes (Server Endpoints)
Create `+server.ts` files for REST-style endpoints:
```typescript
// src/routes/api/posts/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url }) => {
const limit = Number(url.searchParams.get('limit') ?? 10);
const posts = await db.post.findMany({ take: limit });
return json(posts);
};
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
const post = await db.post.create({ data: body });
return json(post, { status: 201 });
};
```
### Step 5: Form Actions
Form actions are the SvelteKit-native way to handle mutations — no client-side fetch required:
```typescript
// src/routes/contact/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
if (!email) {
return fail(400, { email, missing: true });
}
await sendEmail(String(email));
redirect(303, '/thank-you');
}
};
```
```svelte
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
export let form: ActionData;
</script>
<form method="POST" use:enhance>
<input name="email" type="email" />
{#if form?.missing}<p class="error">Email is required</p>{/if}
<button type="submit">Subscribe</button>
</form>
```
### Step 6: Layouts and Nested Routes
```svelte
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import type { LayoutData } from './$types';
export let data: LayoutData;
</script>
<nav>
<a href="/">Home</a>
<a href="/blog">Blog</a>
{#if data.user}
<a href="/dashboard">Dashboard</a>
{/if}
</nav>
<slot /> <!-- child page renders here -->
```
```typescript
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return { user: locals.user ?? null };
};
```
### Step 7: Rendering Modes
Control per-route rendering with page options:
```typescript
// src/routes/docs/+page.ts
export const prerender = true; // Static — generated at build time
export const ssr = true; // Default — rendered on server per request
export const csr = false; // Disable client-side hydration entirely
```
## Examples
### Example 1: Protected Dashboard Route
```typescript
// src/routes/dashboard/+layout.server.ts
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
return { user: locals.user };
};
```
### Example 2: Hooks — Session Middleware
```typescript
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { verifyToken } from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('session');
if (token) {
event.locals.user = await verifyToken(token);
}
return resolve(event);
};
```
### Example 3: Preloading and Invalidation
```svelte
<script lang="ts">
import { invalidateAll } from '$app/navigation';
async function refresh() {
await invalidateAll(); // re-runs all load functions on the page
}
</script>
<button on:click={refresh}>Refresh</button>
```
## Best Practices
- ✅ Use `+page.server.ts` for database/auth logic — it never ships to the client
- ✅ Use `$lib/server/` for shared server-only modules (DB client, auth helpers)
- ✅ Use form actions for mutations instead of client-side `fetch` — works without JS
- ✅ Type all `load` return values with generated `$types` (`PageData`, `LayoutData`)
- ✅ Use `event.locals` in hooks to pass server-side context to load functions
- ❌ Don't import server-only code in `+page.svelte` or `+layout.svelte` directly
- ❌ Don't store sensitive state in stores — use `locals` on the server
- ❌ Don't skip `use:enhance` on forms — without it, forms lose progressive enhancement
## Security & Safety Notes
- All code in `+page.server.ts`, `+server.ts`, and `$lib/server/` runs exclusively on the server — safe for DB queries, secrets, and session validation.
- Always validate and sanitize form data before database writes.
- Use `error(403)` or `redirect(303)` from `@sveltejs/kit` rather than returning raw error objects.
- Set `httpOnly: true` and `secure: true` on all auth cookies.
- CSRF protection is built-in for form actions — do not disable `checkOrigin` in production.
## Common Pitfalls
- **Problem:** `Cannot use import statement in a module` in `+page.server.ts`
**Solution:** The file must be `.ts` or `.js`, not `.svelte`. Server files and Svelte components are separate.
- **Problem:** Store value is `undefined` on first SSR render
**Solution:** Populate the store from the `load` function return value (`data` prop), not from client-side `onMount`.
- **Problem:** Form action does not redirect after submit
**Solution:** Use `redirect(303, '/path')` from `@sveltejs/kit`, not a plain `return`. 303 is required for POST redirects.
- **Problem:** `locals.user` is undefined inside a `+page.server.ts` load function
**Solution:** Set `event.locals.user` in `src/hooks.server.ts` before the `resolve()` call.
## Related Skills
- `@nextjs-app-router-patterns` — When you prefer React over Svelte for SSR/SSG
- `@trpc-fullstack` — Add end-to-end type safety to SvelteKit API routes
- `@auth-implementation-patterns` — Authentication patterns usable with SvelteKit hooks
- `@tailwind-patterns` — Styling SvelteKit apps with Tailwind CSS

Some files were not shown because too many files have changed in this diff Show More