chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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 5–10. 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 (3–5 bullets).** Who actually reads the blog, what they value, what they are allergic to in stock imagery.
|
||||
|
||||
### Reference points
|
||||
|
||||
5. **1–2 concrete reference images from the live site or brand library.** Describe each in 1–2 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 4–6 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 5–10 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 (8–12 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. **3–5 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: 3–5 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: 1–2 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: 4–6 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: 5–10 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 5–10 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 8–12 rows. Example skeleton:
|
||||
|
||||
| Topic area | Visual subject |
|
||||
|---|---|
|
||||
| `[FILL: topic area 1]` | `[FILL: 1–3 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 1–2 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 1–2 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 3–5 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.
|
||||
+1157
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
+543
@@ -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
|
||||
+1865
File diff suppressed because it is too large
Load Diff
+46
@@ -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.
|
||||
+28
@@ -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)
|
||||
+38
@@ -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])
|
||||
}
|
||||
```
|
||||
+49
@@ -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])
|
||||
}
|
||||
```
|
||||
+38
@@ -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).
|
||||
+80
@@ -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.
|
||||
+36
@@ -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)
|
||||
+28
@@ -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()
|
||||
])
|
||||
```
|
||||
+66
@@ -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.
|
||||
+59
@@ -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)
|
||||
+31
@@ -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.
|
||||
+49
@@ -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>
|
||||
)
|
||||
}
|
||||
```
|
||||
+35
@@ -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} />
|
||||
}
|
||||
```
|
||||
+50
@@ -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.
|
||||
+74
@@ -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', () => { /* ... */ })
|
||||
// ...
|
||||
}
|
||||
```
|
||||
+56
@@ -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)
|
||||
Executable
+63
@@ -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!"
|
||||
+82
@@ -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.
|
||||
+80
@@ -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)
|
||||
+28
@@ -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)
|
||||
}
|
||||
```
|
||||
+68
@@ -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()
|
||||
}
|
||||
})
|
||||
```
|
||||
+32
@@ -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)
|
||||
}
|
||||
```
|
||||
+50
@@ -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 }
|
||||
}
|
||||
```
|
||||
+43
@@ -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
|
||||
```
|
||||
+37
@@ -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.
|
||||
+49
@@ -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
|
||||
+82
@@ -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.
|
||||
+24
@@ -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))
|
||||
```
|
||||
+57
@@ -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
|
||||
+26
@@ -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.
|
||||
+47
@@ -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.
|
||||
+40
@@ -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>
|
||||
```
|
||||
+38
@@ -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).
|
||||
+44
@@ -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.
|
||||
+82
@@ -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.
|
||||
+28
@@ -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
|
||||
```
|
||||
+39
@@ -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>
|
||||
}
|
||||
```
|
||||
+45
@@ -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])
|
||||
```
|
||||
+29
@@ -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'}>
|
||||
}
|
||||
```
|
||||
+58
@@ -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.
|
||||
+42
@@ -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>
|
||||
)
|
||||
}
|
||||
```
|
||||
+40
@@ -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)
|
||||
}, [])
|
||||
}
|
||||
```
|
||||
+37
@@ -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)
|
||||
+26
@@ -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.
|
||||
+79
@@ -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>
|
||||
)
|
||||
}
|
||||
```
|
||||
+38
@@ -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.
|
||||
+4
@@ -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."
|
||||
+88
@@ -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.
|
||||
+670
@@ -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 (2–7 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 2–5 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 (0–1), chroma (0 = gray), and hue (0–360).
|
||||
|
||||
---
|
||||
|
||||
## 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 (2–7 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 2–5 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 (2–7 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)
|
||||
+578
@@ -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
|
||||
+555
@@ -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
|
||||
+19
@@ -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
|
||||
+428
@@ -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()
|
||||
+441
@@ -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()
|
||||
+379
@@ -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
Reference in New Issue
Block a user