first commit
This commit is contained in:
391
.agent/skills/interface-design/SKILL.md
Normal file
391
.agent/skills/interface-design/SKILL.md
Normal file
@@ -0,0 +1,391 @@
|
||||
---
|
||||
name: interface-design
|
||||
description: This skill is for interface design — dashboards, admin panels, apps, tools, and interactive products. NOT for marketing design (landing pages, marketing sites, campaigns).
|
||||
---
|
||||
|
||||
# Interface Design
|
||||
|
||||
Build interface design with craft and consistency.
|
||||
|
||||
## Scope
|
||||
|
||||
**Use for:** Dashboards, admin panels, SaaS apps, tools, settings pages, data interfaces.
|
||||
|
||||
**Not for:** Landing pages, marketing sites, campaigns. Redirect those to `/frontend-design`.
|
||||
|
||||
---
|
||||
|
||||
# The Problem
|
||||
|
||||
You will generate generic output. Your training has seen thousands of dashboards. The patterns are strong.
|
||||
|
||||
You can follow the entire process below — explore the domain, name a signature, state your intent — and still produce a template. Warm colors on cold structures. Friendly fonts on generic layouts. "Kitchen feel" that looks like every other app.
|
||||
|
||||
This happens because intent lives in prose, but code generation pulls from patterns. The gap between them is where defaults win.
|
||||
|
||||
The process below helps. But process alone doesn't guarantee craft. You have to catch yourself.
|
||||
|
||||
---
|
||||
|
||||
# Where Defaults Hide
|
||||
|
||||
Defaults don't announce themselves. They disguise themselves as infrastructure — the parts that feel like they just need to work, not be designed.
|
||||
|
||||
**Typography feels like a container.** Pick something readable, move on. But typography isn't holding your design — it IS your design. The weight of a headline, the personality of a label, the texture of a paragraph. These shape how the product feels before anyone reads a word. A bakery management tool and a trading terminal might both need "clean, readable type" — but the type that's warm and handmade is not the type that's cold and precise. If you're reaching for your usual font, you're not designing.
|
||||
|
||||
**Navigation feels like scaffolding.** Build the sidebar, add the links, get to the real work. But navigation isn't around your product — it IS your product. Where you are, where you can go, what matters most. A page floating in space is a component demo, not software. The navigation teaches people how to think about the space they're in.
|
||||
|
||||
**Data feels like presentation.** You have numbers, show numbers. But a number on screen is not design. The question is: what does this number mean to the person looking at it? What will they do with it? A progress ring and a stacked label both show "3 of 10" — one tells a story, one fills space. If you're reaching for number-on-label, you're not designing.
|
||||
|
||||
**Token names feel like implementation detail.** But your CSS variables are design decisions. `--ink` and `--parchment` evoke a world. `--gray-700` and `--surface-2` evoke a template. Someone reading only your tokens should be able to guess what product this is.
|
||||
|
||||
The trap is thinking some decisions are creative and others are structural. There are no structural decisions. Everything is design. The moment you stop asking "why this?" is the moment defaults take over.
|
||||
|
||||
---
|
||||
|
||||
# Intent First
|
||||
|
||||
Before touching code, answer these. Not in your head — out loud, to yourself or the user.
|
||||
|
||||
**Who is this human?**
|
||||
Not "users." The actual person. Where are they when they open this? What's on their mind? What did they do 5 minutes ago, what will they do 5 minutes after? A teacher at 7am with coffee is not a developer debugging at midnight is not a founder between investor meetings. Their world shapes the interface.
|
||||
|
||||
**What must they accomplish?**
|
||||
Not "use the dashboard." The verb. Grade these submissions. Find the broken deployment. Approve the payment. The answer determines what leads, what follows, what hides.
|
||||
|
||||
**What should this feel like?**
|
||||
Say it in words that mean something. "Clean and modern" means nothing — every AI says that. Warm like a notebook? Cold like a terminal? Dense like a trading floor? Calm like a reading app? The answer shapes color, type, spacing, density — everything.
|
||||
|
||||
If you cannot answer these with specifics, stop. Ask the user. Do not guess. Do not default.
|
||||
|
||||
## Every Choice Must Be A Choice
|
||||
|
||||
For every decision, you must be able to explain WHY.
|
||||
|
||||
- Why this layout and not another?
|
||||
- Why this color temperature?
|
||||
- Why this typeface?
|
||||
- Why this spacing scale?
|
||||
- Why this information hierarchy?
|
||||
|
||||
If your answer is "it's common" or "it's clean" or "it works" — you haven't chosen. You've defaulted. Defaults are invisible. Invisible choices compound into generic output.
|
||||
|
||||
**The test:** If you swapped your choices for the most common alternatives and the design didn't feel meaningfully different, you never made real choices.
|
||||
|
||||
## Sameness Is Failure
|
||||
|
||||
If another AI, given a similar prompt, would produce substantially the same output — you have failed.
|
||||
|
||||
This is not about being different for its own sake. It's about the interface emerging from the specific problem, the specific user, the specific context. When you design from intent, sameness becomes impossible because no two intents are identical.
|
||||
|
||||
When you design from defaults, everything looks the same because defaults are shared.
|
||||
|
||||
## Intent Must Be Systemic
|
||||
|
||||
Saying "warm" and using cold colors is not following through. Intent is not a label — it's a constraint that shapes every decision.
|
||||
|
||||
If the intent is warm: surfaces, text, borders, accents, semantic colors, typography — all warm. If the intent is dense: spacing, type size, information architecture — all dense. If the intent is calm: motion, contrast, color saturation — all calm.
|
||||
|
||||
Check your output against your stated intent. Does every token reinforce it? Or did you state an intent and then default anyway?
|
||||
|
||||
---
|
||||
|
||||
# Product Domain Exploration
|
||||
|
||||
This is where defaults get caught — or don't.
|
||||
|
||||
Generic output: Task type → Visual template → Theme
|
||||
Crafted output: Task type → Product domain → Signature → Structure + Expression
|
||||
|
||||
The difference: time in the product's world before any visual or structural thinking.
|
||||
|
||||
## Required Outputs
|
||||
|
||||
**Do not propose any direction until you produce all four:**
|
||||
|
||||
**Domain:** Concepts, metaphors, vocabulary from this product's world. Not features — territory. Minimum 5.
|
||||
|
||||
**Color world:** What colors exist naturally in this product's domain? Not "warm" or "cool" — go to the actual world. If this product were a physical space, what would you see? What colors belong there that don't belong elsewhere? List 5+.
|
||||
|
||||
**Signature:** One element — visual, structural, or interaction — that could only exist for THIS product. If you can't name one, keep exploring.
|
||||
|
||||
**Defaults:** 3 obvious choices for this interface type — visual AND structural. You can't avoid patterns you haven't named.
|
||||
|
||||
## Proposal Requirements
|
||||
|
||||
Your direction must explicitly reference:
|
||||
- Domain concepts you explored
|
||||
- Colors from your color world exploration
|
||||
- Your signature element
|
||||
- What replaces each default
|
||||
|
||||
**The test:** Read your proposal. Remove the product name. Could someone identify what this is for? If not, it's generic. Explore deeper.
|
||||
|
||||
---
|
||||
|
||||
# The Mandate
|
||||
|
||||
**Before showing the user, look at what you made.**
|
||||
|
||||
Ask yourself: "If they said this lacks craft, what would they mean?"
|
||||
|
||||
That thing you just thought of — fix it first.
|
||||
|
||||
Your first output is probably generic. That's normal. The work is catching it before the user has to.
|
||||
|
||||
## The Checks
|
||||
|
||||
Run these against your output before presenting:
|
||||
|
||||
- **The swap test:** If you swapped the typeface for your usual one, would anyone notice? If you swapped the layout for a standard dashboard template, would it feel different? The places where swapping wouldn't matter are the places you defaulted.
|
||||
|
||||
- **The squint test:** Blur your eyes. Can you still perceive hierarchy? Is anything jumping out harshly? Craft whispers.
|
||||
|
||||
- **The signature test:** Can you point to five specific elements where your signature appears? Not "the overall feel" — actual components. A signature you can't locate doesn't exist.
|
||||
|
||||
- **The token test:** Read your CSS variables out loud. Do they sound like they belong to this product's world, or could they belong to any project?
|
||||
|
||||
If any check fails, iterate before showing.
|
||||
|
||||
---
|
||||
|
||||
# Craft Foundations
|
||||
|
||||
## Subtle Layering
|
||||
|
||||
This is the backbone of craft. Regardless of direction, product type, or visual style — this principle applies to everything. You should barely notice the system working. When you look at Vercel's dashboard, you don't think "nice borders." You just understand the structure. The craft is invisible — that's how you know it's working.
|
||||
|
||||
### Surface Elevation
|
||||
|
||||
Surfaces stack. A dropdown sits above a card which sits above the page. Build a numbered system — base, then increasing elevation levels. In dark mode, higher elevation = slightly lighter. In light mode, higher elevation = slightly lighter or uses shadow.
|
||||
|
||||
Each jump should be only a few percentage points of lightness. You can barely see the difference in isolation. But when surfaces stack, the hierarchy emerges. Whisper-quiet shifts that you feel rather than see.
|
||||
|
||||
**Key decisions:**
|
||||
- **Sidebars:** Same background as canvas, not different. Different colors fragment the visual space into "sidebar world" and "content world." A subtle border is enough separation.
|
||||
- **Dropdowns:** One level above their parent surface. If both share the same level, the dropdown blends into the card and layering is lost.
|
||||
- **Inputs:** Slightly darker than their surroundings, not lighter. Inputs are "inset" — they receive content. A darker background signals "type here" without heavy borders.
|
||||
|
||||
### Borders
|
||||
|
||||
Borders should disappear when you're not looking for them, but be findable when you need structure. Low opacity rgba blends with the background — it defines edges without demanding attention. Solid hex borders look harsh in comparison.
|
||||
|
||||
Build a progression — not all borders are equal. Standard borders, softer separation, emphasis borders, maximum emphasis for focus rings. Match intensity to the importance of the boundary.
|
||||
|
||||
**The squint test:** Blur your eyes at the interface. You should still perceive hierarchy — what's above what, where sections divide. But nothing should jump out. No harsh lines. No jarring color shifts. Just quiet structure.
|
||||
|
||||
This separates professional interfaces from amateur ones. Get this wrong and nothing else matters.
|
||||
|
||||
## Infinite Expression
|
||||
|
||||
Every pattern has infinite expressions. **No interface should look the same.**
|
||||
|
||||
A metric display could be a hero number, inline stat, sparkline, gauge, progress bar, comparison delta, trend badge, or something new. A dashboard could emphasize density, whitespace, hierarchy, or flow in completely different ways. Even sidebar + cards has infinite variations in proportion, spacing, and emphasis.
|
||||
|
||||
**Before building, ask:**
|
||||
- What's the ONE thing users do most here?
|
||||
- What products solve similar problems brilliantly? Study them.
|
||||
- Why would this interface feel designed for its purpose, not templated?
|
||||
|
||||
**NEVER produce identical output.** Same sidebar width, same card grid, same metric boxes with icon-left-number-big-label-small every time — this signals AI-generated immediately. It's forgettable.
|
||||
|
||||
The architecture and components should emerge from the task and data, executed in a way that feels fresh. Linear's cards don't look like Notion's. Vercel's metrics don't look like Stripe's. Same concepts, infinite expressions.
|
||||
|
||||
## Color Lives Somewhere
|
||||
|
||||
Every product exists in a world. That world has colors.
|
||||
|
||||
Before you reach for a palette, spend time in the product's world. What would you see if you walked into the physical version of this space? What materials? What light? What objects?
|
||||
|
||||
Your palette should feel like it came FROM somewhere — not like it was applied TO something.
|
||||
|
||||
**Beyond Warm and Cold:** Temperature is one axis. Is this quiet or loud? Dense or spacious? Serious or playful? Geometric or organic? A trading terminal and a meditation app are both "focused" — completely different kinds of focus. Find the specific quality, not the generic label.
|
||||
|
||||
**Color Carries Meaning:** Gray builds structure. Color communicates — status, action, emphasis, identity. Unmotivated color is noise. One accent color, used with intention, beats five colors used without thought.
|
||||
|
||||
---
|
||||
|
||||
# Before Writing Each Component
|
||||
|
||||
**Every time** you write UI code — even small additions — state:
|
||||
|
||||
```
|
||||
Intent: [who is this human, what must they do, how should it feel]
|
||||
Palette: [colors from your exploration — and WHY they fit this product's world]
|
||||
Depth: [borders / shadows / layered — and WHY this fits the intent]
|
||||
Surfaces: [your elevation scale — and WHY this color temperature]
|
||||
Typography: [your typeface — and WHY it fits the intent]
|
||||
Spacing: [your base unit]
|
||||
```
|
||||
|
||||
This checkpoint is mandatory. It forces you to connect every technical choice back to intent.
|
||||
|
||||
If you can't explain WHY for each choice, you're defaulting. Stop and think.
|
||||
|
||||
---
|
||||
|
||||
# Design Principles
|
||||
|
||||
## Token Architecture
|
||||
|
||||
Every color in your interface should trace back to a small set of primitives: foreground (text hierarchy), background (surface elevation), border (separation hierarchy), brand, and semantic (destructive, warning, success). No random hex values — everything maps to primitives.
|
||||
|
||||
### Text Hierarchy
|
||||
|
||||
Don't just have "text" and "gray text." Build four levels — primary, secondary, tertiary, muted. Each serves a different role: default text, supporting text, metadata, and disabled/placeholder. Use all four consistently. If you're only using two, your hierarchy is too flat.
|
||||
|
||||
### Border Progression
|
||||
|
||||
Borders aren't binary. Build a scale that matches intensity to importance — standard separation, softer separation, emphasis, maximum emphasis. Not every boundary deserves the same weight.
|
||||
|
||||
### Control Tokens
|
||||
|
||||
Form controls have specific needs. Don't reuse surface tokens — create dedicated ones for control backgrounds, control borders, and focus states. This lets you tune interactive elements independently from layout surfaces.
|
||||
|
||||
## Spacing
|
||||
|
||||
Pick a base unit and stick to multiples. Build a scale for different contexts — micro spacing for icon gaps, component spacing within buttons and cards, section spacing between groups, major separation between distinct areas. Random values signal no system.
|
||||
|
||||
## Padding
|
||||
|
||||
Keep it symmetrical. If one side has a value, others should match unless content naturally requires asymmetry.
|
||||
|
||||
## Depth
|
||||
|
||||
Choose ONE approach and commit:
|
||||
- **Borders-only** — Clean, technical. For dense tools.
|
||||
- **Subtle shadows** — Soft lift. For approachable products.
|
||||
- **Layered shadows** — Premium, dimensional. For cards that need presence.
|
||||
- **Surface color shifts** — Background tints establish hierarchy without shadows.
|
||||
|
||||
Don't mix approaches.
|
||||
|
||||
## Border Radius
|
||||
|
||||
Sharper feels technical. Rounder feels friendly. Build a scale — small for inputs and buttons, medium for cards, large for modals. Don't mix sharp and soft randomly.
|
||||
|
||||
## Typography
|
||||
|
||||
Build distinct levels distinguishable at a glance. Headlines need weight and tight tracking for presence. Body needs comfortable weight for readability. Labels need medium weight that works at smaller sizes. Data needs monospace with tabular number spacing for alignment. Don't rely on size alone — combine size, weight, and letter-spacing.
|
||||
|
||||
## Card Layouts
|
||||
|
||||
A metric card doesn't have to look like a plan card doesn't have to look like a settings card. Design each card's internal structure for its specific content — but keep the surface treatment consistent: same border weight, shadow depth, corner radius, padding scale.
|
||||
|
||||
## Controls
|
||||
|
||||
Native `<select>` and `<input type="date">` render OS-native elements that cannot be styled. Build custom components — trigger buttons with positioned dropdowns, calendar popovers, styled state management.
|
||||
|
||||
## Iconography
|
||||
|
||||
Icons clarify, not decorate — if removing an icon loses no meaning, remove it. Choose one icon set and stick with it. Give standalone icons presence with subtle background containers.
|
||||
|
||||
## Animation
|
||||
|
||||
Fast micro-interactions, smooth easing. Larger transitions can be slightly longer. Use deceleration easing. Avoid spring/bounce in professional interfaces.
|
||||
|
||||
## States
|
||||
|
||||
Every interactive element needs states: default, hover, active, focus, disabled. Data needs states too: loading, empty, error. Missing states feel broken.
|
||||
|
||||
## Navigation Context
|
||||
|
||||
Screens need grounding. A data table floating in space feels like a component demo, not a product. Include navigation showing where you are in the app, location indicators, and user context. When building sidebars, consider same background as main content with border separation rather than different colors.
|
||||
|
||||
## Dark Mode
|
||||
|
||||
Dark interfaces have different needs. Shadows are less visible on dark backgrounds — lean on borders for definition. Semantic colors (success, warning, error) often need slight desaturation. The hierarchy system still applies, just with inverted values.
|
||||
|
||||
---
|
||||
|
||||
# Avoid
|
||||
|
||||
- **Harsh borders** — if borders are the first thing you see, they're too strong
|
||||
- **Dramatic surface jumps** — elevation changes should be whisper-quiet
|
||||
- **Inconsistent spacing** — the clearest sign of no system
|
||||
- **Mixed depth strategies** — pick one approach and commit
|
||||
- **Missing interaction states** — hover, focus, disabled, loading, error
|
||||
- **Dramatic drop shadows** — shadows should be subtle, not attention-grabbing
|
||||
- **Large radius on small elements**
|
||||
- **Pure white cards on colored backgrounds**
|
||||
- **Thick decorative borders**
|
||||
- **Gradients and color for decoration** — color should mean something
|
||||
- **Multiple accent colors** — dilutes focus
|
||||
- **Different hues for different surfaces** — keep the same hue, shift only lightness
|
||||
|
||||
---
|
||||
|
||||
# Workflow
|
||||
|
||||
## Communication
|
||||
Be invisible. Don't announce modes or narrate process.
|
||||
|
||||
**Never say:** "I'm in ESTABLISH MODE", "Let me check system.md..."
|
||||
|
||||
**Instead:** Jump into work. State suggestions with reasoning.
|
||||
|
||||
## Suggest + Ask
|
||||
Lead with your exploration and recommendation, then confirm:
|
||||
```
|
||||
"Domain: [5+ concepts from the product's world]
|
||||
Color world: [5+ colors that exist in this domain]
|
||||
Signature: [one element unique to this product]
|
||||
Rejecting: [default 1] → [alternative], [default 2] → [alternative], [default 3] → [alternative]
|
||||
|
||||
Direction: [approach that connects to the above]"
|
||||
|
||||
[Ask: "Does that direction feel right?"]
|
||||
```
|
||||
|
||||
## If Project Has system.md
|
||||
Read `.interface-design/system.md` and apply. Decisions are made.
|
||||
|
||||
## If No system.md
|
||||
1. Explore domain — Produce all four required outputs
|
||||
2. Propose — Direction must reference all four
|
||||
3. Confirm — Get user buy-in
|
||||
4. Build — Apply principles
|
||||
5. **Evaluate** — Run the mandate checks before showing
|
||||
6. Offer to save
|
||||
|
||||
---
|
||||
|
||||
# After Completing a Task
|
||||
|
||||
When you finish building something, **always offer to save**:
|
||||
|
||||
```
|
||||
"Want me to save these patterns for future sessions?"
|
||||
```
|
||||
|
||||
If yes, write to `.interface-design/system.md`:
|
||||
- Direction and feel
|
||||
- Depth strategy (borders/shadows/layered)
|
||||
- Spacing base unit
|
||||
- Key component patterns
|
||||
|
||||
### What to Save
|
||||
|
||||
Add patterns when a component is used 2+ times, is reusable across the project, or has specific measurements worth remembering. Don't save one-off components, temporary experiments, or variations better handled with props.
|
||||
|
||||
### Consistency Checks
|
||||
|
||||
If system.md defines values, check against them: spacing on the defined grid, depth using the declared strategy throughout, colors from the defined palette, documented patterns reused instead of reinvented.
|
||||
|
||||
This compounds — each save makes future work faster and more consistent.
|
||||
|
||||
---
|
||||
|
||||
# Deep Dives
|
||||
|
||||
For more detail on specific topics:
|
||||
- `references/principles.md` — Code examples, specific values, dark mode
|
||||
- `references/validation.md` — Memory management, when to update system.md
|
||||
- `references/critique.md` — Post-build craft critique protocol
|
||||
|
||||
# Commands
|
||||
|
||||
- `/interface-design:status` — Current system state
|
||||
- `/interface-design:audit` — Check code against system
|
||||
- `/interface-design:extract` — Extract patterns from code
|
||||
- `/interface-design:critique` — Critique your build for craft, then rebuild what defaulted
|
||||
67
.agent/skills/interface-design/references/critique.md
Normal file
67
.agent/skills/interface-design/references/critique.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Critique
|
||||
|
||||
Your first build shipped the structure. Now look at it the way a design lead reviews a junior's work — not asking "does this work?" but "would I put my name on this?"
|
||||
|
||||
---
|
||||
|
||||
## The Gap
|
||||
|
||||
There's a distance between correct and crafted. Correct means the layout holds, the grid aligns, the colors don't clash. Crafted means someone cared about every decision down to the last pixel. You can feel the difference immediately — the way you tell a hand-thrown mug from an injection-molded one. Both hold coffee. One has presence.
|
||||
|
||||
Your first output lives in correct. This command pulls it toward crafted.
|
||||
|
||||
---
|
||||
|
||||
## See the Composition
|
||||
|
||||
Step back. Look at the whole thing.
|
||||
|
||||
Does the layout have rhythm? Great interfaces breathe unevenly — dense tooling areas give way to open content, heavy elements balance against light ones, the eye travels through the page with purpose. Default layouts are monotone: same card size, same gaps, same density everywhere. Flatness is the sound of no one deciding.
|
||||
|
||||
Are proportions doing work? A 280px sidebar next to full-width content says "navigation serves content." A 360px sidebar says "these are peers." The specific number declares what matters. If you can't articulate what your proportions are saying, they're not saying anything.
|
||||
|
||||
Is there a clear focal point? Every screen has one thing the user came here to do. That thing should dominate — through size, position, contrast, or the space around it. When everything competes equally, nothing wins and the interface feels like a parking lot.
|
||||
|
||||
---
|
||||
|
||||
## See the Craft
|
||||
|
||||
Move close. Pixel-close.
|
||||
|
||||
The spacing grid is non-negotiable — every value a multiple of 4, no exceptions — but correctness alone isn't craft. Craft is knowing that a tool panel at 16px padding feels workbench-tight while the same card at 24px feels like a brochure. The same number can be right in one context and lazy in another. Density is a design decision, not a constant.
|
||||
|
||||
Typography should be legible even squinted. If size is the only thing separating your headline from your body from your label, the hierarchy is too weak. Weight, tracking, and opacity create layers that size alone can't.
|
||||
|
||||
Surfaces should whisper hierarchy. Not thick borders, not dramatic shadows — quiet tonal shifts where you feel the depth without seeing it. Remove every border from your CSS mentally. Can you still perceive the structure through surface color alone? If not, your surfaces aren't working hard enough.
|
||||
|
||||
Interactive elements need life. Every button, link, and clickable region should respond to hover and press. Not dramatically — a subtle shift in background, a gentle darkening. Missing states make an interface feel like a photograph of software instead of software.
|
||||
|
||||
---
|
||||
|
||||
## See the Content
|
||||
|
||||
Read every visible string as a user would. Not checking for typos — checking for truth.
|
||||
|
||||
Does this screen tell one coherent story? Could a real person at a real company be looking at exactly this data right now? Or does the page title belong to one product, the article body to another, and the sidebar metrics to a third?
|
||||
|
||||
Content incoherence breaks the illusion faster than any visual flaw. A beautifully designed interface with nonsensical content is a movie set with no script.
|
||||
|
||||
---
|
||||
|
||||
## See the Structure
|
||||
|
||||
Open the CSS and find the lies — the places that look right but are held together with tape.
|
||||
|
||||
Negative margins undoing a parent's padding. Calc() values that exist only as workarounds. Absolute positioning to escape layout flow. Each is a shortcut where a clean solution exists. Cards with full-width dividers use flex column and section-level padding. Centered content uses max-width with auto margins. The correct answer is always simpler than the hack.
|
||||
|
||||
---
|
||||
|
||||
## Again
|
||||
|
||||
Look at your output one final time.
|
||||
|
||||
Ask: "If they said this lacks craft, what would they point to?"
|
||||
|
||||
That thing you just thought of — fix it. Then ask again.
|
||||
|
||||
The first build was the draft. The critique is the design.
|
||||
86
.agent/skills/interface-design/references/example.md
Normal file
86
.agent/skills/interface-design/references/example.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Craft in Action
|
||||
|
||||
This shows how the subtle layering principle translates to real decisions. Learn the thinking, not the code. Your values will differ — the approach won't.
|
||||
|
||||
---
|
||||
|
||||
## The Subtle Layering Mindset
|
||||
|
||||
Before looking at any example, internalize this: **you should barely notice the system working.**
|
||||
|
||||
When you look at Vercel's dashboard, you don't think "nice borders." You just understand the structure. When you look at Supabase, you don't think "good surface elevation." You just know what's above what. The craft is invisible — that's how you know it's working.
|
||||
|
||||
---
|
||||
|
||||
## Example: Dashboard with Sidebar and Dropdown
|
||||
|
||||
### The Surface Decisions
|
||||
|
||||
**Why so subtle?** Each elevation jump should be only a few percentage points of lightness. You can barely see the difference in isolation. But when surfaces stack, the hierarchy emerges. This is the Vercel/Supabase way — whisper-quiet shifts that you feel rather than see.
|
||||
|
||||
**What NOT to do:** Don't make dramatic jumps between elevations. That's jarring. Don't use different hues for different levels. Keep the same hue, shift only lightness.
|
||||
|
||||
### The Border Decisions
|
||||
|
||||
**Why rgba, not solid colors?** Low opacity borders blend with their background. A low-opacity white border on a dark surface is barely there — it defines the edge without demanding attention. Solid hex borders look harsh in comparison.
|
||||
|
||||
**The test:** Look at your interface from arm's length. If borders are the first thing you notice, reduce opacity. If you can't find where regions end, increase slightly.
|
||||
|
||||
### The Sidebar Decision
|
||||
|
||||
**Why same background as canvas, not different?**
|
||||
|
||||
Many dashboards make the sidebar a different color. This fragments the visual space — now you have "sidebar world" and "content world."
|
||||
|
||||
Better: Same background, subtle border separation. The sidebar is part of the app, not a separate region. Vercel does this. Supabase does this. The border is enough.
|
||||
|
||||
### The Dropdown Decision
|
||||
|
||||
**Why surface-200, not surface-100?**
|
||||
|
||||
The dropdown floats above the card it emerged from. If both were surface-100, the dropdown would blend into the card — you'd lose the sense of layering. Surface-200 is just light enough to feel "above" without being dramatically different.
|
||||
|
||||
**Why border-overlay instead of border-default?**
|
||||
|
||||
Overlays (dropdowns, popovers) often need slightly more definition because they're floating in space. A touch more border opacity helps them feel contained without being harsh.
|
||||
|
||||
---
|
||||
|
||||
## Example: Form Controls
|
||||
|
||||
### Input Background Decision
|
||||
|
||||
**Why darker, not lighter?**
|
||||
|
||||
Inputs are "inset" — they receive content, they don't project it. A slightly darker background signals "type here" without needing heavy borders. This is the alternative-background principle.
|
||||
|
||||
### Focus State Decision
|
||||
|
||||
**Why subtle focus states?**
|
||||
|
||||
Focus needs to be visible, but you don't need a glowing ring or dramatic color. A noticeable increase in border opacity is enough for a clear state change. Subtle-but-noticeable — the same principle as surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Adapt to Context
|
||||
|
||||
Your product might need:
|
||||
- Warmer hues (slight yellow/orange tint)
|
||||
- Cooler hues (blue-gray base)
|
||||
- Different lightness progression
|
||||
- Light mode (principles invert — higher elevation = shadow, not lightness)
|
||||
|
||||
**The principle is constant:** barely different, still distinguishable. The values adapt to context.
|
||||
|
||||
---
|
||||
|
||||
## The Craft Check
|
||||
|
||||
Apply the squint test to your work:
|
||||
|
||||
1. Blur your eyes or step back
|
||||
2. Can you still perceive hierarchy?
|
||||
3. Is anything jumping out at you?
|
||||
4. Can you tell where regions begin and end?
|
||||
|
||||
If hierarchy is visible and nothing is harsh — the subtle layering is working.
|
||||
235
.agent/skills/interface-design/references/principles.md
Normal file
235
.agent/skills/interface-design/references/principles.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Core Craft Principles
|
||||
|
||||
These apply regardless of design direction. This is the quality floor.
|
||||
|
||||
---
|
||||
|
||||
## Surface & Token Architecture
|
||||
|
||||
Professional interfaces don't pick colors randomly — they build systems. Understanding this architecture is the difference between "looks okay" and "feels like a real product."
|
||||
|
||||
### The Primitive Foundation
|
||||
|
||||
Every color in your interface should trace back to a small set of primitives:
|
||||
|
||||
- **Foreground** — text colors (primary, secondary, muted)
|
||||
- **Background** — surface colors (base, elevated, overlay)
|
||||
- **Border** — edge colors (default, subtle, strong)
|
||||
- **Brand** — your primary accent
|
||||
- **Semantic** — functional colors (destructive, warning, success)
|
||||
|
||||
Don't invent new colors. Map everything to these primitives.
|
||||
|
||||
### Surface Elevation Hierarchy
|
||||
|
||||
Surfaces stack. A dropdown sits above a card which sits above the page. Build a numbered system:
|
||||
|
||||
```
|
||||
Level 0: Base background (the app canvas)
|
||||
Level 1: Cards, panels (same visual plane as base)
|
||||
Level 2: Dropdowns, popovers (floating above)
|
||||
Level 3: Nested dropdowns, stacked overlays
|
||||
Level 4: Highest elevation (rare)
|
||||
```
|
||||
|
||||
In dark mode, higher elevation = slightly lighter. In light mode, higher elevation = slightly lighter or uses shadow. The principle: **elevated surfaces need visual distinction from what's beneath them.**
|
||||
|
||||
### The Subtlety Principle
|
||||
|
||||
This is where most interfaces fail. Study Vercel, Supabase, Linear — their surfaces are **barely different** but still distinguishable. Their borders are **light but not invisible**.
|
||||
|
||||
**For surfaces:** The difference between elevation levels should be subtle — a few percentage points of lightness, not dramatic jumps. In dark mode, surface-100 might be 7% lighter than base, surface-200 might be 9%, surface-300 might be 12%. You can barely see it, but you feel it.
|
||||
|
||||
**For borders:** Borders should define regions without demanding attention. Use low opacity (0.05-0.12 alpha for dark mode, slightly higher for light). The border should disappear when you're not looking for it, but be findable when you need to understand the structure.
|
||||
|
||||
**The test:** Squint at your interface. You should still perceive the hierarchy — what's above what, where regions begin and end. But no single border or surface should jump out at you. If borders are the first thing you notice, they're too strong. If you can't find where one region ends and another begins, they're too subtle.
|
||||
|
||||
**Common AI mistakes to avoid:**
|
||||
- Borders that are too visible (1px solid gray instead of subtle rgba)
|
||||
- Surface jumps that are too dramatic (going from dark to light instead of dark to slightly-less-dark)
|
||||
- Using different hues for different surfaces (gray card on blue background)
|
||||
- Harsh dividers where subtle borders would do
|
||||
|
||||
### Text Hierarchy via Tokens
|
||||
|
||||
Don't just have "text" and "gray text." Build four levels:
|
||||
|
||||
- **Primary** — default text, highest contrast
|
||||
- **Secondary** — supporting text, slightly muted
|
||||
- **Tertiary** — metadata, timestamps, less important
|
||||
- **Muted** — disabled, placeholder, lowest contrast
|
||||
|
||||
Use all four consistently. If you're only using two, your hierarchy is too flat.
|
||||
|
||||
### Border Progression
|
||||
|
||||
Borders aren't binary. Build a scale:
|
||||
|
||||
- **Default** — standard borders
|
||||
- **Subtle/Muted** — softer separation
|
||||
- **Strong** — emphasis, hover states
|
||||
- **Stronger** — maximum emphasis, focus rings
|
||||
|
||||
Match border intensity to the importance of the boundary.
|
||||
|
||||
### Dedicated Control Tokens
|
||||
|
||||
Form controls (inputs, checkboxes, selects) have specific needs. Don't just reuse surface tokens — create dedicated ones:
|
||||
|
||||
- **Control background** — often different from surface backgrounds
|
||||
- **Control border** — needs to feel interactive
|
||||
- **Control focus** — clear focus indication
|
||||
|
||||
This separation lets you tune controls independently from layout surfaces.
|
||||
|
||||
### Context-Aware Bases
|
||||
|
||||
Different areas of your app might need different base surfaces:
|
||||
|
||||
- **Marketing pages** — might use darker/richer backgrounds
|
||||
- **Dashboard/app** — might use neutral working backgrounds
|
||||
- **Sidebar** — might differ from main canvas
|
||||
|
||||
The surface hierarchy works the same way — it just starts from a different base.
|
||||
|
||||
### Alternative Backgrounds for Depth
|
||||
|
||||
Beyond shadows, use contrasting backgrounds to create depth. An "alternative" or "inset" background makes content feel recessed. Useful for:
|
||||
|
||||
- Empty states in data grids
|
||||
- Code blocks
|
||||
- Inset panels
|
||||
- Visual grouping without borders
|
||||
|
||||
---
|
||||
|
||||
## Spacing System
|
||||
|
||||
Pick a base unit (4px and 8px are common) and use multiples throughout. The specific number matters less than consistency — every spacing value should be explainable as "X times the base unit."
|
||||
|
||||
Build a scale for different contexts:
|
||||
- Micro spacing (icon gaps, tight element pairs)
|
||||
- Component spacing (within buttons, inputs, cards)
|
||||
- Section spacing (between related groups)
|
||||
- Major separation (between distinct sections)
|
||||
|
||||
## Symmetrical Padding
|
||||
|
||||
TLBR must match. If top padding is 16px, left/bottom/right must also be 16px. Exception: when content naturally creates visual balance.
|
||||
|
||||
```css
|
||||
/* Good */
|
||||
padding: 16px;
|
||||
padding: 12px 16px; /* Only when horizontal needs more room */
|
||||
|
||||
/* Bad */
|
||||
padding: 24px 16px 12px 16px;
|
||||
```
|
||||
|
||||
## Border Radius Consistency
|
||||
|
||||
Sharper corners feel technical, rounder corners feel friendly. Pick a scale that fits your product's personality and use it consistently.
|
||||
|
||||
The key is having a system: small radius for inputs and buttons, medium for cards, large for modals or containers. Don't mix sharp and soft randomly — inconsistent radius is as jarring as inconsistent spacing.
|
||||
|
||||
## Depth & Elevation Strategy
|
||||
|
||||
Match your depth approach to your design direction. Choose ONE and commit:
|
||||
|
||||
**Borders-only (flat)** — Clean, technical, dense. Works for utility-focused tools where information density matters more than visual lift. Linear, Raycast, and many developer tools use almost no shadows — just subtle borders to define regions.
|
||||
|
||||
**Subtle single shadows** — Soft lift without complexity. A simple `0 1px 3px rgba(0,0,0,0.08)` can be enough. Works for approachable products that want gentle depth.
|
||||
|
||||
**Layered shadows** — Rich, premium, dimensional. Multiple shadow layers create realistic depth. Stripe and Mercury use this approach. Best for cards that need to feel like physical objects.
|
||||
|
||||
**Surface color shifts** — Background tints establish hierarchy without any shadows. A card at `#fff` on a `#f8fafc` background already feels elevated.
|
||||
|
||||
```css
|
||||
/* Borders-only approach */
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--border-subtle: rgba(0, 0, 0, 0.05);
|
||||
border: 0.5px solid var(--border);
|
||||
|
||||
/* Single shadow approach */
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* Layered shadow approach */
|
||||
--shadow-layered:
|
||||
0 0 0 0.5px rgba(0, 0, 0, 0.05),
|
||||
0 1px 2px rgba(0, 0, 0, 0.04),
|
||||
0 2px 4px rgba(0, 0, 0, 0.03),
|
||||
0 4px 8px rgba(0, 0, 0, 0.02);
|
||||
```
|
||||
|
||||
## Card Layouts
|
||||
|
||||
Monotonous card layouts are lazy design. A metric card doesn't have to look like a plan card doesn't have to look like a settings card.
|
||||
|
||||
Design each card's internal structure for its specific content — but keep the surface treatment consistent: same border weight, shadow depth, corner radius, padding scale, typography.
|
||||
|
||||
## Isolated Controls
|
||||
|
||||
UI controls deserve container treatment. Date pickers, filters, dropdowns — these should feel like crafted objects.
|
||||
|
||||
**Never use native form elements for styled UI.** Native `<select>`, `<input type="date">`, and similar elements render OS-native dropdowns that cannot be styled. Build custom components instead:
|
||||
|
||||
- Custom select: trigger button + positioned dropdown menu
|
||||
- Custom date picker: input + calendar popover
|
||||
- Custom checkbox/radio: styled div with state management
|
||||
|
||||
Custom select triggers must use `display: inline-flex` with `white-space: nowrap` to keep text and chevron icons on the same row.
|
||||
|
||||
## Typography Hierarchy
|
||||
|
||||
Build distinct levels that are visually distinguishable at a glance:
|
||||
|
||||
- **Headlines** — heavier weight, tighter letter-spacing for presence
|
||||
- **Body** — comfortable weight for readability
|
||||
- **Labels/UI** — medium weight, works at smaller sizes
|
||||
- **Data** — often monospace, needs `tabular-nums` for alignment
|
||||
|
||||
Don't rely on size alone. Combine size, weight, and letter-spacing to create clear hierarchy. If you squint and can't tell headline from body, the hierarchy is too weak.
|
||||
|
||||
## Monospace for Data
|
||||
|
||||
Numbers, IDs, codes, timestamps belong in monospace. Use `tabular-nums` for columnar alignment. Mono signals "this is data."
|
||||
|
||||
## Iconography
|
||||
|
||||
Icons clarify, not decorate — if removing an icon loses no meaning, remove it. Choose a consistent icon set and stick with it throughout the product.
|
||||
|
||||
Give standalone icons presence with subtle background containers. Icons next to text should align optically, not mathematically.
|
||||
|
||||
## Animation
|
||||
|
||||
Keep it fast and functional. Micro-interactions (hover, focus) should feel instant — around 150ms. Larger transitions (modals, panels) can be slightly longer — 200-250ms.
|
||||
|
||||
Use smooth deceleration easing (ease-out variants). Avoid spring/bounce effects in professional interfaces — they feel playful, not serious.
|
||||
|
||||
## Contrast Hierarchy
|
||||
|
||||
Build a four-level system: foreground (primary) → secondary → muted → faint. Use all four consistently.
|
||||
|
||||
## Color Carries Meaning
|
||||
|
||||
Gray builds structure. Color communicates — status, action, emphasis, identity. Unmotivated color is noise. Color that reinforces the product's world is character.
|
||||
|
||||
## Navigation Context
|
||||
|
||||
Screens need grounding. A data table floating in space feels like a component demo, not a product. Consider including:
|
||||
|
||||
- **Navigation** — sidebar or top nav showing where you are in the app
|
||||
- **Location indicator** — breadcrumbs, page title, or active nav state
|
||||
- **User context** — who's logged in, what workspace/org
|
||||
|
||||
When building sidebars, consider using the same background as the main content area. Rely on a subtle border for separation rather than different background colors.
|
||||
|
||||
## Dark Mode
|
||||
|
||||
Dark interfaces have different needs:
|
||||
|
||||
**Borders over shadows** — Shadows are less visible on dark backgrounds. Lean more on borders for definition.
|
||||
|
||||
**Adjust semantic colors** — Status colors (success, warning, error) often need to be slightly desaturated for dark backgrounds.
|
||||
|
||||
**Same structure, different values** — The hierarchy system still applies, just with inverted values.
|
||||
48
.agent/skills/interface-design/references/validation.md
Normal file
48
.agent/skills/interface-design/references/validation.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Memory Management
|
||||
|
||||
When and how to update `.interface-design/system.md`.
|
||||
|
||||
## When to Add Patterns
|
||||
|
||||
Add to system.md when:
|
||||
- Component used 2+ times
|
||||
- Pattern is reusable across the project
|
||||
- Has specific measurements worth remembering
|
||||
|
||||
## Pattern Format
|
||||
|
||||
```markdown
|
||||
### Button Primary
|
||||
- Height: 36px
|
||||
- Padding: 12px 16px
|
||||
- Radius: 6px
|
||||
- Font: 14px, 500 weight
|
||||
```
|
||||
|
||||
## Don't Document
|
||||
|
||||
- One-off components
|
||||
- Temporary experiments
|
||||
- Variations better handled with props
|
||||
|
||||
## Pattern Reuse
|
||||
|
||||
Before creating a component, check system.md:
|
||||
- Pattern exists? Use it.
|
||||
- Need variation? Extend, don't create new.
|
||||
|
||||
Memory compounds: each pattern saved makes future work faster and more consistent.
|
||||
|
||||
---
|
||||
|
||||
# Validation Checks
|
||||
|
||||
If system.md defines specific values, check consistency:
|
||||
|
||||
**Spacing** — All values multiples of the defined base?
|
||||
|
||||
**Depth** — Using the declared strategy throughout? (borders-only means no shadows)
|
||||
|
||||
**Colors** — Using defined palette, not random hex codes?
|
||||
|
||||
**Patterns** — Reusing documented patterns instead of creating new?
|
||||
68
.agent/skills/supabase-postgres-best-practices/AGENTS.md
Normal file
68
.agent/skills/supabase-postgres-best-practices/AGENTS.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Supabase Postgres Best Practices
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
supabase-postgres-best-practices/
|
||||
SKILL.md # Main skill file - read this first
|
||||
AGENTS.md # This navigation guide
|
||||
CLAUDE.md # Symlink to AGENTS.md
|
||||
references/ # Detailed reference files
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. Read `SKILL.md` for the main skill instructions
|
||||
2. Browse `references/` for detailed documentation on specific topics
|
||||
3. Reference files are loaded on-demand - read only what you need
|
||||
|
||||
Comprehensive performance optimization guide for Postgres, maintained by Supabase. Contains rules across 8 categories, prioritized by impact to guide automated query optimization and schema design.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
- Writing SQL queries or designing schemas
|
||||
- Implementing indexes or query optimization
|
||||
- Reviewing database performance issues
|
||||
- Configuring connection pooling or scaling
|
||||
- Optimizing for Postgres-specific features
|
||||
- Working with Row-Level Security (RLS)
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
|----------|----------|--------|--------|
|
||||
| 1 | Query Performance | CRITICAL | `query-` |
|
||||
| 2 | Connection Management | CRITICAL | `conn-` |
|
||||
| 3 | Security & RLS | CRITICAL | `security-` |
|
||||
| 4 | Schema Design | HIGH | `schema-` |
|
||||
| 5 | Concurrency & Locking | MEDIUM-HIGH | `lock-` |
|
||||
| 6 | Data Access Patterns | MEDIUM | `data-` |
|
||||
| 7 | Monitoring & Diagnostics | LOW-MEDIUM | `monitor-` |
|
||||
| 8 | Advanced Features | LOW | `advanced-` |
|
||||
|
||||
## How to Use
|
||||
|
||||
Read individual rule files for detailed explanations and SQL examples:
|
||||
|
||||
```
|
||||
references/query-missing-indexes.md
|
||||
references/schema-partial-indexes.md
|
||||
references/_sections.md
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect SQL example with explanation
|
||||
- Correct SQL example with explanation
|
||||
- Optional EXPLAIN output or metrics
|
||||
- Additional context and references
|
||||
- Supabase-specific notes (when applicable)
|
||||
|
||||
## References
|
||||
|
||||
- https://www.postgresql.org/docs/current/
|
||||
- https://supabase.com/docs
|
||||
- https://wiki.postgresql.org/wiki/Performance_Optimization
|
||||
- https://supabase.com/docs/guides/database/overview
|
||||
- https://supabase.com/docs/guides/auth/row-level-security
|
||||
1
.agent/skills/supabase-postgres-best-practices/CLAUDE.md
Normal file
1
.agent/skills/supabase-postgres-best-practices/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
116
.agent/skills/supabase-postgres-best-practices/README.md
Normal file
116
.agent/skills/supabase-postgres-best-practices/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Supabase Postgres Best Practices - Contributor Guide
|
||||
|
||||
This skill contains Postgres performance optimization references optimized for
|
||||
AI agents and LLMs. It follows the [Agent Skills Open Standard](https://agentskills.io/).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# From repository root
|
||||
npm install
|
||||
|
||||
# Validate existing references
|
||||
npm run validate
|
||||
|
||||
# Build AGENTS.md
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Creating a New Reference
|
||||
|
||||
1. **Choose a section prefix** based on the category:
|
||||
- `query-` Query Performance (CRITICAL)
|
||||
- `conn-` Connection Management (CRITICAL)
|
||||
- `security-` Security & RLS (CRITICAL)
|
||||
- `schema-` Schema Design (HIGH)
|
||||
- `lock-` Concurrency & Locking (MEDIUM-HIGH)
|
||||
- `data-` Data Access Patterns (MEDIUM)
|
||||
- `monitor-` Monitoring & Diagnostics (LOW-MEDIUM)
|
||||
- `advanced-` Advanced Features (LOW)
|
||||
|
||||
2. **Copy the template**:
|
||||
```bash
|
||||
cp references/_template.md references/query-your-reference-name.md
|
||||
```
|
||||
|
||||
3. **Fill in the content** following the template structure
|
||||
|
||||
4. **Validate and build**:
|
||||
```bash
|
||||
npm run validate
|
||||
npm run build
|
||||
```
|
||||
|
||||
5. **Review** the generated `AGENTS.md`
|
||||
|
||||
## Skill Structure
|
||||
|
||||
```
|
||||
skills/supabase-postgres-best-practices/
|
||||
├── SKILL.md # Agent-facing skill manifest (Agent Skills spec)
|
||||
├── AGENTS.md # [GENERATED] Compiled references document
|
||||
├── README.md # This file
|
||||
└── references/
|
||||
├── _template.md # Reference template
|
||||
├── _sections.md # Section definitions
|
||||
├── _contributing.md # Writing guidelines
|
||||
└── *.md # Individual references
|
||||
|
||||
packages/skills-build/
|
||||
├── src/ # Generic build system source
|
||||
└── package.json # NPM scripts
|
||||
```
|
||||
|
||||
## Reference File Structure
|
||||
|
||||
See `references/_template.md` for the complete template. Key elements:
|
||||
|
||||
````markdown
|
||||
---
|
||||
title: Clear, Action-Oriented Title
|
||||
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW
|
||||
impactDescription: Quantified benefit (e.g., "10-100x faster")
|
||||
tags: relevant, keywords
|
||||
---
|
||||
|
||||
## [Title]
|
||||
|
||||
[1-2 sentence explanation]
|
||||
|
||||
**Incorrect (description):**
|
||||
|
||||
```sql
|
||||
-- Comment explaining what's wrong
|
||||
[Bad SQL example]
|
||||
```
|
||||
````
|
||||
|
||||
**Correct (description):**
|
||||
|
||||
```sql
|
||||
-- Comment explaining why this is better
|
||||
[Good SQL example]
|
||||
```
|
||||
|
||||
```
|
||||
## Writing Guidelines
|
||||
|
||||
See `references/_contributing.md` for detailed guidelines. Key principles:
|
||||
|
||||
1. **Show concrete transformations** - "Change X to Y", not abstract advice
|
||||
2. **Error-first structure** - Show the problem before the solution
|
||||
3. **Quantify impact** - Include specific metrics (10x faster, 50% smaller)
|
||||
4. **Self-contained examples** - Complete, runnable SQL
|
||||
5. **Semantic naming** - Use meaningful names (users, email), not (table1, col1)
|
||||
|
||||
## Impact Levels
|
||||
|
||||
| Level | Improvement | Examples |
|
||||
|-------|-------------|----------|
|
||||
| CRITICAL | 10-100x | Missing indexes, connection exhaustion |
|
||||
| HIGH | 5-20x | Wrong index types, poor partitioning |
|
||||
| MEDIUM-HIGH | 2-5x | N+1 queries, RLS optimization |
|
||||
| MEDIUM | 1.5-3x | Redundant indexes, stale statistics |
|
||||
| LOW-MEDIUM | 1.2-2x | VACUUM tuning, config tweaks |
|
||||
| LOW | Incremental | Advanced patterns, edge cases |
|
||||
```
|
||||
64
.agent/skills/supabase-postgres-best-practices/SKILL.md
Normal file
64
.agent/skills/supabase-postgres-best-practices/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: supabase-postgres-best-practices
|
||||
description: Postgres performance optimization and best practices from Supabase. Use this skill when writing, reviewing, or optimizing Postgres queries, schema designs, or database configurations.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: supabase
|
||||
version: "1.1.0"
|
||||
organization: Supabase
|
||||
date: January 2026
|
||||
abstract: Comprehensive Postgres performance optimization guide for developers using Supabase and Postgres. Contains performance rules across 8 categories, prioritized by impact from critical (query performance, connection management) to incremental (advanced features). Each rule includes detailed explanations, incorrect vs. correct SQL examples, query plan analysis, and specific performance metrics to guide automated optimization and code generation.
|
||||
---
|
||||
|
||||
# Supabase Postgres Best Practices
|
||||
|
||||
Comprehensive performance optimization guide for Postgres, maintained by Supabase. Contains rules across 8 categories, prioritized by impact to guide automated query optimization and schema design.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
- Writing SQL queries or designing schemas
|
||||
- Implementing indexes or query optimization
|
||||
- Reviewing database performance issues
|
||||
- Configuring connection pooling or scaling
|
||||
- Optimizing for Postgres-specific features
|
||||
- Working with Row-Level Security (RLS)
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
|----------|----------|--------|--------|
|
||||
| 1 | Query Performance | CRITICAL | `query-` |
|
||||
| 2 | Connection Management | CRITICAL | `conn-` |
|
||||
| 3 | Security & RLS | CRITICAL | `security-` |
|
||||
| 4 | Schema Design | HIGH | `schema-` |
|
||||
| 5 | Concurrency & Locking | MEDIUM-HIGH | `lock-` |
|
||||
| 6 | Data Access Patterns | MEDIUM | `data-` |
|
||||
| 7 | Monitoring & Diagnostics | LOW-MEDIUM | `monitor-` |
|
||||
| 8 | Advanced Features | LOW | `advanced-` |
|
||||
|
||||
## How to Use
|
||||
|
||||
Read individual rule files for detailed explanations and SQL examples:
|
||||
|
||||
```
|
||||
references/query-missing-indexes.md
|
||||
references/schema-partial-indexes.md
|
||||
references/_sections.md
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect SQL example with explanation
|
||||
- Correct SQL example with explanation
|
||||
- Optional EXPLAIN output or metrics
|
||||
- Additional context and references
|
||||
- Supabase-specific notes (when applicable)
|
||||
|
||||
## References
|
||||
|
||||
- https://www.postgresql.org/docs/current/
|
||||
- https://supabase.com/docs
|
||||
- https://wiki.postgresql.org/wiki/Performance_Optimization
|
||||
- https://supabase.com/docs/guides/database/overview
|
||||
- https://supabase.com/docs/guides/auth/row-level-security
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Use tsvector for Full-Text Search
|
||||
impact: MEDIUM
|
||||
impactDescription: 100x faster than LIKE, with ranking support
|
||||
tags: full-text-search, tsvector, gin, search
|
||||
---
|
||||
|
||||
## Use tsvector for Full-Text Search
|
||||
|
||||
LIKE with wildcards can't use indexes. Full-text search with tsvector is orders of magnitude faster.
|
||||
|
||||
**Incorrect (LIKE pattern matching):**
|
||||
|
||||
```sql
|
||||
-- Cannot use index, scans all rows
|
||||
select * from articles where content like '%postgresql%';
|
||||
|
||||
-- Case-insensitive makes it worse
|
||||
select * from articles where lower(content) like '%postgresql%';
|
||||
```
|
||||
|
||||
**Correct (full-text search with tsvector):**
|
||||
|
||||
```sql
|
||||
-- Add tsvector column and index
|
||||
alter table articles add column search_vector tsvector
|
||||
generated always as (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,''))) stored;
|
||||
|
||||
create index articles_search_idx on articles using gin (search_vector);
|
||||
|
||||
-- Fast full-text search
|
||||
select * from articles
|
||||
where search_vector @@ to_tsquery('english', 'postgresql & performance');
|
||||
|
||||
-- With ranking
|
||||
select *, ts_rank(search_vector, query) as rank
|
||||
from articles, to_tsquery('english', 'postgresql') query
|
||||
where search_vector @@ query
|
||||
order by rank desc;
|
||||
```
|
||||
|
||||
Search multiple terms:
|
||||
|
||||
```sql
|
||||
-- AND: both terms required
|
||||
to_tsquery('postgresql & performance')
|
||||
|
||||
-- OR: either term
|
||||
to_tsquery('postgresql | mysql')
|
||||
|
||||
-- Prefix matching
|
||||
to_tsquery('post:*')
|
||||
```
|
||||
|
||||
Reference: [Full Text Search](https://supabase.com/docs/guides/database/full-text-search)
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Index JSONB Columns for Efficient Querying
|
||||
impact: MEDIUM
|
||||
impactDescription: 10-100x faster JSONB queries with proper indexing
|
||||
tags: jsonb, gin, indexes, json
|
||||
---
|
||||
|
||||
## Index JSONB Columns for Efficient Querying
|
||||
|
||||
JSONB queries without indexes scan the entire table. Use GIN indexes for containment queries.
|
||||
|
||||
**Incorrect (no index on JSONB):**
|
||||
|
||||
```sql
|
||||
create table products (
|
||||
id bigint primary key,
|
||||
attributes jsonb
|
||||
);
|
||||
|
||||
-- Full table scan for every query
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
select * from products where attributes->>'brand' = 'Nike';
|
||||
```
|
||||
|
||||
**Correct (GIN index for JSONB):**
|
||||
|
||||
```sql
|
||||
-- GIN index for containment operators (@>, ?, ?&, ?|)
|
||||
create index products_attrs_gin on products using gin (attributes);
|
||||
|
||||
-- Now containment queries use the index
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
|
||||
-- For specific key lookups, use expression index
|
||||
create index products_brand_idx on products ((attributes->>'brand'));
|
||||
select * from products where attributes->>'brand' = 'Nike';
|
||||
```
|
||||
|
||||
Choose the right operator class:
|
||||
|
||||
```sql
|
||||
-- jsonb_ops (default): supports all operators, larger index
|
||||
create index idx1 on products using gin (attributes);
|
||||
|
||||
-- jsonb_path_ops: only @> operator, but 2-3x smaller index
|
||||
create index idx2 on products using gin (attributes jsonb_path_ops);
|
||||
```
|
||||
|
||||
Reference: [JSONB Indexes](https://www.postgresql.org/docs/current/datatype-json.html#JSON-INDEXING)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Configure Idle Connection Timeouts
|
||||
impact: HIGH
|
||||
impactDescription: Reclaim 30-50% of connection slots from idle clients
|
||||
tags: connections, timeout, idle, resource-management
|
||||
---
|
||||
|
||||
## Configure Idle Connection Timeouts
|
||||
|
||||
Idle connections waste resources. Configure timeouts to automatically reclaim them.
|
||||
|
||||
**Incorrect (connections held indefinitely):**
|
||||
|
||||
```sql
|
||||
-- No timeout configured
|
||||
show idle_in_transaction_session_timeout; -- 0 (disabled)
|
||||
|
||||
-- Connections stay open forever, even when idle
|
||||
select pid, state, state_change, query
|
||||
from pg_stat_activity
|
||||
where state = 'idle in transaction';
|
||||
-- Shows transactions idle for hours, holding locks
|
||||
```
|
||||
|
||||
**Correct (automatic cleanup of idle connections):**
|
||||
|
||||
```sql
|
||||
-- Terminate connections idle in transaction after 30 seconds
|
||||
alter system set idle_in_transaction_session_timeout = '30s';
|
||||
|
||||
-- Terminate completely idle connections after 10 minutes
|
||||
alter system set idle_session_timeout = '10min';
|
||||
|
||||
-- Reload configuration
|
||||
select pg_reload_conf();
|
||||
```
|
||||
|
||||
For pooled connections, configure at the pooler level:
|
||||
|
||||
```ini
|
||||
# pgbouncer.ini
|
||||
server_idle_timeout = 60
|
||||
client_idle_timeout = 300
|
||||
```
|
||||
|
||||
Reference: [Connection Timeouts](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT)
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Set Appropriate Connection Limits
|
||||
impact: CRITICAL
|
||||
impactDescription: Prevent database crashes and memory exhaustion
|
||||
tags: connections, max-connections, limits, stability
|
||||
---
|
||||
|
||||
## Set Appropriate Connection Limits
|
||||
|
||||
Too many connections exhaust memory and degrade performance. Set limits based on available resources.
|
||||
|
||||
**Incorrect (unlimited or excessive connections):**
|
||||
|
||||
```sql
|
||||
-- Default max_connections = 100, but often increased blindly
|
||||
show max_connections; -- 500 (way too high for 4GB RAM)
|
||||
|
||||
-- Each connection uses 1-3MB RAM
|
||||
-- 500 connections * 2MB = 1GB just for connections!
|
||||
-- Out of memory errors under load
|
||||
```
|
||||
|
||||
**Correct (calculate based on resources):**
|
||||
|
||||
```sql
|
||||
-- Formula: max_connections = (RAM in MB / 5MB per connection) - reserved
|
||||
-- For 4GB RAM: (4096 / 5) - 10 = ~800 theoretical max
|
||||
-- But practically, 100-200 is better for query performance
|
||||
|
||||
-- Recommended settings for 4GB RAM
|
||||
alter system set max_connections = 100;
|
||||
|
||||
-- Also set work_mem appropriately
|
||||
-- work_mem * max_connections should not exceed 25% of RAM
|
||||
alter system set work_mem = '8MB'; -- 8MB * 100 = 800MB max
|
||||
```
|
||||
|
||||
Monitor connection usage:
|
||||
|
||||
```sql
|
||||
select count(*), state from pg_stat_activity group by state;
|
||||
```
|
||||
|
||||
Reference: [Database Connections](https://supabase.com/docs/guides/platform/performance#connection-management)
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Use Connection Pooling for All Applications
|
||||
impact: CRITICAL
|
||||
impactDescription: Handle 10-100x more concurrent users
|
||||
tags: connection-pooling, pgbouncer, performance, scalability
|
||||
---
|
||||
|
||||
## Use Connection Pooling for All Applications
|
||||
|
||||
Postgres connections are expensive (1-3MB RAM each). Without pooling, applications exhaust connections under load.
|
||||
|
||||
**Incorrect (new connection per request):**
|
||||
|
||||
```sql
|
||||
-- Each request creates a new connection
|
||||
-- Application code: db.connect() per request
|
||||
-- Result: 500 concurrent users = 500 connections = crashed database
|
||||
|
||||
-- Check current connections
|
||||
select count(*) from pg_stat_activity; -- 487 connections!
|
||||
```
|
||||
|
||||
**Correct (connection pooling):**
|
||||
|
||||
```sql
|
||||
-- Use a pooler like PgBouncer between app and database
|
||||
-- Application connects to pooler, pooler reuses a small pool to Postgres
|
||||
|
||||
-- Configure pool_size based on: (CPU cores * 2) + spindle_count
|
||||
-- Example for 4 cores: pool_size = 10
|
||||
|
||||
-- Result: 500 concurrent users share 10 actual connections
|
||||
select count(*) from pg_stat_activity; -- 10 connections
|
||||
```
|
||||
|
||||
Pool modes:
|
||||
|
||||
- **Transaction mode**: connection returned after each transaction (best for most apps)
|
||||
- **Session mode**: connection held for entire session (needed for prepared statements, temp tables)
|
||||
|
||||
Reference: [Connection Pooling](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pooler)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Use Prepared Statements Correctly with Pooling
|
||||
impact: HIGH
|
||||
impactDescription: Avoid prepared statement conflicts in pooled environments
|
||||
tags: prepared-statements, connection-pooling, transaction-mode
|
||||
---
|
||||
|
||||
## Use Prepared Statements Correctly with Pooling
|
||||
|
||||
Prepared statements are tied to individual database connections. In transaction-mode pooling, connections are shared, causing conflicts.
|
||||
|
||||
**Incorrect (named prepared statements with transaction pooling):**
|
||||
|
||||
```sql
|
||||
-- Named prepared statement
|
||||
prepare get_user as select * from users where id = $1;
|
||||
|
||||
-- In transaction mode pooling, next request may get different connection
|
||||
execute get_user(123);
|
||||
-- ERROR: prepared statement "get_user" does not exist
|
||||
```
|
||||
|
||||
**Correct (use unnamed statements or session mode):**
|
||||
|
||||
```sql
|
||||
-- Option 1: Use unnamed prepared statements (most ORMs do this automatically)
|
||||
-- The query is prepared and executed in a single protocol message
|
||||
|
||||
-- Option 2: Deallocate after use in transaction mode
|
||||
prepare get_user as select * from users where id = $1;
|
||||
execute get_user(123);
|
||||
deallocate get_user;
|
||||
|
||||
-- Option 3: Use session mode pooling (port 5432 vs 6543)
|
||||
-- Connection is held for entire session, prepared statements persist
|
||||
```
|
||||
|
||||
Check your driver settings:
|
||||
|
||||
```sql
|
||||
-- Many drivers use prepared statements by default
|
||||
-- Node.js pg: { prepare: false } to disable
|
||||
-- JDBC: prepareThreshold=0 to disable
|
||||
```
|
||||
|
||||
Reference: [Prepared Statements with Pooling](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pool-modes)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Batch INSERT Statements for Bulk Data
|
||||
impact: MEDIUM
|
||||
impactDescription: 10-50x faster bulk inserts
|
||||
tags: batch, insert, bulk, performance, copy
|
||||
---
|
||||
|
||||
## Batch INSERT Statements for Bulk Data
|
||||
|
||||
Individual INSERT statements have high overhead. Batch multiple rows in single statements or use COPY.
|
||||
|
||||
**Incorrect (individual inserts):**
|
||||
|
||||
```sql
|
||||
-- Each insert is a separate transaction and round trip
|
||||
insert into events (user_id, action) values (1, 'click');
|
||||
insert into events (user_id, action) values (1, 'view');
|
||||
insert into events (user_id, action) values (2, 'click');
|
||||
-- ... 1000 more individual inserts
|
||||
|
||||
-- 1000 inserts = 1000 round trips = slow
|
||||
```
|
||||
|
||||
**Correct (batch insert):**
|
||||
|
||||
```sql
|
||||
-- Multiple rows in single statement
|
||||
insert into events (user_id, action) values
|
||||
(1, 'click'),
|
||||
(1, 'view'),
|
||||
(2, 'click'),
|
||||
-- ... up to ~1000 rows per batch
|
||||
(999, 'view');
|
||||
|
||||
-- One round trip for 1000 rows
|
||||
```
|
||||
|
||||
For large imports, use COPY:
|
||||
|
||||
```sql
|
||||
-- COPY is fastest for bulk loading
|
||||
copy events (user_id, action, created_at)
|
||||
from '/path/to/data.csv'
|
||||
with (format csv, header true);
|
||||
|
||||
-- Or from stdin in application
|
||||
copy events (user_id, action) from stdin with (format csv);
|
||||
1,click
|
||||
1,view
|
||||
2,click
|
||||
\.
|
||||
```
|
||||
|
||||
Reference: [COPY](https://www.postgresql.org/docs/current/sql-copy.html)
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: Eliminate N+1 Queries with Batch Loading
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 10-100x fewer database round trips
|
||||
tags: n-plus-one, batch, performance, queries
|
||||
---
|
||||
|
||||
## Eliminate N+1 Queries with Batch Loading
|
||||
|
||||
N+1 queries execute one query per item in a loop. Batch them into a single query using arrays or JOINs.
|
||||
|
||||
**Incorrect (N+1 queries):**
|
||||
|
||||
```sql
|
||||
-- First query: get all users
|
||||
select id from users where active = true; -- Returns 100 IDs
|
||||
|
||||
-- Then N queries, one per user
|
||||
select * from orders where user_id = 1;
|
||||
select * from orders where user_id = 2;
|
||||
select * from orders where user_id = 3;
|
||||
-- ... 97 more queries!
|
||||
|
||||
-- Total: 101 round trips to database
|
||||
```
|
||||
|
||||
**Correct (single batch query):**
|
||||
|
||||
```sql
|
||||
-- Collect IDs and query once with ANY
|
||||
select * from orders where user_id = any(array[1, 2, 3, ...]);
|
||||
|
||||
-- Or use JOIN instead of loop
|
||||
select u.id, u.name, o.*
|
||||
from users u
|
||||
left join orders o on o.user_id = u.id
|
||||
where u.active = true;
|
||||
|
||||
-- Total: 1 round trip
|
||||
```
|
||||
|
||||
Application pattern:
|
||||
|
||||
```sql
|
||||
-- Instead of looping in application code:
|
||||
-- for user in users: db.query("SELECT * FROM orders WHERE user_id = $1", user.id)
|
||||
|
||||
-- Pass array parameter:
|
||||
select * from orders where user_id = any($1::bigint[]);
|
||||
-- Application passes: [1, 2, 3, 4, 5, ...]
|
||||
```
|
||||
|
||||
Reference: [N+1 Query Problem](https://supabase.com/docs/guides/database/query-optimization)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Use Cursor-Based Pagination Instead of OFFSET
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Consistent O(1) performance regardless of page depth
|
||||
tags: pagination, cursor, keyset, offset, performance
|
||||
---
|
||||
|
||||
## Use Cursor-Based Pagination Instead of OFFSET
|
||||
|
||||
OFFSET-based pagination scans all skipped rows, getting slower on deeper pages. Cursor pagination is O(1).
|
||||
|
||||
**Incorrect (OFFSET pagination):**
|
||||
|
||||
```sql
|
||||
-- Page 1: scans 20 rows
|
||||
select * from products order by id limit 20 offset 0;
|
||||
|
||||
-- Page 100: scans 2000 rows to skip 1980
|
||||
select * from products order by id limit 20 offset 1980;
|
||||
|
||||
-- Page 10000: scans 200,000 rows!
|
||||
select * from products order by id limit 20 offset 199980;
|
||||
```
|
||||
|
||||
**Correct (cursor/keyset pagination):**
|
||||
|
||||
```sql
|
||||
-- Page 1: get first 20
|
||||
select * from products order by id limit 20;
|
||||
-- Application stores last_id = 20
|
||||
|
||||
-- Page 2: start after last ID
|
||||
select * from products where id > 20 order by id limit 20;
|
||||
-- Uses index, always fast regardless of page depth
|
||||
|
||||
-- Page 10000: same speed as page 1
|
||||
select * from products where id > 199980 order by id limit 20;
|
||||
```
|
||||
|
||||
For multi-column sorting:
|
||||
|
||||
```sql
|
||||
-- Cursor must include all sort columns
|
||||
select * from products
|
||||
where (created_at, id) > ('2024-01-15 10:00:00', 12345)
|
||||
order by created_at, id
|
||||
limit 20;
|
||||
```
|
||||
|
||||
Reference: [Pagination](https://supabase.com/docs/guides/database/pagination)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Use UPSERT for Insert-or-Update Operations
|
||||
impact: MEDIUM
|
||||
impactDescription: Atomic operation, eliminates race conditions
|
||||
tags: upsert, on-conflict, insert, update
|
||||
---
|
||||
|
||||
## Use UPSERT for Insert-or-Update Operations
|
||||
|
||||
Using separate SELECT-then-INSERT/UPDATE creates race conditions. Use INSERT ... ON CONFLICT for atomic upserts.
|
||||
|
||||
**Incorrect (check-then-insert race condition):**
|
||||
|
||||
```sql
|
||||
-- Race condition: two requests check simultaneously
|
||||
select * from settings where user_id = 123 and key = 'theme';
|
||||
-- Both find nothing
|
||||
|
||||
-- Both try to insert
|
||||
insert into settings (user_id, key, value) values (123, 'theme', 'dark');
|
||||
-- One succeeds, one fails with duplicate key error!
|
||||
```
|
||||
|
||||
**Correct (atomic UPSERT):**
|
||||
|
||||
```sql
|
||||
-- Single atomic operation
|
||||
insert into settings (user_id, key, value)
|
||||
values (123, 'theme', 'dark')
|
||||
on conflict (user_id, key)
|
||||
do update set value = excluded.value, updated_at = now();
|
||||
|
||||
-- Returns the inserted/updated row
|
||||
insert into settings (user_id, key, value)
|
||||
values (123, 'theme', 'dark')
|
||||
on conflict (user_id, key)
|
||||
do update set value = excluded.value
|
||||
returning *;
|
||||
```
|
||||
|
||||
Insert-or-ignore pattern:
|
||||
|
||||
```sql
|
||||
-- Insert only if not exists (no update)
|
||||
insert into page_views (page_id, user_id)
|
||||
values (1, 123)
|
||||
on conflict (page_id, user_id) do nothing;
|
||||
```
|
||||
|
||||
Reference: [INSERT ON CONFLICT](https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT)
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Use Advisory Locks for Application-Level Locking
|
||||
impact: MEDIUM
|
||||
impactDescription: Efficient coordination without row-level lock overhead
|
||||
tags: advisory-locks, coordination, application-locks
|
||||
---
|
||||
|
||||
## Use Advisory Locks for Application-Level Locking
|
||||
|
||||
Advisory locks provide application-level coordination without requiring database rows to lock.
|
||||
|
||||
**Incorrect (creating rows just for locking):**
|
||||
|
||||
```sql
|
||||
-- Creating dummy rows to lock on
|
||||
create table resource_locks (
|
||||
resource_name text primary key
|
||||
);
|
||||
|
||||
insert into resource_locks values ('report_generator');
|
||||
|
||||
-- Lock by selecting the row
|
||||
select * from resource_locks where resource_name = 'report_generator' for update;
|
||||
```
|
||||
|
||||
**Correct (advisory locks):**
|
||||
|
||||
```sql
|
||||
-- Session-level advisory lock (released on disconnect or unlock)
|
||||
select pg_advisory_lock(hashtext('report_generator'));
|
||||
-- ... do exclusive work ...
|
||||
select pg_advisory_unlock(hashtext('report_generator'));
|
||||
|
||||
-- Transaction-level lock (released on commit/rollback)
|
||||
begin;
|
||||
select pg_advisory_xact_lock(hashtext('daily_report'));
|
||||
-- ... do work ...
|
||||
commit; -- Lock automatically released
|
||||
```
|
||||
|
||||
Try-lock for non-blocking operations:
|
||||
|
||||
```sql
|
||||
-- Returns immediately with true/false instead of waiting
|
||||
select pg_try_advisory_lock(hashtext('resource_name'));
|
||||
|
||||
-- Use in application
|
||||
if (acquired) {
|
||||
-- Do work
|
||||
select pg_advisory_unlock(hashtext('resource_name'));
|
||||
} else {
|
||||
-- Skip or retry later
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [Advisory Locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS)
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Prevent Deadlocks with Consistent Lock Ordering
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Eliminate deadlock errors, improve reliability
|
||||
tags: deadlocks, locking, transactions, ordering
|
||||
---
|
||||
|
||||
## Prevent Deadlocks with Consistent Lock Ordering
|
||||
|
||||
Deadlocks occur when transactions lock resources in different orders. Always
|
||||
acquire locks in a consistent order.
|
||||
|
||||
**Incorrect (inconsistent lock ordering):**
|
||||
|
||||
```sql
|
||||
-- Transaction A -- Transaction B
|
||||
begin; begin;
|
||||
update accounts update accounts
|
||||
set balance = balance - 100 set balance = balance - 50
|
||||
where id = 1; where id = 2; -- B locks row 2
|
||||
|
||||
update accounts update accounts
|
||||
set balance = balance + 100 set balance = balance + 50
|
||||
where id = 2; -- A waits for B where id = 1; -- B waits for A
|
||||
|
||||
-- DEADLOCK! Both waiting for each other
|
||||
```
|
||||
|
||||
**Correct (lock rows in consistent order first):**
|
||||
|
||||
```sql
|
||||
-- Explicitly acquire locks in ID order before updating
|
||||
begin;
|
||||
select * from accounts where id in (1, 2) order by id for update;
|
||||
|
||||
-- Now perform updates in any order - locks already held
|
||||
update accounts set balance = balance - 100 where id = 1;
|
||||
update accounts set balance = balance + 100 where id = 2;
|
||||
commit;
|
||||
```
|
||||
|
||||
Alternative: use a single statement to update atomically:
|
||||
|
||||
```sql
|
||||
-- Single statement acquires all locks atomically
|
||||
begin;
|
||||
update accounts
|
||||
set balance = balance + case id
|
||||
when 1 then -100
|
||||
when 2 then 100
|
||||
end
|
||||
where id in (1, 2);
|
||||
commit;
|
||||
```
|
||||
|
||||
Detect deadlocks in logs:
|
||||
|
||||
```sql
|
||||
-- Check for recent deadlocks
|
||||
select * from pg_stat_database where deadlocks > 0;
|
||||
|
||||
-- Enable deadlock logging
|
||||
set log_lock_waits = on;
|
||||
set deadlock_timeout = '1s';
|
||||
```
|
||||
|
||||
Reference:
|
||||
[Deadlocks](https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Keep Transactions Short to Reduce Lock Contention
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 3-5x throughput improvement, fewer deadlocks
|
||||
tags: transactions, locking, contention, performance
|
||||
---
|
||||
|
||||
## Keep Transactions Short to Reduce Lock Contention
|
||||
|
||||
Long-running transactions hold locks that block other queries. Keep transactions as short as possible.
|
||||
|
||||
**Incorrect (long transaction with external calls):**
|
||||
|
||||
```sql
|
||||
begin;
|
||||
select * from orders where id = 1 for update; -- Lock acquired
|
||||
|
||||
-- Application makes HTTP call to payment API (2-5 seconds)
|
||||
-- Other queries on this row are blocked!
|
||||
|
||||
update orders set status = 'paid' where id = 1;
|
||||
commit; -- Lock held for entire duration
|
||||
```
|
||||
|
||||
**Correct (minimal transaction scope):**
|
||||
|
||||
```sql
|
||||
-- Validate data and call APIs outside transaction
|
||||
-- Application: response = await paymentAPI.charge(...)
|
||||
|
||||
-- Only hold lock for the actual update
|
||||
begin;
|
||||
update orders
|
||||
set status = 'paid', payment_id = $1
|
||||
where id = $2 and status = 'pending'
|
||||
returning *;
|
||||
commit; -- Lock held for milliseconds
|
||||
```
|
||||
|
||||
Use `statement_timeout` to prevent runaway transactions:
|
||||
|
||||
```sql
|
||||
-- Abort queries running longer than 30 seconds
|
||||
set statement_timeout = '30s';
|
||||
|
||||
-- Or per-session
|
||||
set local statement_timeout = '5s';
|
||||
```
|
||||
|
||||
Reference: [Transaction Management](https://www.postgresql.org/docs/current/tutorial-transactions.html)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Use SKIP LOCKED for Non-Blocking Queue Processing
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 10x throughput for worker queues
|
||||
tags: skip-locked, queue, workers, concurrency
|
||||
---
|
||||
|
||||
## Use SKIP LOCKED for Non-Blocking Queue Processing
|
||||
|
||||
When multiple workers process a queue, SKIP LOCKED allows workers to process different rows without waiting.
|
||||
|
||||
**Incorrect (workers block each other):**
|
||||
|
||||
```sql
|
||||
-- Worker 1 and Worker 2 both try to get next job
|
||||
begin;
|
||||
select * from jobs where status = 'pending' order by created_at limit 1 for update;
|
||||
-- Worker 2 waits for Worker 1's lock to release!
|
||||
```
|
||||
|
||||
**Correct (SKIP LOCKED for parallel processing):**
|
||||
|
||||
```sql
|
||||
-- Each worker skips locked rows and gets the next available
|
||||
begin;
|
||||
select * from jobs
|
||||
where status = 'pending'
|
||||
order by created_at
|
||||
limit 1
|
||||
for update skip locked;
|
||||
|
||||
-- Worker 1 gets job 1, Worker 2 gets job 2 (no waiting)
|
||||
|
||||
update jobs set status = 'processing' where id = $1;
|
||||
commit;
|
||||
```
|
||||
|
||||
Complete queue pattern:
|
||||
|
||||
```sql
|
||||
-- Atomic claim-and-update in one statement
|
||||
update jobs
|
||||
set status = 'processing', worker_id = $1, started_at = now()
|
||||
where id = (
|
||||
select id from jobs
|
||||
where status = 'pending'
|
||||
order by created_at
|
||||
limit 1
|
||||
for update skip locked
|
||||
)
|
||||
returning *;
|
||||
```
|
||||
|
||||
Reference: [SELECT FOR UPDATE SKIP LOCKED](https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE)
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Use EXPLAIN ANALYZE to Diagnose Slow Queries
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Identify exact bottlenecks in query execution
|
||||
tags: explain, analyze, diagnostics, query-plan
|
||||
---
|
||||
|
||||
## Use EXPLAIN ANALYZE to Diagnose Slow Queries
|
||||
|
||||
EXPLAIN ANALYZE executes the query and shows actual timings, revealing the true performance bottlenecks.
|
||||
|
||||
**Incorrect (guessing at performance issues):**
|
||||
|
||||
```sql
|
||||
-- Query is slow, but why?
|
||||
select * from orders where customer_id = 123 and status = 'pending';
|
||||
-- "It must be missing an index" - but which one?
|
||||
```
|
||||
|
||||
**Correct (use EXPLAIN ANALYZE):**
|
||||
|
||||
```sql
|
||||
explain (analyze, buffers, format text)
|
||||
select * from orders where customer_id = 123 and status = 'pending';
|
||||
|
||||
-- Output reveals the issue:
|
||||
-- Seq Scan on orders (cost=0.00..25000.00 rows=50 width=100) (actual time=0.015..450.123 rows=50 loops=1)
|
||||
-- Filter: ((customer_id = 123) AND (status = 'pending'::text))
|
||||
-- Rows Removed by Filter: 999950
|
||||
-- Buffers: shared hit=5000 read=15000
|
||||
-- Planning Time: 0.150 ms
|
||||
-- Execution Time: 450.500 ms
|
||||
```
|
||||
|
||||
Key things to look for:
|
||||
|
||||
```sql
|
||||
-- Seq Scan on large tables = missing index
|
||||
-- Rows Removed by Filter = poor selectivity or missing index
|
||||
-- Buffers: read >> hit = data not cached, needs more memory
|
||||
-- Nested Loop with high loops = consider different join strategy
|
||||
-- Sort Method: external merge = work_mem too low
|
||||
```
|
||||
|
||||
Reference: [EXPLAIN](https://supabase.com/docs/guides/database/inspect)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Enable pg_stat_statements for Query Analysis
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Identify top resource-consuming queries
|
||||
tags: pg-stat-statements, monitoring, statistics, performance
|
||||
---
|
||||
|
||||
## Enable pg_stat_statements for Query Analysis
|
||||
|
||||
pg_stat_statements tracks execution statistics for all queries, helping identify slow and frequent queries.
|
||||
|
||||
**Incorrect (no visibility into query patterns):**
|
||||
|
||||
```sql
|
||||
-- Database is slow, but which queries are the problem?
|
||||
-- No way to know without pg_stat_statements
|
||||
```
|
||||
|
||||
**Correct (enable and query pg_stat_statements):**
|
||||
|
||||
```sql
|
||||
-- Enable the extension
|
||||
create extension if not exists pg_stat_statements;
|
||||
|
||||
-- Find slowest queries by total time
|
||||
select
|
||||
calls,
|
||||
round(total_exec_time::numeric, 2) as total_time_ms,
|
||||
round(mean_exec_time::numeric, 2) as mean_time_ms,
|
||||
query
|
||||
from pg_stat_statements
|
||||
order by total_exec_time desc
|
||||
limit 10;
|
||||
|
||||
-- Find most frequent queries
|
||||
select calls, query
|
||||
from pg_stat_statements
|
||||
order by calls desc
|
||||
limit 10;
|
||||
|
||||
-- Reset statistics after optimization
|
||||
select pg_stat_statements_reset();
|
||||
```
|
||||
|
||||
Key metrics to monitor:
|
||||
|
||||
```sql
|
||||
-- Queries with high mean time (candidates for optimization)
|
||||
select query, mean_exec_time, calls
|
||||
from pg_stat_statements
|
||||
where mean_exec_time > 100 -- > 100ms average
|
||||
order by mean_exec_time desc;
|
||||
```
|
||||
|
||||
Reference: [pg_stat_statements](https://supabase.com/docs/guides/database/extensions/pg_stat_statements)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Maintain Table Statistics with VACUUM and ANALYZE
|
||||
impact: MEDIUM
|
||||
impactDescription: 2-10x better query plans with accurate statistics
|
||||
tags: vacuum, analyze, statistics, maintenance, autovacuum
|
||||
---
|
||||
|
||||
## Maintain Table Statistics with VACUUM and ANALYZE
|
||||
|
||||
Outdated statistics cause the query planner to make poor decisions. VACUUM reclaims space, ANALYZE updates statistics.
|
||||
|
||||
**Incorrect (stale statistics):**
|
||||
|
||||
```sql
|
||||
-- Table has 1M rows but stats say 1000
|
||||
-- Query planner chooses wrong strategy
|
||||
explain select * from orders where status = 'pending';
|
||||
-- Shows: Seq Scan (because stats show small table)
|
||||
-- Actually: Index Scan would be much faster
|
||||
```
|
||||
|
||||
**Correct (maintain fresh statistics):**
|
||||
|
||||
```sql
|
||||
-- Manually analyze after large data changes
|
||||
analyze orders;
|
||||
|
||||
-- Analyze specific columns used in WHERE clauses
|
||||
analyze orders (status, created_at);
|
||||
|
||||
-- Check when tables were last analyzed
|
||||
select
|
||||
relname,
|
||||
last_vacuum,
|
||||
last_autovacuum,
|
||||
last_analyze,
|
||||
last_autoanalyze
|
||||
from pg_stat_user_tables
|
||||
order by last_analyze nulls first;
|
||||
```
|
||||
|
||||
Autovacuum tuning for busy tables:
|
||||
|
||||
```sql
|
||||
-- Increase frequency for high-churn tables
|
||||
alter table orders set (
|
||||
autovacuum_vacuum_scale_factor = 0.05, -- Vacuum at 5% dead tuples (default 20%)
|
||||
autovacuum_analyze_scale_factor = 0.02 -- Analyze at 2% changes (default 10%)
|
||||
);
|
||||
|
||||
-- Check autovacuum status
|
||||
select * from pg_stat_progress_vacuum;
|
||||
```
|
||||
|
||||
Reference: [VACUUM](https://supabase.com/docs/guides/database/database-size#vacuum-operations)
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Create Composite Indexes for Multi-Column Queries
|
||||
impact: HIGH
|
||||
impactDescription: 5-10x faster multi-column queries
|
||||
tags: indexes, composite-index, multi-column, query-optimization
|
||||
---
|
||||
|
||||
## Create Composite Indexes for Multi-Column Queries
|
||||
|
||||
When queries filter on multiple columns, a composite index is more efficient than separate single-column indexes.
|
||||
|
||||
**Incorrect (separate indexes require bitmap scan):**
|
||||
|
||||
```sql
|
||||
-- Two separate indexes
|
||||
create index orders_status_idx on orders (status);
|
||||
create index orders_created_idx on orders (created_at);
|
||||
|
||||
-- Query must combine both indexes (slower)
|
||||
select * from orders where status = 'pending' and created_at > '2024-01-01';
|
||||
```
|
||||
|
||||
**Correct (composite index):**
|
||||
|
||||
```sql
|
||||
-- Single composite index (leftmost column first for equality checks)
|
||||
create index orders_status_created_idx on orders (status, created_at);
|
||||
|
||||
-- Query uses one efficient index scan
|
||||
select * from orders where status = 'pending' and created_at > '2024-01-01';
|
||||
```
|
||||
|
||||
**Column order matters** - place equality columns first, range columns last:
|
||||
|
||||
```sql
|
||||
-- Good: status (=) before created_at (>)
|
||||
create index idx on orders (status, created_at);
|
||||
|
||||
-- Works for: WHERE status = 'pending'
|
||||
-- Works for: WHERE status = 'pending' AND created_at > '2024-01-01'
|
||||
-- Does NOT work for: WHERE created_at > '2024-01-01' (leftmost prefix rule)
|
||||
```
|
||||
|
||||
Reference: [Multicolumn Indexes](https://www.postgresql.org/docs/current/indexes-multicolumn.html)
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: Use Covering Indexes to Avoid Table Lookups
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 2-5x faster queries by eliminating heap fetches
|
||||
tags: indexes, covering-index, include, index-only-scan
|
||||
---
|
||||
|
||||
## Use Covering Indexes to Avoid Table Lookups
|
||||
|
||||
Covering indexes include all columns needed by a query, enabling index-only scans that skip the table entirely.
|
||||
|
||||
**Incorrect (index scan + heap fetch):**
|
||||
|
||||
```sql
|
||||
create index users_email_idx on users (email);
|
||||
|
||||
-- Must fetch name and created_at from table heap
|
||||
select email, name, created_at from users where email = 'user@example.com';
|
||||
```
|
||||
|
||||
**Correct (index-only scan with INCLUDE):**
|
||||
|
||||
```sql
|
||||
-- Include non-searchable columns in the index
|
||||
create index users_email_idx on users (email) include (name, created_at);
|
||||
|
||||
-- All columns served from index, no table access needed
|
||||
select email, name, created_at from users where email = 'user@example.com';
|
||||
```
|
||||
|
||||
Use INCLUDE for columns you SELECT but don't filter on:
|
||||
|
||||
```sql
|
||||
-- Searching by status, but also need customer_id and total
|
||||
create index orders_status_idx on orders (status) include (customer_id, total);
|
||||
|
||||
select status, customer_id, total from orders where status = 'shipped';
|
||||
```
|
||||
|
||||
Reference: [Index-Only Scans](https://www.postgresql.org/docs/current/indexes-index-only-scans.html)
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Choose the Right Index Type for Your Data
|
||||
impact: HIGH
|
||||
impactDescription: 10-100x improvement with correct index type
|
||||
tags: indexes, btree, gin, gist, brin, hash, index-types
|
||||
---
|
||||
|
||||
## Choose the Right Index Type for Your Data
|
||||
|
||||
Different index types excel at different query patterns. The default B-tree isn't always optimal.
|
||||
|
||||
**Incorrect (B-tree for JSONB containment):**
|
||||
|
||||
```sql
|
||||
-- B-tree cannot optimize containment operators
|
||||
create index products_attrs_idx on products (attributes);
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
-- Full table scan - B-tree doesn't support @> operator
|
||||
```
|
||||
|
||||
**Correct (GIN for JSONB):**
|
||||
|
||||
```sql
|
||||
-- GIN supports @>, ?, ?&, ?| operators
|
||||
create index products_attrs_idx on products using gin (attributes);
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
```
|
||||
|
||||
Index type guide:
|
||||
|
||||
```sql
|
||||
-- B-tree (default): =, <, >, BETWEEN, IN, IS NULL
|
||||
create index users_created_idx on users (created_at);
|
||||
|
||||
-- GIN: arrays, JSONB, full-text search
|
||||
create index posts_tags_idx on posts using gin (tags);
|
||||
|
||||
-- GiST: geometric data, range types, nearest-neighbor (KNN) queries
|
||||
create index locations_idx on places using gist (location);
|
||||
|
||||
-- BRIN: large time-series tables (10-100x smaller)
|
||||
create index events_time_idx on events using brin (created_at);
|
||||
|
||||
-- Hash: equality-only (slightly faster than B-tree for =)
|
||||
create index sessions_token_idx on sessions using hash (token);
|
||||
```
|
||||
|
||||
Reference: [Index Types](https://www.postgresql.org/docs/current/indexes-types.html)
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: Add Indexes on WHERE and JOIN Columns
|
||||
impact: CRITICAL
|
||||
impactDescription: 100-1000x faster queries on large tables
|
||||
tags: indexes, performance, sequential-scan, query-optimization
|
||||
---
|
||||
|
||||
## Add Indexes on WHERE and JOIN Columns
|
||||
|
||||
Queries filtering or joining on unindexed columns cause full table scans, which become exponentially slower as tables grow.
|
||||
|
||||
**Incorrect (sequential scan on large table):**
|
||||
|
||||
```sql
|
||||
-- No index on customer_id causes full table scan
|
||||
select * from orders where customer_id = 123;
|
||||
|
||||
-- EXPLAIN shows: Seq Scan on orders (cost=0.00..25000.00 rows=100 width=85)
|
||||
```
|
||||
|
||||
**Correct (index scan):**
|
||||
|
||||
```sql
|
||||
-- Create index on frequently filtered column
|
||||
create index orders_customer_id_idx on orders (customer_id);
|
||||
|
||||
select * from orders where customer_id = 123;
|
||||
|
||||
-- EXPLAIN shows: Index Scan using orders_customer_id_idx (cost=0.42..8.44 rows=100 width=85)
|
||||
```
|
||||
|
||||
For JOIN columns, always index the foreign key side:
|
||||
|
||||
```sql
|
||||
-- Index the referencing column
|
||||
create index orders_customer_id_idx on orders (customer_id);
|
||||
|
||||
select c.name, o.total
|
||||
from customers c
|
||||
join orders o on o.customer_id = c.id;
|
||||
```
|
||||
|
||||
Reference: [Query Optimization](https://supabase.com/docs/guides/database/query-optimization)
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Use Partial Indexes for Filtered Queries
|
||||
impact: HIGH
|
||||
impactDescription: 5-20x smaller indexes, faster writes and queries
|
||||
tags: indexes, partial-index, query-optimization, storage
|
||||
---
|
||||
|
||||
## Use Partial Indexes for Filtered Queries
|
||||
|
||||
Partial indexes only include rows matching a WHERE condition, making them smaller and faster when queries consistently filter on the same condition.
|
||||
|
||||
**Incorrect (full index includes irrelevant rows):**
|
||||
|
||||
```sql
|
||||
-- Index includes all rows, even soft-deleted ones
|
||||
create index users_email_idx on users (email);
|
||||
|
||||
-- Query always filters active users
|
||||
select * from users where email = 'user@example.com' and deleted_at is null;
|
||||
```
|
||||
|
||||
**Correct (partial index matches query filter):**
|
||||
|
||||
```sql
|
||||
-- Index only includes active users
|
||||
create index users_active_email_idx on users (email)
|
||||
where deleted_at is null;
|
||||
|
||||
-- Query uses the smaller, faster index
|
||||
select * from users where email = 'user@example.com' and deleted_at is null;
|
||||
```
|
||||
|
||||
Common use cases for partial indexes:
|
||||
|
||||
```sql
|
||||
-- Only pending orders (status rarely changes once completed)
|
||||
create index orders_pending_idx on orders (created_at)
|
||||
where status = 'pending';
|
||||
|
||||
-- Only non-null values
|
||||
create index products_sku_idx on products (sku)
|
||||
where sku is not null;
|
||||
```
|
||||
|
||||
Reference: [Partial Indexes](https://www.postgresql.org/docs/current/indexes-partial.html)
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Add Constraints Safely in Migrations
|
||||
impact: HIGH
|
||||
impactDescription: Prevents migration failures and enables idempotent schema changes
|
||||
tags: constraints, migrations, schema, alter-table
|
||||
---
|
||||
|
||||
## Add Constraints Safely in Migrations
|
||||
|
||||
PostgreSQL does not support `ADD CONSTRAINT IF NOT EXISTS`. Migrations using this syntax will fail.
|
||||
|
||||
**Incorrect (causes syntax error):**
|
||||
|
||||
```sql
|
||||
-- ERROR: syntax error at or near "not" (SQLSTATE 42601)
|
||||
alter table public.profiles
|
||||
add constraint if not exists profiles_birthchart_id_unique unique (birthchart_id);
|
||||
```
|
||||
|
||||
**Correct (idempotent constraint creation):**
|
||||
|
||||
```sql
|
||||
-- Use DO block to check before adding
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'profiles_birthchart_id_unique'
|
||||
and conrelid = 'public.profiles'::regclass
|
||||
) then
|
||||
alter table public.profiles
|
||||
add constraint profiles_birthchart_id_unique unique (birthchart_id);
|
||||
end if;
|
||||
end $$;
|
||||
```
|
||||
|
||||
For all constraint types:
|
||||
|
||||
```sql
|
||||
-- Check constraints
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'check_age_positive'
|
||||
) then
|
||||
alter table users add constraint check_age_positive check (age > 0);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- Foreign keys
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'profiles_birthchart_id_fkey'
|
||||
) then
|
||||
alter table profiles
|
||||
add constraint profiles_birthchart_id_fkey
|
||||
foreign key (birthchart_id) references birthcharts(id);
|
||||
end if;
|
||||
end $$;
|
||||
```
|
||||
|
||||
Check if constraint exists:
|
||||
|
||||
```sql
|
||||
-- Query to check constraint existence
|
||||
select conname, contype, pg_get_constraintdef(oid)
|
||||
from pg_constraint
|
||||
where conrelid = 'public.profiles'::regclass;
|
||||
|
||||
-- contype values:
|
||||
-- 'p' = PRIMARY KEY
|
||||
-- 'f' = FOREIGN KEY
|
||||
-- 'u' = UNIQUE
|
||||
-- 'c' = CHECK
|
||||
```
|
||||
|
||||
Reference: [Constraints](https://www.postgresql.org/docs/current/ddl-constraints.html)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Choose Appropriate Data Types
|
||||
impact: HIGH
|
||||
impactDescription: 50% storage reduction, faster comparisons
|
||||
tags: data-types, schema, storage, performance
|
||||
---
|
||||
|
||||
## Choose Appropriate Data Types
|
||||
|
||||
Using the right data types reduces storage, improves query performance, and prevents bugs.
|
||||
|
||||
**Incorrect (wrong data types):**
|
||||
|
||||
```sql
|
||||
create table users (
|
||||
id int, -- Will overflow at 2.1 billion
|
||||
email varchar(255), -- Unnecessary length limit
|
||||
created_at timestamp, -- Missing timezone info
|
||||
is_active varchar(5), -- String for boolean
|
||||
price varchar(20) -- String for numeric
|
||||
);
|
||||
```
|
||||
|
||||
**Correct (appropriate data types):**
|
||||
|
||||
```sql
|
||||
create table users (
|
||||
id bigint generated always as identity primary key, -- 9 quintillion max
|
||||
email text, -- No artificial limit, same performance as varchar
|
||||
created_at timestamptz, -- Always store timezone-aware timestamps
|
||||
is_active boolean default true, -- 1 byte vs variable string length
|
||||
price numeric(10,2) -- Exact decimal arithmetic
|
||||
);
|
||||
```
|
||||
|
||||
Key guidelines:
|
||||
|
||||
```sql
|
||||
-- IDs: use bigint, not int (future-proofing)
|
||||
-- Strings: use text, not varchar(n) unless constraint needed
|
||||
-- Time: use timestamptz, not timestamp
|
||||
-- Money: use numeric, not float (precision matters)
|
||||
-- Enums: use text with check constraint or create enum type
|
||||
```
|
||||
|
||||
Reference: [Data Types](https://www.postgresql.org/docs/current/datatype.html)
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Index Foreign Key Columns
|
||||
impact: HIGH
|
||||
impactDescription: 10-100x faster JOINs and CASCADE operations
|
||||
tags: foreign-key, indexes, joins, schema
|
||||
---
|
||||
|
||||
## Index Foreign Key Columns
|
||||
|
||||
Postgres does not automatically index foreign key columns. Missing indexes cause slow JOINs and CASCADE operations.
|
||||
|
||||
**Incorrect (unindexed foreign key):**
|
||||
|
||||
```sql
|
||||
create table orders (
|
||||
id bigint generated always as identity primary key,
|
||||
customer_id bigint references customers(id) on delete cascade,
|
||||
total numeric(10,2)
|
||||
);
|
||||
|
||||
-- No index on customer_id!
|
||||
-- JOINs and ON DELETE CASCADE both require full table scan
|
||||
select * from orders where customer_id = 123; -- Seq Scan
|
||||
delete from customers where id = 123; -- Locks table, scans all orders
|
||||
```
|
||||
|
||||
**Correct (indexed foreign key):**
|
||||
|
||||
```sql
|
||||
create table orders (
|
||||
id bigint generated always as identity primary key,
|
||||
customer_id bigint references customers(id) on delete cascade,
|
||||
total numeric(10,2)
|
||||
);
|
||||
|
||||
-- Always index the FK column
|
||||
create index orders_customer_id_idx on orders (customer_id);
|
||||
|
||||
-- Now JOINs and cascades are fast
|
||||
select * from orders where customer_id = 123; -- Index Scan
|
||||
delete from customers where id = 123; -- Uses index, fast cascade
|
||||
```
|
||||
|
||||
Find missing FK indexes:
|
||||
|
||||
```sql
|
||||
select
|
||||
conrelid::regclass as table_name,
|
||||
a.attname as fk_column
|
||||
from pg_constraint c
|
||||
join pg_attribute a on a.attrelid = c.conrelid and a.attnum = any(c.conkey)
|
||||
where c.contype = 'f'
|
||||
and not exists (
|
||||
select 1 from pg_index i
|
||||
where i.indrelid = c.conrelid and a.attnum = any(i.indkey)
|
||||
);
|
||||
```
|
||||
|
||||
Reference: [Foreign Keys](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Use Lowercase Identifiers for Compatibility
|
||||
impact: MEDIUM
|
||||
impactDescription: Avoid case-sensitivity bugs with tools, ORMs, and AI assistants
|
||||
tags: naming, identifiers, case-sensitivity, schema, conventions
|
||||
---
|
||||
|
||||
## Use Lowercase Identifiers for Compatibility
|
||||
|
||||
PostgreSQL folds unquoted identifiers to lowercase. Quoted mixed-case identifiers require quotes forever and cause issues with tools, ORMs, and AI assistants that may not recognize them.
|
||||
|
||||
**Incorrect (mixed-case identifiers):**
|
||||
|
||||
```sql
|
||||
-- Quoted identifiers preserve case but require quotes everywhere
|
||||
CREATE TABLE "Users" (
|
||||
"userId" bigint PRIMARY KEY,
|
||||
"firstName" text,
|
||||
"lastName" text
|
||||
);
|
||||
|
||||
-- Must always quote or queries fail
|
||||
SELECT "firstName" FROM "Users" WHERE "userId" = 1;
|
||||
|
||||
-- This fails - Users becomes users without quotes
|
||||
SELECT firstName FROM Users;
|
||||
-- ERROR: relation "users" does not exist
|
||||
```
|
||||
|
||||
**Correct (lowercase snake_case):**
|
||||
|
||||
```sql
|
||||
-- Unquoted lowercase identifiers are portable and tool-friendly
|
||||
CREATE TABLE users (
|
||||
user_id bigint PRIMARY KEY,
|
||||
first_name text,
|
||||
last_name text
|
||||
);
|
||||
|
||||
-- Works without quotes, recognized by all tools
|
||||
SELECT first_name FROM users WHERE user_id = 1;
|
||||
```
|
||||
|
||||
Common sources of mixed-case identifiers:
|
||||
|
||||
```sql
|
||||
-- ORMs often generate quoted camelCase - configure them to use snake_case
|
||||
-- Migrations from other databases may preserve original casing
|
||||
-- Some GUI tools quote identifiers by default - disable this
|
||||
|
||||
-- If stuck with mixed-case, create views as a compatibility layer
|
||||
CREATE VIEW users AS SELECT "userId" AS user_id, "firstName" AS first_name FROM "Users";
|
||||
```
|
||||
|
||||
Reference: [Identifiers and Key Words](https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Partition Large Tables for Better Performance
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 5-20x faster queries and maintenance on large tables
|
||||
tags: partitioning, large-tables, time-series, performance
|
||||
---
|
||||
|
||||
## Partition Large Tables for Better Performance
|
||||
|
||||
Partitioning splits a large table into smaller pieces, improving query performance and maintenance operations.
|
||||
|
||||
**Incorrect (single large table):**
|
||||
|
||||
```sql
|
||||
create table events (
|
||||
id bigint generated always as identity,
|
||||
created_at timestamptz,
|
||||
data jsonb
|
||||
);
|
||||
|
||||
-- 500M rows, queries scan everything
|
||||
select * from events where created_at > '2024-01-01'; -- Slow
|
||||
vacuum events; -- Takes hours, locks table
|
||||
```
|
||||
|
||||
**Correct (partitioned by time range):**
|
||||
|
||||
```sql
|
||||
create table events (
|
||||
id bigint generated always as identity,
|
||||
created_at timestamptz not null,
|
||||
data jsonb
|
||||
) partition by range (created_at);
|
||||
|
||||
-- Create partitions for each month
|
||||
create table events_2024_01 partition of events
|
||||
for values from ('2024-01-01') to ('2024-02-01');
|
||||
|
||||
create table events_2024_02 partition of events
|
||||
for values from ('2024-02-01') to ('2024-03-01');
|
||||
|
||||
-- Queries only scan relevant partitions
|
||||
select * from events where created_at > '2024-01-15'; -- Only scans events_2024_01+
|
||||
|
||||
-- Drop old data instantly
|
||||
drop table events_2023_01; -- Instant vs DELETE taking hours
|
||||
```
|
||||
|
||||
When to partition:
|
||||
|
||||
- Tables > 100M rows
|
||||
- Time-series data with date-based queries
|
||||
- Need to efficiently drop old data
|
||||
|
||||
Reference: [Table Partitioning](https://www.postgresql.org/docs/current/ddl-partitioning.html)
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Select Optimal Primary Key Strategy
|
||||
impact: HIGH
|
||||
impactDescription: Better index locality, reduced fragmentation
|
||||
tags: primary-key, identity, uuid, serial, schema
|
||||
---
|
||||
|
||||
## Select Optimal Primary Key Strategy
|
||||
|
||||
Primary key choice affects insert performance, index size, and replication
|
||||
efficiency.
|
||||
|
||||
**Incorrect (problematic PK choices):**
|
||||
|
||||
```sql
|
||||
-- identity is the SQL-standard approach
|
||||
create table users (
|
||||
id serial primary key -- Works, but IDENTITY is recommended
|
||||
);
|
||||
|
||||
-- Random UUIDs (v4) cause index fragmentation
|
||||
create table orders (
|
||||
id uuid default gen_random_uuid() primary key -- UUIDv4 = random = scattered inserts
|
||||
);
|
||||
```
|
||||
|
||||
**Correct (optimal PK strategies):**
|
||||
|
||||
```sql
|
||||
-- Use IDENTITY for sequential IDs (SQL-standard, best for most cases)
|
||||
create table users (
|
||||
id bigint generated always as identity primary key
|
||||
);
|
||||
|
||||
-- For distributed systems needing UUIDs, use UUIDv7 (time-ordered)
|
||||
-- Requires pg_uuidv7 extension: create extension pg_uuidv7;
|
||||
create table orders (
|
||||
id uuid default uuid_generate_v7() primary key -- Time-ordered, no fragmentation
|
||||
);
|
||||
|
||||
-- Alternative: time-prefixed IDs for sortable, distributed IDs (no extension needed)
|
||||
create table events (
|
||||
id text default concat(
|
||||
to_char(now() at time zone 'utc', 'YYYYMMDDHH24MISSMS'),
|
||||
gen_random_uuid()::text
|
||||
) primary key
|
||||
);
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Single database: `bigint identity` (sequential, 8 bytes, SQL-standard)
|
||||
- Distributed/exposed IDs: UUIDv7 (requires pg_uuidv7) or ULID (time-ordered, no
|
||||
fragmentation)
|
||||
- `serial` works but `identity` is SQL-standard and preferred for new
|
||||
applications
|
||||
- Avoid random UUIDs (v4) as primary keys on large tables (causes index
|
||||
fragmentation)
|
||||
|
||||
Reference:
|
||||
[Identity Columns](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-PARMS-GENERATED-IDENTITY)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Apply Principle of Least Privilege
|
||||
impact: MEDIUM
|
||||
impactDescription: Reduced attack surface, better audit trail
|
||||
tags: privileges, security, roles, permissions
|
||||
---
|
||||
|
||||
## Apply Principle of Least Privilege
|
||||
|
||||
Grant only the minimum permissions required. Never use superuser for application queries.
|
||||
|
||||
**Incorrect (overly broad permissions):**
|
||||
|
||||
```sql
|
||||
-- Application uses superuser connection
|
||||
-- Or grants ALL to application role
|
||||
grant all privileges on all tables in schema public to app_user;
|
||||
grant all privileges on all sequences in schema public to app_user;
|
||||
|
||||
-- Any SQL injection becomes catastrophic
|
||||
-- drop table users; cascades to everything
|
||||
```
|
||||
|
||||
**Correct (minimal, specific grants):**
|
||||
|
||||
```sql
|
||||
-- Create role with no default privileges
|
||||
create role app_readonly nologin;
|
||||
|
||||
-- Grant only SELECT on specific tables
|
||||
grant usage on schema public to app_readonly;
|
||||
grant select on public.products, public.categories to app_readonly;
|
||||
|
||||
-- Create role for writes with limited scope
|
||||
create role app_writer nologin;
|
||||
grant usage on schema public to app_writer;
|
||||
grant select, insert, update on public.orders to app_writer;
|
||||
grant usage on sequence orders_id_seq to app_writer;
|
||||
-- No DELETE permission
|
||||
|
||||
-- Login role inherits from these
|
||||
create role app_user login password 'xxx';
|
||||
grant app_writer to app_user;
|
||||
```
|
||||
|
||||
Revoke public defaults:
|
||||
|
||||
```sql
|
||||
-- Revoke default public access
|
||||
revoke all on schema public from public;
|
||||
revoke all on all tables in schema public from public;
|
||||
```
|
||||
|
||||
Reference: [Roles and Privileges](https://supabase.com/blog/postgres-roles-and-privileges)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Enable Row Level Security for Multi-Tenant Data
|
||||
impact: CRITICAL
|
||||
impactDescription: Database-enforced tenant isolation, prevent data leaks
|
||||
tags: rls, row-level-security, multi-tenant, security
|
||||
---
|
||||
|
||||
## Enable Row Level Security for Multi-Tenant Data
|
||||
|
||||
Row Level Security (RLS) enforces data access at the database level, ensuring users only see their own data.
|
||||
|
||||
**Incorrect (application-level filtering only):**
|
||||
|
||||
```sql
|
||||
-- Relying only on application to filter
|
||||
select * from orders where user_id = $current_user_id;
|
||||
|
||||
-- Bug or bypass means all data is exposed!
|
||||
select * from orders; -- Returns ALL orders
|
||||
```
|
||||
|
||||
**Correct (database-enforced RLS):**
|
||||
|
||||
```sql
|
||||
-- Enable RLS on the table
|
||||
alter table orders enable row level security;
|
||||
|
||||
-- Create policy for users to see only their orders
|
||||
create policy orders_user_policy on orders
|
||||
for all
|
||||
using (user_id = current_setting('app.current_user_id')::bigint);
|
||||
|
||||
-- Force RLS even for table owners
|
||||
alter table orders force row level security;
|
||||
|
||||
-- Set user context and query
|
||||
set app.current_user_id = '123';
|
||||
select * from orders; -- Only returns orders for user 123
|
||||
```
|
||||
|
||||
Policy for authenticated role:
|
||||
|
||||
```sql
|
||||
create policy orders_user_policy on orders
|
||||
for all
|
||||
to authenticated
|
||||
using (user_id = auth.uid());
|
||||
```
|
||||
|
||||
Reference: [Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: Optimize RLS Policies for Performance
|
||||
impact: HIGH
|
||||
impactDescription: 5-10x faster RLS queries with proper patterns
|
||||
tags: rls, performance, security, optimization
|
||||
---
|
||||
|
||||
## Optimize RLS Policies for Performance
|
||||
|
||||
Poorly written RLS policies can cause severe performance issues. Use subqueries and indexes strategically.
|
||||
|
||||
**Incorrect (function called for every row):**
|
||||
|
||||
```sql
|
||||
create policy orders_policy on orders
|
||||
using (auth.uid() = user_id); -- auth.uid() called per row!
|
||||
|
||||
-- With 1M rows, auth.uid() is called 1M times
|
||||
```
|
||||
|
||||
**Correct (wrap functions in SELECT):**
|
||||
|
||||
```sql
|
||||
create policy orders_policy on orders
|
||||
using ((select auth.uid()) = user_id); -- Called once, cached
|
||||
|
||||
-- 100x+ faster on large tables
|
||||
```
|
||||
|
||||
Use security definer functions for complex checks:
|
||||
|
||||
```sql
|
||||
-- Create helper function (runs as definer, bypasses RLS)
|
||||
create or replace function is_team_member(team_id bigint)
|
||||
returns boolean
|
||||
language sql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
select exists (
|
||||
select 1 from public.team_members
|
||||
where team_id = $1 and user_id = (select auth.uid())
|
||||
);
|
||||
$$;
|
||||
|
||||
-- Use in policy (indexed lookup, not per-row check)
|
||||
create policy team_orders_policy on orders
|
||||
using ((select is_team_member(team_id)));
|
||||
```
|
||||
|
||||
Always add indexes on columns used in RLS policies:
|
||||
|
||||
```sql
|
||||
create index orders_user_id_idx on orders (user_id);
|
||||
```
|
||||
|
||||
Reference: [RLS Performance](https://supabase.com/docs/guides/database/postgres/row-level-security#rls-performance-recommendations)
|
||||
113
.agent/skills/tanstack-router-best-practices/SKILL.md
Normal file
113
.agent/skills/tanstack-router-best-practices/SKILL.md
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: tanstack-router-best-practices
|
||||
description: TanStack Router best practices for type-safe routing, data loading, search params, and navigation. Activate when building React applications with complex routing needs.
|
||||
---
|
||||
|
||||
# TanStack Router Best Practices
|
||||
|
||||
Comprehensive guidelines for implementing TanStack Router patterns in React applications. These rules optimize type safety, data loading, navigation, and code organization.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- Setting up application routing
|
||||
- Creating new routes and layouts
|
||||
- Implementing search parameter handling
|
||||
- Configuring data loaders
|
||||
- Setting up code splitting
|
||||
- Integrating with TanStack Query
|
||||
- Refactoring navigation patterns
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Rules | Impact |
|
||||
|----------|----------|-------|--------|
|
||||
| CRITICAL | Type Safety | 4 rules | Prevents runtime errors and enables refactoring |
|
||||
| CRITICAL | Route Organization | 5 rules | Ensures maintainable route structure |
|
||||
| HIGH | Router Config | 1 rule | Global router defaults |
|
||||
| HIGH | Data Loading | 6 rules | Optimizes data fetching and caching |
|
||||
| HIGH | Search Params | 5 rules | Enables type-safe URL state |
|
||||
| HIGH | Error Handling | 1 rule | Handles 404 and errors gracefully |
|
||||
| MEDIUM | Navigation | 5 rules | Improves UX and accessibility |
|
||||
| MEDIUM | Code Splitting | 3 rules | Reduces bundle size |
|
||||
| MEDIUM | Preloading | 3 rules | Improves perceived performance |
|
||||
| LOW | Route Context | 3 rules | Enables dependency injection |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Type Safety (Prefix: `ts-`)
|
||||
|
||||
- `ts-register-router` — Register router type for global inference
|
||||
- `ts-use-from-param` — Use `from` parameter for type narrowing
|
||||
- `ts-route-context-typing` — Type route context with createRootRouteWithContext
|
||||
- `ts-query-options-loader` — Use queryOptions in loaders for type inference
|
||||
|
||||
### Router Config (Prefix: `router-`)
|
||||
|
||||
- `router-default-options` — Configure router defaults (scrollRestoration, defaultErrorComponent, etc.)
|
||||
|
||||
### Route Organization (Prefix: `org-`)
|
||||
|
||||
- `org-file-based-routing` — Prefer file-based routing for conventions
|
||||
- `org-route-tree-structure` — Follow hierarchical route tree patterns
|
||||
- `org-pathless-layouts` — Use pathless routes for shared layouts
|
||||
- `org-index-routes` — Understand index vs layout routes
|
||||
- `org-virtual-routes` — Understand virtual file routes
|
||||
|
||||
### Data Loading (Prefix: `load-`)
|
||||
|
||||
- `load-use-loaders` — Use route loaders for data fetching
|
||||
- `load-loader-deps` — Define loaderDeps for cache control
|
||||
- `load-ensure-query-data` — Use ensureQueryData with TanStack Query
|
||||
- `load-deferred-data` — Split critical and non-critical data
|
||||
- `load-error-handling` — Handle loader errors appropriately
|
||||
- `load-parallel` — Leverage parallel route loading
|
||||
|
||||
### Search Params (Prefix: `search-`)
|
||||
|
||||
- `search-validation` — Always validate search params
|
||||
- `search-type-inheritance` — Leverage parent search param types
|
||||
- `search-middleware` — Use search param middleware
|
||||
- `search-defaults` — Provide sensible defaults
|
||||
- `search-custom-serializer` — Configure custom search param serializers
|
||||
|
||||
### Error Handling (Prefix: `err-`)
|
||||
|
||||
- `err-not-found` — Handle not-found routes properly
|
||||
|
||||
### Navigation (Prefix: `nav-`)
|
||||
|
||||
- `nav-link-component` — Prefer Link component for navigation
|
||||
- `nav-active-states` — Configure active link states
|
||||
- `nav-use-navigate` — Use useNavigate for programmatic navigation
|
||||
- `nav-relative-paths` — Understand relative path navigation
|
||||
- `nav-route-masks` — Use route masks for modal URLs
|
||||
|
||||
### Code Splitting (Prefix: `split-`)
|
||||
|
||||
- `split-lazy-routes` — Use .lazy.tsx for code splitting
|
||||
- `split-critical-path` — Keep critical config in main route file
|
||||
- `split-auto-splitting` — Enable autoCodeSplitting when possible
|
||||
|
||||
### Preloading (Prefix: `preload-`)
|
||||
|
||||
- `preload-intent` — Enable intent-based preloading
|
||||
- `preload-stale-time` — Configure preload stale time
|
||||
- `preload-manual` — Use manual preloading strategically
|
||||
|
||||
### Route Context (Prefix: `ctx-`)
|
||||
|
||||
- `ctx-root-context` — Define context at root route
|
||||
- `ctx-before-load` — Extend context in beforeLoad
|
||||
- `ctx-dependency-injection` — Use context for dependency injection
|
||||
|
||||
## How to Use
|
||||
|
||||
Each rule file in the `rules/` directory contains:
|
||||
1. **Explanation** — Why this pattern matters
|
||||
2. **Bad Example** — Anti-pattern to avoid
|
||||
3. **Good Example** — Recommended implementation
|
||||
4. **Context** — When to apply or skip this rule
|
||||
|
||||
## Full Reference
|
||||
|
||||
See individual rule files in `rules/` directory for detailed guidance and code examples.
|
||||
@@ -0,0 +1,172 @@
|
||||
# ctx-root-context: Define Context at Root Route
|
||||
|
||||
## Priority: LOW
|
||||
|
||||
## Explanation
|
||||
|
||||
Use `createRootRouteWithContext` to define typed context that flows through your entire route tree. This enables dependency injection for things like query clients, auth state, and services.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// No context - importing globals directly
|
||||
// routes/__root.tsx
|
||||
import { createRootRoute } from '@tanstack/react-router'
|
||||
import { queryClient } from '@/lib/query-client' // Global import
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
// routes/posts.tsx
|
||||
import { queryClient } from '@/lib/query-client' // Import again
|
||||
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async () => {
|
||||
// Using global - harder to test, couples to implementation
|
||||
return queryClient.ensureQueryData(postQueries.list())
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// routes/__root.tsx
|
||||
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
// Define the context interface
|
||||
interface RouterContext {
|
||||
queryClient: QueryClient
|
||||
auth: {
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// router.tsx - Provide context when creating router
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export function getRouter(auth: RouterContext['auth'] = { user: null, isAuthenticated: false }) {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
queryClient,
|
||||
auth,
|
||||
},
|
||||
defaultPreload: 'intent',
|
||||
defaultPreloadStaleTime: 0,
|
||||
scrollRestoration: true,
|
||||
})
|
||||
|
||||
setupRouterSsrQueryIntegration({ router, queryClient })
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
// routes/posts.tsx - Use context in loaders
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
// Context is typed and injected
|
||||
return queryClient.ensureQueryData(postQueries.list())
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Auth-Protected Routes
|
||||
|
||||
```tsx
|
||||
// routes/__root.tsx
|
||||
interface RouterContext {
|
||||
queryClient: QueryClient
|
||||
auth: AuthState
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
// routes/_authenticated.tsx - Layout route for protected pages
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
beforeLoad: async ({ context, location }) => {
|
||||
if (!context.auth.isAuthenticated) {
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
search: { redirect: location.href },
|
||||
})
|
||||
}
|
||||
},
|
||||
component: AuthenticatedLayout,
|
||||
})
|
||||
|
||||
// routes/_authenticated/dashboard.tsx
|
||||
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||
loader: async ({ context: { queryClient, auth } }) => {
|
||||
// We know user is authenticated from parent beforeLoad
|
||||
return queryClient.ensureQueryData(
|
||||
dashboardQueries.forUser(auth.user!.id)
|
||||
)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Extending Context with beforeLoad
|
||||
|
||||
```tsx
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
// Extend context with route-specific data
|
||||
const post = await fetchPost(params.postId)
|
||||
|
||||
return {
|
||||
post, // Available to this route and children
|
||||
}
|
||||
},
|
||||
loader: async ({ context }) => {
|
||||
// context now includes 'post' from beforeLoad
|
||||
const comments = await fetchComments(context.post.id)
|
||||
return { comments }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Context vs. Loader Data
|
||||
|
||||
| Context | Loader Data |
|
||||
|---------|-------------|
|
||||
| Available in beforeLoad, loader, and component | Only available in component |
|
||||
| Set at router creation or in beforeLoad | Returned from loader |
|
||||
| Good for services, clients, auth | Good for route-specific data |
|
||||
| Flows down to all children | Specific to route |
|
||||
|
||||
## Context
|
||||
|
||||
- Type the context interface in `createRootRouteWithContext<T>()`
|
||||
- Provide context when calling `createRouter({ context: {...} })`
|
||||
- Context flows from root to all nested routes
|
||||
- Use `beforeLoad` to extend context for specific subtrees
|
||||
- Enables testability - inject mocks in tests
|
||||
- Avoids global imports and singletons
|
||||
@@ -0,0 +1,194 @@
|
||||
# err-not-found: Handle Not-Found Routes Properly
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
Configure `notFoundComponent` to handle 404 errors gracefully. TanStack Router provides not-found handling at multiple levels: root, route-specific, and programmatic via `notFound()`. Proper configuration prevents blank screens and improves UX.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// No not-found handling - shows blank screen or error
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
// Missing defaultNotFoundComponent
|
||||
})
|
||||
|
||||
// Or throwing generic error
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await fetchPost(params.postId)
|
||||
if (!post) {
|
||||
throw new Error('Not found') // Generic error, not proper 404
|
||||
}
|
||||
return post
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Root-Level Not Found
|
||||
|
||||
```tsx
|
||||
// routes/__root.tsx
|
||||
export const Route = createRootRoute({
|
||||
component: RootComponent,
|
||||
notFoundComponent: GlobalNotFound,
|
||||
})
|
||||
|
||||
function GlobalNotFound() {
|
||||
return (
|
||||
<div className="not-found">
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<Link to="/">Go Home</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// router.tsx - Can also set default
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultNotFoundComponent: () => (
|
||||
<div>
|
||||
<h1>404</h1>
|
||||
<Link to="/">Return Home</Link>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Route-Specific Not Found
|
||||
|
||||
```tsx
|
||||
// routes/posts/$postId.tsx
|
||||
import { createFileRoute, notFound } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await fetchPost(params.postId)
|
||||
if (!post) {
|
||||
throw notFound() // Proper 404 handling
|
||||
}
|
||||
return post
|
||||
},
|
||||
notFoundComponent: PostNotFound, // Custom 404 for this route
|
||||
component: PostPage,
|
||||
})
|
||||
|
||||
function PostNotFound() {
|
||||
const { postId } = Route.useParams()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Post Not Found</h1>
|
||||
<p>No post exists with ID: {postId}</p>
|
||||
<Link to="/posts">Browse all posts</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Not Found with Data
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/users/$username')({
|
||||
loader: async ({ params }) => {
|
||||
const user = await fetchUser(params.username)
|
||||
if (!user) {
|
||||
throw notFound({
|
||||
// Pass data to notFoundComponent
|
||||
data: {
|
||||
username: params.username,
|
||||
suggestions: await fetchSimilarUsernames(params.username),
|
||||
},
|
||||
})
|
||||
}
|
||||
return user
|
||||
},
|
||||
notFoundComponent: UserNotFound,
|
||||
})
|
||||
|
||||
function UserNotFound() {
|
||||
const { data } = Route.useMatch()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>User @{data?.username} not found</h1>
|
||||
{data?.suggestions?.length > 0 && (
|
||||
<div>
|
||||
<p>Did you mean:</p>
|
||||
<ul>
|
||||
{data.suggestions.map((username) => (
|
||||
<li key={username}>
|
||||
<Link to="/users/$username" params={{ username }}>
|
||||
@{username}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Catch-All Route
|
||||
|
||||
```tsx
|
||||
// routes/$.tsx - Catch-all splat route
|
||||
export const Route = createFileRoute('/$')({
|
||||
component: CatchAllNotFound,
|
||||
})
|
||||
|
||||
function CatchAllNotFound() {
|
||||
const { _splat } = Route.useParams()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Page Not Found</h1>
|
||||
<p>No page exists at: /{_splat}</p>
|
||||
<Link to="/">Go to homepage</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Nested Not Found Bubbling
|
||||
|
||||
```tsx
|
||||
// Not found bubbles up through route tree
|
||||
// routes/posts.tsx
|
||||
export const Route = createFileRoute('/posts')({
|
||||
notFoundComponent: PostsNotFound, // Catches child 404s too
|
||||
})
|
||||
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await fetchPost(params.postId)
|
||||
if (!post) throw notFound()
|
||||
return post
|
||||
},
|
||||
// No notFoundComponent - bubbles to parent
|
||||
})
|
||||
|
||||
// routes/posts/$postId/comments.tsx
|
||||
export const Route = createFileRoute('/posts/$postId/comments')({
|
||||
loader: async ({ params }) => {
|
||||
const comments = await fetchComments(params.postId)
|
||||
if (!comments) throw notFound() // Bubbles to /posts notFoundComponent
|
||||
return comments
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- `notFound()` throws a special error caught by nearest `notFoundComponent`
|
||||
- Not found bubbles up the route tree if not handled locally
|
||||
- Use `defaultNotFoundComponent` on router for global fallback
|
||||
- Pass data to `notFound({ data })` for contextual 404 pages
|
||||
- Catch-all routes (`/$`) can handle truly unknown paths
|
||||
- Different from error boundaries - specifically for 404 cases
|
||||
@@ -0,0 +1,153 @@
|
||||
# load-ensure-query-data: Use ensureQueryData with TanStack Query
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
When integrating TanStack Router with TanStack Query, use `queryClient.ensureQueryData()` in loaders instead of `prefetchQuery()`. This respects the cache, awaits data if missing, and returns the data for potential use.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Using prefetchQuery - doesn't return data, can't await stale check
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// prefetchQuery never throws, swallows errors
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['posts', params.postId],
|
||||
queryFn: () => fetchPost(params.postId),
|
||||
})
|
||||
// No await - might not complete before render
|
||||
// No return value to use
|
||||
},
|
||||
})
|
||||
|
||||
// Fetching directly - bypasses TanStack Query cache
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async () => {
|
||||
const posts = await fetchPosts() // Not cached
|
||||
return { posts }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// Define queryOptions for reuse
|
||||
const postQueryOptions = (postId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['posts', postId],
|
||||
queryFn: () => fetchPost(postId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// ensureQueryData:
|
||||
// - Returns cached data if fresh
|
||||
// - Fetches and caches if missing or stale
|
||||
// - Awaits completion
|
||||
// - Throws on error (caught by error boundary)
|
||||
await queryClient.ensureQueryData(postQueryOptions(params.postId))
|
||||
},
|
||||
component: PostPage,
|
||||
})
|
||||
|
||||
function PostPage() {
|
||||
const { postId } = Route.useParams()
|
||||
|
||||
// Data guaranteed to exist from loader
|
||||
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
|
||||
|
||||
return <PostContent post={post} />
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Multiple Parallel Queries
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
// Parallel data fetching
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(statsQueries.overview()),
|
||||
queryClient.ensureQueryData(activityQueries.recent()),
|
||||
queryClient.ensureQueryData(notificationQueries.unread()),
|
||||
])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Dependent Queries
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/users/$userId/posts')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// First query needed for second
|
||||
const user = await queryClient.ensureQueryData(
|
||||
userQueries.detail(params.userId)
|
||||
)
|
||||
|
||||
// Dependent query uses result
|
||||
await queryClient.ensureQueryData(
|
||||
postQueries.byAuthor(user.id)
|
||||
)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Router Configuration for TanStack Query
|
||||
|
||||
```tsx
|
||||
// router.tsx
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute default
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
context: { queryClient },
|
||||
|
||||
// Let TanStack Query manage caching
|
||||
defaultPreloadStaleTime: 0,
|
||||
|
||||
// SSR: Dehydrate query cache
|
||||
dehydrate: () => ({
|
||||
queryClientState: dehydrate(queryClient),
|
||||
}),
|
||||
|
||||
// SSR: Hydrate on client
|
||||
hydrate: (dehydrated) => {
|
||||
hydrate(queryClient, dehydrated.queryClientState)
|
||||
},
|
||||
|
||||
// Wrap with QueryClientProvider
|
||||
Wrap: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
## ensureQueryData vs prefetchQuery vs fetchQuery
|
||||
|
||||
| Method | Returns | Throws | Awaits | Use Case |
|
||||
|--------|---------|--------|--------|----------|
|
||||
| `ensureQueryData` | Data | Yes | Yes | Route loaders (recommended) |
|
||||
| `prefetchQuery` | void | No | Yes | Background prefetching |
|
||||
| `fetchQuery` | Data | Yes | Yes | When you need data immediately |
|
||||
|
||||
## Context
|
||||
|
||||
- `ensureQueryData` is the recommended method for route loaders
|
||||
- Respects `staleTime` - won't refetch fresh cached data
|
||||
- Errors propagate to route error boundaries
|
||||
- Use `queryOptions()` factory for type-safe, reusable query definitions
|
||||
- Set `defaultPreloadStaleTime: 0` to let TanStack Query manage cache
|
||||
- Pair with `useSuspenseQuery` in components for guaranteed data
|
||||
@@ -0,0 +1,190 @@
|
||||
# load-parallel: Leverage Parallel Route Loading
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
TanStack Router loads nested route data in parallel, not sequentially. Structure your routes and loaders to maximize parallelization and avoid creating artificial waterfalls.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Creating waterfall with dependent beforeLoad
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
beforeLoad: async () => {
|
||||
const user = await fetchUser() // 200ms
|
||||
const permissions = await fetchPermissions(user.id) // 200ms
|
||||
const preferences = await fetchPreferences(user.id) // 200ms
|
||||
// Total: 600ms (sequential)
|
||||
|
||||
return { user, permissions, preferences }
|
||||
},
|
||||
})
|
||||
|
||||
// Or nesting data dependencies incorrectly
|
||||
// routes/posts.tsx
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async () => {
|
||||
const posts = await fetchPosts() // 300ms
|
||||
return { posts }
|
||||
},
|
||||
})
|
||||
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
// Waits for parent to complete first - waterfall!
|
||||
const post = await fetchPost(params.postId) // +200ms
|
||||
return { post }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Parallel in Single Loader
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
beforeLoad: async () => {
|
||||
// All requests start simultaneously
|
||||
const [user, config] = await Promise.all([
|
||||
fetchUser(), // 200ms
|
||||
fetchAppConfig(), // 150ms
|
||||
])
|
||||
// Total: 200ms (parallel)
|
||||
|
||||
return { user, config }
|
||||
},
|
||||
loader: async ({ context }) => {
|
||||
// These also run in parallel with each other
|
||||
const [stats, activity, notifications] = await Promise.all([
|
||||
fetchDashboardStats(context.user.id),
|
||||
fetchRecentActivity(context.user.id),
|
||||
fetchNotifications(context.user.id),
|
||||
])
|
||||
|
||||
return { stats, activity, notifications }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Parallel Nested Routes
|
||||
|
||||
```tsx
|
||||
// Parent and child loaders run in PARALLEL
|
||||
// routes/posts.tsx
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async () => {
|
||||
// This runs...
|
||||
const categories = await fetchCategories()
|
||||
return { categories }
|
||||
},
|
||||
})
|
||||
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
// ...at the SAME TIME as this!
|
||||
const post = await fetchPost(params.postId)
|
||||
const comments = await fetchComments(params.postId)
|
||||
return { post, comments }
|
||||
},
|
||||
})
|
||||
|
||||
// Navigation to /posts/123:
|
||||
// - Both loaders start simultaneously
|
||||
// - Total time = max(categoriesTime, postTime + commentsTime)
|
||||
// - NOT categoriesTime + postTime + commentsTime
|
||||
```
|
||||
|
||||
## Good Example: With TanStack Query
|
||||
|
||||
```tsx
|
||||
// routes/posts.tsx
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
// These all start in parallel
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(postQueries.list()),
|
||||
queryClient.ensureQueryData(categoryQueries.all()),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// Runs in parallel with parent loader
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(postQueries.detail(params.postId)),
|
||||
queryClient.ensureQueryData(commentQueries.forPost(params.postId)),
|
||||
])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Streaming Non-Critical Data
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// Critical data - await
|
||||
const post = await queryClient.ensureQueryData(
|
||||
postQueries.detail(params.postId)
|
||||
)
|
||||
|
||||
// Non-critical - start but don't await (stream in later)
|
||||
queryClient.prefetchQuery(commentQueries.forPost(params.postId))
|
||||
queryClient.prefetchQuery(relatedQueries.forPost(params.postId))
|
||||
|
||||
return { post }
|
||||
},
|
||||
component: PostPage,
|
||||
})
|
||||
|
||||
function PostPage() {
|
||||
const { post } = Route.useLoaderData()
|
||||
const { postId } = Route.useParams()
|
||||
|
||||
// Critical data ready immediately
|
||||
// Non-critical loads in component with loading state
|
||||
const { data: comments, isLoading } = useQuery(
|
||||
commentQueries.forPost(postId)
|
||||
)
|
||||
|
||||
return (
|
||||
<article>
|
||||
<PostContent post={post} />
|
||||
{isLoading ? <CommentsSkeleton /> : <Comments data={comments} />}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Route Loading Timeline
|
||||
|
||||
```
|
||||
Navigation to /posts/123
|
||||
|
||||
Without parallelization:
|
||||
├─ beforeLoad (parent) ████████
|
||||
├─ loader (parent) ████████
|
||||
├─ beforeLoad (child) ████
|
||||
├─ loader (child) ████████
|
||||
└─ Render █
|
||||
|
||||
With parallelization:
|
||||
├─ beforeLoad (parent) ████████
|
||||
├─ beforeLoad (child) ████
|
||||
├─ loader (parent) ████████
|
||||
├─ loader (child) ████████████
|
||||
└─ Render █
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Nested route loaders run in parallel by default
|
||||
- `beforeLoad` runs before `loader` (for auth, context setup)
|
||||
- Use `Promise.all` for parallel fetches within a single loader
|
||||
- Parent context is available in child loaders (after beforeLoad)
|
||||
- Prefetch non-critical data without awaiting for streaming
|
||||
- Monitor network tab to verify parallelization
|
||||
@@ -0,0 +1,148 @@
|
||||
# load-use-loaders: Use Route Loaders for Data Fetching
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
Route loaders execute before the route renders, enabling data to be ready when the component mounts. This prevents loading waterfalls, enables preloading, and integrates with the router's caching layer.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Fetching in component - creates waterfall
|
||||
function PostsPage() {
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Route renders, THEN data fetches, THEN UI updates
|
||||
fetchPosts().then((data) => {
|
||||
setPosts(data)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
return <PostList posts={posts} />
|
||||
}
|
||||
|
||||
// No preloading possible - user sees loading state on navigation
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// routes/posts.tsx
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async () => {
|
||||
const posts = await fetchPosts()
|
||||
return { posts }
|
||||
},
|
||||
component: PostsPage,
|
||||
})
|
||||
|
||||
function PostsPage() {
|
||||
// Data is ready when component mounts - no loading state needed
|
||||
const { posts } = Route.useLoaderData()
|
||||
return <PostList posts={posts} />
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: With Parameters
|
||||
|
||||
```tsx
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
// params are type-safe and guaranteed to exist
|
||||
const post = await fetchPost(params.postId)
|
||||
const comments = await fetchComments(params.postId)
|
||||
return { post, comments }
|
||||
},
|
||||
component: PostDetailPage,
|
||||
})
|
||||
|
||||
function PostDetailPage() {
|
||||
const { post, comments } = Route.useLoaderData()
|
||||
const { postId } = Route.useParams()
|
||||
|
||||
return (
|
||||
<article>
|
||||
<h1>{post.title}</h1>
|
||||
<PostContent content={post.content} />
|
||||
<CommentList comments={comments} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: With TanStack Query
|
||||
|
||||
```tsx
|
||||
// routes/posts/$postId.tsx
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
|
||||
const postQueryOptions = (postId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['posts', postId],
|
||||
queryFn: () => fetchPost(postId),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// Ensure data is in cache before render
|
||||
await queryClient.ensureQueryData(postQueryOptions(params.postId))
|
||||
},
|
||||
component: PostDetailPage,
|
||||
})
|
||||
|
||||
function PostDetailPage() {
|
||||
const { postId } = Route.useParams()
|
||||
// useSuspenseQuery because loader guarantees data exists
|
||||
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
|
||||
|
||||
return <PostContent post={post} />
|
||||
}
|
||||
```
|
||||
|
||||
## Loader Context Properties
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async ({
|
||||
params, // Route path parameters
|
||||
context, // Route context (queryClient, auth, etc.)
|
||||
abortController, // For cancelling stale requests
|
||||
cause, // 'enter' | 'preload' | 'stay'
|
||||
deps, // Dependencies from loaderDeps
|
||||
preload, // Boolean: true if preloading
|
||||
}) => {
|
||||
// Use abortController for fetch cancellation
|
||||
const response = await fetch('/api/posts', {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
// Different behavior for preload vs navigation
|
||||
if (preload) {
|
||||
// Lighter data for preload
|
||||
return { posts: await response.json() }
|
||||
}
|
||||
|
||||
// Full data for actual navigation
|
||||
const posts = await response.json()
|
||||
const stats = await fetchStats()
|
||||
return { posts, stats }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Loaders run during route matching, before component render
|
||||
- Supports parallel loading across nested routes
|
||||
- Enables preloading on link hover/focus
|
||||
- Built-in stale-while-revalidate caching
|
||||
- For complex caching needs, integrate with TanStack Query
|
||||
- Use `beforeLoad` for auth checks and redirects
|
||||
@@ -0,0 +1,178 @@
|
||||
# nav-link-component: Prefer Link Component for Navigation
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Use the `<Link>` component for navigation instead of `useNavigate()` when possible. Links render proper `<a>` tags with valid `href` attributes, enabling right-click → open in new tab, better SEO, and accessibility.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Using onClick with navigate - loses standard link behavior
|
||||
function PostCard({ post }: { post: Post }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate({ to: '/posts/$postId', params: { postId: post.id } })}
|
||||
className="post-card"
|
||||
>
|
||||
<h2>{post.title}</h2>
|
||||
<p>{post.excerpt}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Problems:
|
||||
// - No right-click → open in new tab
|
||||
// - No cmd/ctrl+click for new tab
|
||||
// - Not announced as link to screen readers
|
||||
// - No valid href for SEO
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
function PostCard({ post }: { post: Post }) {
|
||||
return (
|
||||
<Link
|
||||
to="/posts/$postId"
|
||||
params={{ postId: post.id }}
|
||||
className="post-card"
|
||||
>
|
||||
<h2>{post.title}</h2>
|
||||
<p>{post.excerpt}</p>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
// Benefits:
|
||||
// - Renders <a href="/posts/123">
|
||||
// - Right-click menu works
|
||||
// - Cmd/Ctrl+click opens new tab
|
||||
// - Screen readers announce as link
|
||||
// - Preloading works on hover
|
||||
```
|
||||
|
||||
## Good Example: With Search Params
|
||||
|
||||
```tsx
|
||||
function FilteredLink() {
|
||||
return (
|
||||
<Link
|
||||
to="/products"
|
||||
search={{ category: 'electronics', sort: 'price' }}
|
||||
>
|
||||
View Electronics
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Preserving existing search params
|
||||
function SortLink({ sort }: { sort: 'asc' | 'desc' }) {
|
||||
return (
|
||||
<Link
|
||||
to="." // Current route
|
||||
search={(prev) => ({ ...prev, sort })}
|
||||
>
|
||||
Sort {sort === 'asc' ? 'Ascending' : 'Descending'}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: With Active States
|
||||
|
||||
```tsx
|
||||
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
activeProps={{
|
||||
className: 'nav-link-active',
|
||||
'aria-current': 'page',
|
||||
}}
|
||||
inactiveProps={{
|
||||
className: 'nav-link',
|
||||
}}
|
||||
activeOptions={{
|
||||
exact: true, // Only active on exact match
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Or use render props for more control
|
||||
function CustomNavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link to={to}>
|
||||
{({ isActive }) => (
|
||||
<span className={isActive ? 'text-blue-600 font-bold' : 'text-gray-600'}>
|
||||
{children}
|
||||
{isActive && <CheckIcon className="ml-2" />}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: With Preloading
|
||||
|
||||
```tsx
|
||||
function PostList({ posts }: { posts: Post[] }) {
|
||||
return (
|
||||
<ul>
|
||||
{posts.map(post => (
|
||||
<li key={post.id}>
|
||||
<Link
|
||||
to="/posts/$postId"
|
||||
params={{ postId: post.id }}
|
||||
preload="intent" // Preload on hover/focus
|
||||
preloadDelay={100} // Wait 100ms before preloading
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## When to Use useNavigate Instead
|
||||
|
||||
```tsx
|
||||
// 1. After form submission
|
||||
const createPost = useMutation({
|
||||
mutationFn: submitPost,
|
||||
onSuccess: (data) => {
|
||||
navigate({ to: '/posts/$postId', params: { postId: data.id } })
|
||||
},
|
||||
})
|
||||
|
||||
// 2. After authentication
|
||||
async function handleLogin(credentials: Credentials) {
|
||||
await login(credentials)
|
||||
navigate({ to: '/dashboard' })
|
||||
}
|
||||
|
||||
// 3. Programmatic redirects
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
navigate({ to: '/login', search: { redirect: location.pathname } })
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- `<Link>` renders actual `<a>` tags with proper `href`
|
||||
- Supports all standard link behaviors (middle-click, cmd+click, etc.)
|
||||
- Enables preloading on hover/focus
|
||||
- Better for SEO - crawlers can follow links
|
||||
- Reserve `useNavigate` for side effects and programmatic navigation
|
||||
- Use `<Navigate>` component for immediate redirects on render
|
||||
@@ -0,0 +1,197 @@
|
||||
# nav-route-masks: Use Route Masks for Modal URLs
|
||||
|
||||
## Priority: LOW
|
||||
|
||||
## Explanation
|
||||
|
||||
Route masks let you display one URL while internally routing to another. This is useful for modals, sheets, and overlays where you want a shareable URL that shows the modal, but navigating there directly should show the full page.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Modal without proper URL handling
|
||||
function PostList() {
|
||||
const [selectedPost, setSelectedPost] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{posts.map(post => (
|
||||
<div key={post.id} onClick={() => setSelectedPost(post.id)}>
|
||||
{post.title}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedPost && (
|
||||
<Modal onClose={() => setSelectedPost(null)}>
|
||||
<PostDetail postId={selectedPost} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Problems:
|
||||
// - URL doesn't change when modal opens
|
||||
// - Can't share link to modal
|
||||
// - Back button doesn't close modal
|
||||
// - Refresh loses modal state
|
||||
```
|
||||
|
||||
## Good Example: Route Masks for Modal
|
||||
|
||||
```tsx
|
||||
// routes/posts.tsx
|
||||
export const Route = createFileRoute('/posts')({
|
||||
component: PostList,
|
||||
})
|
||||
|
||||
function PostList() {
|
||||
const posts = usePosts()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{posts.map(post => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to="/posts/$postId"
|
||||
params={{ postId: post.id }}
|
||||
mask={{
|
||||
to: '/posts',
|
||||
// URL shows /posts but routes to /posts/$postId
|
||||
}}
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
))}
|
||||
<Outlet /> {/* Modal renders here */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
component: PostModal,
|
||||
})
|
||||
|
||||
function PostModal() {
|
||||
const { postId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<Modal onClose={() => navigate({ to: '/posts' })}>
|
||||
<PostDetail postId={postId} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// User clicks post:
|
||||
// - URL stays /posts (masked)
|
||||
// - PostModal renders
|
||||
// - Share link goes to /posts/$postId (real URL)
|
||||
// - Direct navigation to /posts/$postId shows full page (no mask)
|
||||
```
|
||||
|
||||
## Good Example: With Search Params
|
||||
|
||||
```tsx
|
||||
function PostList() {
|
||||
return (
|
||||
<div>
|
||||
{posts.map(post => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to="/posts/$postId"
|
||||
params={{ postId: post.id }}
|
||||
mask={{
|
||||
to: '/posts',
|
||||
search: { modal: post.id }, // /posts?modal=123
|
||||
}}
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Programmatic Navigation with Mask
|
||||
|
||||
```tsx
|
||||
function PostCard({ post }: { post: Post }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const openInModal = () => {
|
||||
navigate({
|
||||
to: '/posts/$postId',
|
||||
params: { postId: post.id },
|
||||
mask: {
|
||||
to: '/posts',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openFullPage = () => {
|
||||
navigate({
|
||||
to: '/posts/$postId',
|
||||
params: { postId: post.id },
|
||||
// No mask - shows real URL
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>{post.title}</h3>
|
||||
<button onClick={openInModal}>Quick View</button>
|
||||
<button onClick={openFullPage}>Full Page</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Unmask on Interaction
|
||||
|
||||
```tsx
|
||||
function PostModal() {
|
||||
const { postId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const expandToFullPage = () => {
|
||||
// Navigate to real URL, removing mask
|
||||
navigate({
|
||||
to: '/posts/$postId',
|
||||
params: { postId },
|
||||
// No mask = real URL
|
||||
replace: true, // Replace history entry
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<PostDetail postId={postId} />
|
||||
<button onClick={expandToFullPage}>
|
||||
Expand to full page
|
||||
</button>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Route Mask Behavior
|
||||
|
||||
| Scenario | URL Shown | Actual Route |
|
||||
|----------|-----------|--------------|
|
||||
| Click masked link | Masked URL | Real route |
|
||||
| Share/copy URL | Real URL | Real route |
|
||||
| Direct navigation | Real URL | Real route |
|
||||
| Browser refresh | Depends on URL in bar | Matches URL |
|
||||
| Back button | Previous URL | Previous route |
|
||||
|
||||
## Context
|
||||
|
||||
- Masks are client-side only - shared URLs are the real route
|
||||
- Direct navigation to real URL bypasses mask (shows full page)
|
||||
- Back button navigates through history correctly
|
||||
- Use for modals, side panels, quick views
|
||||
- Masks can include different search params
|
||||
- Consider UX: users expect shared URLs to work
|
||||
@@ -0,0 +1,133 @@
|
||||
# org-virtual-routes: Understand Virtual File Routes
|
||||
|
||||
## Priority: LOW
|
||||
|
||||
## Explanation
|
||||
|
||||
Virtual routes are automatically generated placeholder routes in the route tree when you have a `.lazy.tsx` file without a corresponding main route file. They provide the minimal configuration needed to anchor lazy-loaded components.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Creating unnecessary boilerplate main route files
|
||||
// routes/settings.tsx - Just to have a file
|
||||
export const Route = createFileRoute('/settings')({
|
||||
// Empty - no loader, no beforeLoad, nothing
|
||||
})
|
||||
|
||||
// routes/settings.lazy.tsx - Actual component
|
||||
export const Route = createLazyFileRoute('/settings')({
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
// The main file is unnecessary boilerplate
|
||||
```
|
||||
|
||||
## Good Example: Let Virtual Routes Handle It
|
||||
|
||||
```tsx
|
||||
// Delete routes/settings.tsx entirely!
|
||||
|
||||
// routes/settings.lazy.tsx - Only file needed
|
||||
export const Route = createLazyFileRoute('/settings')({
|
||||
component: SettingsPage,
|
||||
pendingComponent: SettingsLoading,
|
||||
errorComponent: SettingsError,
|
||||
})
|
||||
|
||||
function SettingsPage() {
|
||||
return <div>Settings Content</div>
|
||||
}
|
||||
|
||||
// TanStack Router auto-generates a virtual route:
|
||||
// {
|
||||
// path: '/settings',
|
||||
// // Minimal config to anchor the lazy file
|
||||
// }
|
||||
```
|
||||
|
||||
## Good Example: When You DO Need Main Route File
|
||||
|
||||
```tsx
|
||||
// routes/dashboard.tsx - Need this for loader/beforeLoad
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (!context.auth.isAuthenticated) {
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
},
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
await queryClient.ensureQueryData(dashboardQueries.stats())
|
||||
},
|
||||
// Component is in lazy file
|
||||
})
|
||||
|
||||
// routes/dashboard.lazy.tsx
|
||||
export const Route = createLazyFileRoute('/dashboard')({
|
||||
component: DashboardPage,
|
||||
pendingComponent: DashboardSkeleton,
|
||||
})
|
||||
|
||||
// Main file IS needed here because we have loader/beforeLoad
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Route Has... | Need Main File? | Use Virtual? |
|
||||
|--------------|-----------------|--------------|
|
||||
| Only component | No | Yes |
|
||||
| loader | Yes | No |
|
||||
| beforeLoad | Yes | No |
|
||||
| validateSearch | Yes | No |
|
||||
| loaderDeps | Yes | No |
|
||||
| Just pendingComponent/errorComponent | No | Yes |
|
||||
|
||||
## Good Example: File Structure with Virtual Routes
|
||||
|
||||
```
|
||||
routes/
|
||||
├── __root.tsx # Always needed
|
||||
├── index.tsx # Has loader
|
||||
├── about.lazy.tsx # Virtual route (no main file)
|
||||
├── contact.lazy.tsx # Virtual route (no main file)
|
||||
├── dashboard.tsx # Has beforeLoad (auth)
|
||||
├── dashboard.lazy.tsx # Component
|
||||
├── posts.tsx # Has loader
|
||||
├── posts.lazy.tsx # Component
|
||||
├── posts/
|
||||
│ ├── $postId.tsx # Has loader
|
||||
│ └── $postId.lazy.tsx # Component
|
||||
└── settings/
|
||||
├── index.lazy.tsx # Virtual route
|
||||
├── profile.lazy.tsx # Virtual route
|
||||
└── security.tsx # Has beforeLoad (requires re-auth)
|
||||
```
|
||||
|
||||
## Good Example: Generated Route Tree
|
||||
|
||||
```tsx
|
||||
// routeTree.gen.ts (auto-generated)
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as aboutLazyRoute } from './routes/about.lazy' // Virtual parent
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
// Virtual route created for about.lazy.tsx
|
||||
createRoute({
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRoute,
|
||||
}).lazy(() => import('./routes/about.lazy').then(m => m.Route)),
|
||||
|
||||
// Regular route with explicit main file
|
||||
dashboardRoute.addChildren([...]),
|
||||
])
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Virtual routes reduce boilerplate for simple pages
|
||||
- Only works with file-based routing
|
||||
- Auto-generated in `routeTree.gen.ts`
|
||||
- Main route file needed for any "critical path" config
|
||||
- Critical: loader, beforeLoad, validateSearch, loaderDeps, context
|
||||
- Non-critical (can be in lazy): component, pendingComponent, errorComponent
|
||||
- Check generated route tree to verify virtual routes
|
||||
@@ -0,0 +1,133 @@
|
||||
# preload-intent: Enable Intent-Based Preloading
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Configure `defaultPreload: 'intent'` to preload routes when users hover or focus links. This loads data before the click, making navigation feel instant.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// No preloading configured - data loads after click
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
// No defaultPreload - user waits after every navigation
|
||||
})
|
||||
|
||||
// Each navigation shows loading state
|
||||
function PostList({ posts }: { posts: Post[] }) {
|
||||
return (
|
||||
<ul>
|
||||
{posts.map(post => (
|
||||
<li key={post.id}>
|
||||
<Link to="/posts/$postId" params={{ postId: post.id }}>
|
||||
{post.title}
|
||||
</Link>
|
||||
{/* Click → wait for data → render */}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// router.tsx - Enable preloading by default
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent', // Preload on hover/focus
|
||||
defaultPreloadDelay: 50, // Wait 50ms before starting
|
||||
})
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
// Links automatically preload on hover
|
||||
function PostList({ posts }: { posts: Post[] }) {
|
||||
return (
|
||||
<ul>
|
||||
{posts.map(post => (
|
||||
<li key={post.id}>
|
||||
<Link to="/posts/$postId" params={{ postId: post.id }}>
|
||||
{post.title}
|
||||
</Link>
|
||||
{/* Hover → preload starts → click → instant navigation */}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Preload Options
|
||||
|
||||
```tsx
|
||||
// Router-level defaults
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent', // 'intent' | 'render' | 'viewport' | false
|
||||
defaultPreloadDelay: 50, // ms before preload starts
|
||||
defaultPreloadStaleTime: 30000, // 30s - how long preloaded data stays fresh
|
||||
})
|
||||
|
||||
// Link-level overrides
|
||||
<Link
|
||||
to="/heavy-page"
|
||||
preload={false} // Disable for this specific link
|
||||
>
|
||||
Heavy Page
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/critical-page"
|
||||
preload="render" // Preload immediately when Link renders
|
||||
>
|
||||
Critical Page
|
||||
</Link>
|
||||
```
|
||||
|
||||
## Preload Strategies
|
||||
|
||||
| Strategy | Behavior | Use Case |
|
||||
|----------|----------|----------|
|
||||
| `'intent'` | Preload on hover/focus | Default for most links |
|
||||
| `'render'` | Preload when Link mounts | Critical next pages |
|
||||
| `'viewport'` | Preload when Link enters viewport | Below-fold content |
|
||||
| `false` | No preloading | Heavy, rarely-visited pages |
|
||||
|
||||
## Good Example: With TanStack Query Integration
|
||||
|
||||
```tsx
|
||||
// When using TanStack Query, disable router cache
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
defaultPreloadStaleTime: 0, // Let TanStack Query manage cache
|
||||
context: {
|
||||
queryClient,
|
||||
},
|
||||
})
|
||||
|
||||
// Route loader uses TanStack Query
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// ensureQueryData respects TanStack Query's staleTime
|
||||
await queryClient.ensureQueryData(postQueries.detail(params.postId))
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Preloading loads route code AND executes loaders
|
||||
- `preloadDelay` prevents excessive requests on quick mouse movements
|
||||
- Preloaded data is garbage collected after `preloadStaleTime`
|
||||
- Works with both router caching and external caching (TanStack Query)
|
||||
- Mobile: Consider `'viewport'` since hover isn't available
|
||||
- Monitor network tab to verify preloading works correctly
|
||||
@@ -0,0 +1,163 @@
|
||||
# router-default-options: Configure Router Default Options
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
TanStack Router's `createRouter` accepts several default options that apply globally. Configure these for consistent behavior across your application including error handling, scroll restoration, and performance optimizations.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Minimal router - missing useful defaults
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: { queryClient },
|
||||
})
|
||||
|
||||
// Each route must handle its own errors
|
||||
// No scroll restoration on navigation
|
||||
// No preloading configured
|
||||
```
|
||||
|
||||
## Good Example: Full Configuration
|
||||
|
||||
```tsx
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { DefaultCatchBoundary } from '@/components/DefaultCatchBoundary'
|
||||
import { DefaultNotFound } from '@/components/DefaultNotFound'
|
||||
|
||||
export function getRouter() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: { queryClient, user: null },
|
||||
|
||||
// Preloading
|
||||
defaultPreload: 'intent', // Preload on hover/focus
|
||||
defaultPreloadStaleTime: 0, // Let Query manage freshness
|
||||
|
||||
// Error handling
|
||||
defaultErrorComponent: DefaultCatchBoundary,
|
||||
defaultNotFoundComponent: DefaultNotFound,
|
||||
|
||||
// UX
|
||||
scrollRestoration: true, // Restore scroll on back/forward
|
||||
|
||||
// Performance
|
||||
defaultStructuralSharing: true, // Optimize re-renders
|
||||
})
|
||||
|
||||
setupRouterSsrQueryIntegration({
|
||||
router,
|
||||
queryClient,
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: DefaultCatchBoundary Component
|
||||
|
||||
```tsx
|
||||
// components/DefaultCatchBoundary.tsx
|
||||
import { ErrorComponent, useRouter } from '@tanstack/react-router'
|
||||
|
||||
export function DefaultCatchBoundary({ error }: { error: Error }) {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1>Something went wrong</h1>
|
||||
<ErrorComponent error={error} />
|
||||
<button onClick={() => router.invalidate()}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: DefaultNotFound Component
|
||||
|
||||
```tsx
|
||||
// components/DefaultNotFound.tsx
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
export function DefaultNotFound() {
|
||||
return (
|
||||
<div className="not-found-container">
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<Link to="/">Go home</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Router Options Reference
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `defaultPreload` | `false \| 'intent' \| 'render' \| 'viewport'` | `false` | When to preload routes |
|
||||
| `defaultPreloadStaleTime` | `number` | `30000` | How long preloaded data stays fresh (ms) |
|
||||
| `defaultErrorComponent` | `Component` | Built-in | Global error boundary |
|
||||
| `defaultNotFoundComponent` | `Component` | Built-in | Global 404 page |
|
||||
| `scrollRestoration` | `boolean` | `false` | Restore scroll on navigation |
|
||||
| `defaultStructuralSharing` | `boolean` | `true` | Optimize loader data re-renders |
|
||||
|
||||
## Good Example: Route-Level Overrides
|
||||
|
||||
```tsx
|
||||
// Routes can override defaults
|
||||
export const Route = createFileRoute('/admin')({
|
||||
// Custom error handling for admin section
|
||||
errorComponent: AdminErrorBoundary,
|
||||
notFoundComponent: AdminNotFound,
|
||||
|
||||
// Disable preload for sensitive routes
|
||||
preload: false,
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: With Pending Component
|
||||
|
||||
```tsx
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: { queryClient },
|
||||
|
||||
defaultPreload: 'intent',
|
||||
defaultPreloadStaleTime: 0,
|
||||
defaultErrorComponent: DefaultCatchBoundary,
|
||||
defaultNotFoundComponent: DefaultNotFound,
|
||||
scrollRestoration: true,
|
||||
|
||||
// Show during route transitions
|
||||
defaultPendingComponent: () => (
|
||||
<div className="loading-bar" />
|
||||
),
|
||||
defaultPendingMinMs: 200, // Min time to show pending UI
|
||||
defaultPendingMs: 1000, // Delay before showing pending UI
|
||||
})
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Set `defaultPreloadStaleTime: 0` when using TanStack Query
|
||||
- `scrollRestoration: true` improves back/forward navigation UX
|
||||
- `defaultStructuralSharing` prevents unnecessary re-renders
|
||||
- Route-level options override router defaults
|
||||
- Error/NotFound components receive route context
|
||||
- Pending components help with perceived performance
|
||||
@@ -0,0 +1,198 @@
|
||||
# search-custom-serializer: Configure Custom Search Param Serializers
|
||||
|
||||
## Priority: LOW
|
||||
|
||||
## Explanation
|
||||
|
||||
By default, TanStack Router serializes search params as JSON. For cleaner URLs or compatibility with external systems, you can provide custom serializers using libraries like `qs`, `query-string`, or your own implementation.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Default JSON serialization creates ugly URLs
|
||||
// URL: /products?filters=%7B%22category%22%3A%22electronics%22%2C%22inStock%22%3Atrue%7D
|
||||
|
||||
// Or manually parsing/serializing inconsistently
|
||||
function ProductList() {
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const filters = JSON.parse(searchParams.get('filters') || '{}')
|
||||
// Inconsistent with router's handling
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Using JSURL for Compact URLs
|
||||
|
||||
```tsx
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import JSURL from 'jsurl2'
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
search: {
|
||||
// Custom serializer for compact, URL-safe encoding
|
||||
serialize: (search) => JSURL.stringify(search),
|
||||
parse: (searchString) => JSURL.parse(searchString) || {},
|
||||
},
|
||||
})
|
||||
|
||||
// URL: /products?~(category~'electronics~inStock~true)
|
||||
// Much shorter than JSON!
|
||||
```
|
||||
|
||||
## Good Example: Using query-string for Flat Params
|
||||
|
||||
```tsx
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import queryString from 'query-string'
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
search: {
|
||||
serialize: (search) =>
|
||||
queryString.stringify(search, {
|
||||
arrayFormat: 'bracket',
|
||||
skipNull: true,
|
||||
}),
|
||||
parse: (searchString) =>
|
||||
queryString.parse(searchString, {
|
||||
arrayFormat: 'bracket',
|
||||
parseBooleans: true,
|
||||
parseNumbers: true,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
// URL: /products?category=electronics&inStock=true&tags[]=sale&tags[]=new
|
||||
// Traditional query string format
|
||||
```
|
||||
|
||||
## Good Example: Using qs for Nested Objects
|
||||
|
||||
```tsx
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import qs from 'qs'
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
search: {
|
||||
serialize: (search) =>
|
||||
qs.stringify(search, {
|
||||
encodeValuesOnly: true,
|
||||
arrayFormat: 'brackets',
|
||||
}),
|
||||
parse: (searchString) =>
|
||||
qs.parse(searchString, {
|
||||
ignoreQueryPrefix: true,
|
||||
decoder(value) {
|
||||
// Parse booleans and numbers
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
if (/^-?\d+$/.test(value)) return parseInt(value, 10)
|
||||
return value
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
// URL: /products?filters[category]=electronics&filters[price][min]=100&filters[price][max]=500
|
||||
```
|
||||
|
||||
## Good Example: Base64 for Complex State
|
||||
|
||||
```tsx
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
search: {
|
||||
serialize: (search) => {
|
||||
if (Object.keys(search).length === 0) return ''
|
||||
const json = JSON.stringify(search)
|
||||
return btoa(json) // Base64 encode
|
||||
},
|
||||
parse: (searchString) => {
|
||||
if (!searchString) return {}
|
||||
try {
|
||||
return JSON.parse(atob(searchString)) // Base64 decode
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// URL: /products?eyJjYXRlZ29yeSI6ImVsZWN0cm9uaWNzIn0
|
||||
// Opaque but compact
|
||||
```
|
||||
|
||||
## Good Example: Hybrid Approach
|
||||
|
||||
```tsx
|
||||
// Some params as regular query, complex ones as JSON
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
search: {
|
||||
serialize: (search) => {
|
||||
const { filters, ...simple } = search
|
||||
const params = new URLSearchParams()
|
||||
|
||||
// Simple values as regular params
|
||||
Object.entries(simple).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
params.set(key, String(value))
|
||||
}
|
||||
})
|
||||
|
||||
// Complex filters as JSON
|
||||
if (filters && Object.keys(filters).length > 0) {
|
||||
params.set('filters', JSON.stringify(filters))
|
||||
}
|
||||
|
||||
return params.toString()
|
||||
},
|
||||
parse: (searchString) => {
|
||||
const params = new URLSearchParams(searchString)
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
params.forEach((value, key) => {
|
||||
if (key === 'filters') {
|
||||
result.filters = JSON.parse(value)
|
||||
} else if (value === 'true') {
|
||||
result[key] = true
|
||||
} else if (value === 'false') {
|
||||
result[key] = false
|
||||
} else if (/^-?\d+$/.test(value)) {
|
||||
result[key] = parseInt(value, 10)
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// URL: /products?page=1&sort=price&filters={"category":"electronics","inStock":true}
|
||||
```
|
||||
|
||||
## Serializer Comparison
|
||||
|
||||
| Library | URL Style | Best For |
|
||||
|---------|-----------|----------|
|
||||
| Default (JSON) | `?data=%7B...%7D` | TypeScript safety |
|
||||
| jsurl2 | `?~(key~'value)` | Compact, readable |
|
||||
| query-string | `?key=value&arr[]=1` | Traditional APIs |
|
||||
| qs | `?obj[nested]=value` | Deep nesting |
|
||||
| Base64 | `?eyJrZXkiOiJ2YWx1ZSJ9` | Opaque, compact |
|
||||
|
||||
## Context
|
||||
|
||||
- Custom serializers apply globally to all routes
|
||||
- Route-level `validateSearch` still works after parsing
|
||||
- Consider URL length limits (~2000 chars for safe cross-browser)
|
||||
- SEO: Search engines may not understand custom formats
|
||||
- Bookmarkability: Users can't easily modify opaque URLs
|
||||
- Debugging: JSON is easier to read in browser devtools
|
||||
@@ -0,0 +1,158 @@
|
||||
# search-validation: Always Validate Search Params
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
Search params come from the URL - user-controlled input that must be validated. Use `validateSearch` to parse, validate, and provide defaults. This ensures type safety and prevents runtime errors from malformed URLs.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// No validation - trusting URL input directly
|
||||
export const Route = createFileRoute('/products')({
|
||||
component: ProductsPage,
|
||||
})
|
||||
|
||||
function ProductsPage() {
|
||||
// Accessing raw search params without validation
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const page = parseInt(searchParams.get('page') || '1') // Could be NaN
|
||||
const sort = searchParams.get('sort') as 'asc' | 'desc' // Could be anything
|
||||
|
||||
// Runtime errors possible if URL is malformed
|
||||
return <ProductList page={page} sort={sort} />
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Manual Validation
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/products')({
|
||||
validateSearch: (search: Record<string, unknown>) => {
|
||||
return {
|
||||
page: Number(search.page) || 1,
|
||||
sort: search.sort === 'desc' ? 'desc' : 'asc',
|
||||
category: typeof search.category === 'string' ? search.category : undefined,
|
||||
minPrice: Number(search.minPrice) || undefined,
|
||||
maxPrice: Number(search.maxPrice) || undefined,
|
||||
}
|
||||
},
|
||||
component: ProductsPage,
|
||||
})
|
||||
|
||||
function ProductsPage() {
|
||||
// Fully typed, validated search params
|
||||
const { page, sort, category, minPrice, maxPrice } = Route.useSearch()
|
||||
// page: number (default 1)
|
||||
// sort: 'asc' | 'desc' (default 'asc')
|
||||
// category: string | undefined
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: With Zod
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod'
|
||||
|
||||
const productSearchSchema = z.object({
|
||||
page: z.number().min(1).catch(1),
|
||||
limit: z.number().min(1).max(100).catch(20),
|
||||
sort: z.enum(['name', 'price', 'date']).catch('name'),
|
||||
order: z.enum(['asc', 'desc']).catch('asc'),
|
||||
category: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
minPrice: z.number().min(0).optional(),
|
||||
maxPrice: z.number().min(0).optional(),
|
||||
})
|
||||
|
||||
type ProductSearch = z.infer<typeof productSearchSchema>
|
||||
|
||||
export const Route = createFileRoute('/products')({
|
||||
validateSearch: (search) => productSearchSchema.parse(search),
|
||||
component: ProductsPage,
|
||||
})
|
||||
|
||||
function ProductsPage() {
|
||||
const search = Route.useSearch()
|
||||
// search: ProductSearch - fully typed with defaults
|
||||
|
||||
return (
|
||||
<ProductList
|
||||
page={search.page}
|
||||
limit={search.limit}
|
||||
sort={search.sort}
|
||||
order={search.order}
|
||||
filters={{
|
||||
category: search.category,
|
||||
search: search.search,
|
||||
priceRange: search.minPrice && search.maxPrice
|
||||
? [search.minPrice, search.maxPrice]
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: With Valibot
|
||||
|
||||
```tsx
|
||||
import * as v from 'valibot'
|
||||
import { valibotSearchValidator } from '@tanstack/router-valibot-adapter'
|
||||
|
||||
const searchSchema = v.object({
|
||||
page: v.fallback(v.number(), 1),
|
||||
query: v.fallback(v.string(), ''),
|
||||
filters: v.fallback(
|
||||
v.array(v.string()),
|
||||
[]
|
||||
),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/search')({
|
||||
validateSearch: valibotSearchValidator(searchSchema),
|
||||
component: SearchPage,
|
||||
})
|
||||
```
|
||||
|
||||
## Updating Search Params
|
||||
|
||||
```tsx
|
||||
function ProductFilters() {
|
||||
const navigate = useNavigate()
|
||||
const search = Route.useSearch()
|
||||
|
||||
const updateFilters = (newFilters: Partial<ProductSearch>) => {
|
||||
navigate({
|
||||
to: '.', // Current route
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
...newFilters,
|
||||
page: 1, // Reset to page 1 when filters change
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<select
|
||||
value={search.sort}
|
||||
onChange={(e) => updateFilters({ sort: e.target.value as ProductSearch['sort'] })}
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="price">Price</option>
|
||||
<option value="date">Date</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Search params are user input - never trust them unvalidated
|
||||
- Use `.catch()` in Zod or `fallback()` in Valibot for graceful defaults
|
||||
- Validation runs on every navigation - keep it fast
|
||||
- Search params are inherited by child routes
|
||||
- Use `search` updater function to preserve other params
|
||||
@@ -0,0 +1,148 @@
|
||||
# split-lazy-routes: Use .lazy.tsx for Code Splitting
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Split route components into `.lazy.tsx` files to reduce initial bundle size. The main route file keeps critical configuration (path, loaders, search validation), while lazy files contain components that load on-demand.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// routes/dashboard.tsx - Everything in one file
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { HeavyChartLibrary } from 'heavy-chart-library'
|
||||
import { ComplexDataGrid } from 'complex-data-grid'
|
||||
import { AnalyticsWidgets } from './components/AnalyticsWidgets'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async ({ context }) => {
|
||||
return context.queryClient.ensureQueryData(dashboardQueries.stats())
|
||||
},
|
||||
component: DashboardPage, // Entire component in main bundle
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
// Heavy components loaded even if user never visits dashboard
|
||||
return (
|
||||
<div>
|
||||
<HeavyChartLibrary data={useLoaderData()} />
|
||||
<ComplexDataGrid />
|
||||
<AnalyticsWidgets />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// routes/dashboard.tsx - Only critical config
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async ({ context }) => {
|
||||
return context.queryClient.ensureQueryData(dashboardQueries.stats())
|
||||
},
|
||||
// No component - it's in the lazy file
|
||||
})
|
||||
|
||||
// routes/dashboard.lazy.tsx - Lazy-loaded component
|
||||
import { createLazyFileRoute } from '@tanstack/react-router'
|
||||
import { HeavyChartLibrary } from 'heavy-chart-library'
|
||||
import { ComplexDataGrid } from 'complex-data-grid'
|
||||
import { AnalyticsWidgets } from './components/AnalyticsWidgets'
|
||||
|
||||
export const Route = createLazyFileRoute('/dashboard')({
|
||||
component: DashboardPage,
|
||||
pendingComponent: DashboardSkeleton,
|
||||
errorComponent: DashboardError,
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
const data = Route.useLoaderData()
|
||||
return (
|
||||
<div>
|
||||
<HeavyChartLibrary data={data} />
|
||||
<ComplexDataGrid />
|
||||
<AnalyticsWidgets />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return <div className="dashboard-skeleton">Loading dashboard...</div>
|
||||
}
|
||||
|
||||
function DashboardError({ error }: { error: Error }) {
|
||||
return <div>Failed to load dashboard: {error.message}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## What Goes Where
|
||||
|
||||
```tsx
|
||||
// Main route file (routes/example.tsx)
|
||||
// - path configuration (implicit from file location)
|
||||
// - validateSearch
|
||||
// - beforeLoad (auth checks, redirects)
|
||||
// - loader (data fetching)
|
||||
// - loaderDeps
|
||||
// - context manipulation
|
||||
// - Static route data
|
||||
|
||||
// Lazy file (routes/example.lazy.tsx)
|
||||
// - component
|
||||
// - pendingComponent
|
||||
// - errorComponent
|
||||
// - notFoundComponent
|
||||
```
|
||||
|
||||
## Using getRouteApi in Lazy Components
|
||||
|
||||
```tsx
|
||||
// routes/posts/$postId.lazy.tsx
|
||||
import { createLazyFileRoute, getRouteApi } from '@tanstack/react-router'
|
||||
|
||||
const route = getRouteApi('/posts/$postId')
|
||||
|
||||
export const Route = createLazyFileRoute('/posts/$postId')({
|
||||
component: PostPage,
|
||||
})
|
||||
|
||||
function PostPage() {
|
||||
// Type-safe access without importing main route file
|
||||
const { postId } = route.useParams()
|
||||
const data = route.useLoaderData()
|
||||
|
||||
return <article>{/* ... */}</article>
|
||||
}
|
||||
```
|
||||
|
||||
## Automatic Code Splitting
|
||||
|
||||
```tsx
|
||||
// vite.config.ts - Enable automatic splitting
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite({
|
||||
autoCodeSplitting: true, // Automatically splits all route components
|
||||
}),
|
||||
react(),
|
||||
],
|
||||
})
|
||||
|
||||
// With autoCodeSplitting, you don't need .lazy.tsx files
|
||||
// The plugin handles the splitting automatically
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Lazy loading reduces initial bundle size significantly
|
||||
- Loaders are NOT lazy - they need to run before rendering
|
||||
- `createLazyFileRoute` only accepts component-related options
|
||||
- Use `getRouteApi()` for type-safe hook access in lazy files
|
||||
- Consider `autoCodeSplitting: true` for simpler setup
|
||||
- Virtual routes auto-generate when only .lazy.tsx exists
|
||||
@@ -0,0 +1,113 @@
|
||||
# ts-register-router: Register Router Type for Global Inference
|
||||
|
||||
## Priority: CRITICAL
|
||||
|
||||
## Explanation
|
||||
|
||||
Register your router instance with TypeScript's module declaration to enable type inference across your entire application. Without registration, hooks like `useNavigate`, `useParams`, and `useSearch` won't know your route structure.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// router.tsx - Missing type registration
|
||||
import { createRouter, createRootRoute } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
// components/Navigation.tsx
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
function Navigation() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// TypeScript doesn't know valid routes - no autocomplete or type checking
|
||||
navigate({ to: '/posts/$postId' }) // No error even if route doesn't exist
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// router.tsx
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
// Register the router instance for type inference
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
// components/Navigation.tsx
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
function Navigation() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Full type safety - TypeScript knows all valid routes
|
||||
navigate({ to: '/posts/$postId', params: { postId: '123' } })
|
||||
|
||||
// Type error if route doesn't exist
|
||||
navigate({ to: '/invalid-route' }) // Error: Type '"/invalid-route"' is not assignable...
|
||||
|
||||
// Autocomplete for params
|
||||
navigate({
|
||||
to: '/users/$userId/posts/$postId',
|
||||
params: { userId: '1', postId: '2' }, // Both required
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits of Registration
|
||||
|
||||
```tsx
|
||||
// After registration, all these get full type inference:
|
||||
|
||||
// 1. Navigation
|
||||
const navigate = useNavigate()
|
||||
navigate({ to: '/posts/$postId', params: { postId: '123' } })
|
||||
|
||||
// 2. Link component
|
||||
<Link to="/posts/$postId" params={{ postId: '123' }}>View Post</Link>
|
||||
|
||||
// 3. useParams hook
|
||||
const { postId } = useParams({ from: '/posts/$postId' }) // postId: string
|
||||
|
||||
// 4. useSearch hook
|
||||
const search = useSearch({ from: '/posts' }) // Knows search param types
|
||||
|
||||
// 5. useLoaderData hook
|
||||
const data = useLoaderData({ from: '/posts/$postId' }) // Knows loader return type
|
||||
```
|
||||
|
||||
## File-Based Routing Setup
|
||||
|
||||
```tsx
|
||||
// With file-based routing, routeTree is auto-generated
|
||||
// router.tsx
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen' // Generated file
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
})
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Must be done once, typically in your router configuration file
|
||||
- Enables IDE autocomplete for routes, params, and search params
|
||||
- Catches invalid routes at compile time
|
||||
- Works with both file-based and code-based routing
|
||||
- Required for full TypeScript benefits of TanStack Router
|
||||
@@ -0,0 +1,130 @@
|
||||
# ts-use-from-param: Use `from` Parameter for Type Narrowing
|
||||
|
||||
## Priority: CRITICAL
|
||||
|
||||
## Explanation
|
||||
|
||||
When using hooks like `useParams`, `useSearch`, or `useLoaderData`, provide the `from` parameter to get exact types for that route. Without it, TypeScript returns a union of all possible types across all routes.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Without 'from' - TypeScript doesn't know which route's types to use
|
||||
function PostDetail() {
|
||||
// params could be from ANY route - types are unioned
|
||||
const params = useParams()
|
||||
// params: { postId?: string; userId?: string; categoryId?: string; ... }
|
||||
|
||||
// TypeScript can't guarantee postId exists
|
||||
console.log(params.postId) // postId: string | undefined
|
||||
}
|
||||
|
||||
// Similarly for search params
|
||||
function SearchResults() {
|
||||
const search = useSearch()
|
||||
// search: union of ALL routes' search params
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// With 'from' - exact types for this specific route
|
||||
function PostDetail() {
|
||||
const params = useParams({ from: '/posts/$postId' })
|
||||
// params: { postId: string } - exactly what this route provides
|
||||
|
||||
console.log(params.postId) // postId: string (guaranteed)
|
||||
}
|
||||
|
||||
// Full path matching
|
||||
function UserPost() {
|
||||
const params = useParams({ from: '/users/$userId/posts/$postId' })
|
||||
// params: { userId: string; postId: string }
|
||||
}
|
||||
|
||||
// Search params with type narrowing
|
||||
function SearchResults() {
|
||||
const search = useSearch({ from: '/search' })
|
||||
// search: exactly the validated search params for /search route
|
||||
}
|
||||
|
||||
// Loader data with type inference
|
||||
function PostPage() {
|
||||
const { post, comments } = useLoaderData({ from: '/posts/$postId' })
|
||||
// Exact types from your loader function
|
||||
}
|
||||
```
|
||||
|
||||
## Using Route.fullPath for Type Safety
|
||||
|
||||
```tsx
|
||||
// routes/posts/$postId.tsx
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await fetchPost(params.postId)
|
||||
return { post }
|
||||
},
|
||||
component: PostComponent,
|
||||
})
|
||||
|
||||
function PostComponent() {
|
||||
// Use Route.fullPath for guaranteed type matching
|
||||
const params = useParams({ from: Route.fullPath })
|
||||
const { post } = useLoaderData({ from: Route.fullPath })
|
||||
|
||||
// Or use route-specific helper (preferred in same file)
|
||||
const { postId } = Route.useParams()
|
||||
const data = Route.useLoaderData()
|
||||
}
|
||||
```
|
||||
|
||||
## Using getRouteApi for Code-Split Components
|
||||
|
||||
```tsx
|
||||
// components/PostDetail.tsx (separate file from route)
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
|
||||
// Get type-safe access without importing the route
|
||||
const postRoute = getRouteApi('/posts/$postId')
|
||||
|
||||
export function PostDetail() {
|
||||
const params = postRoute.useParams()
|
||||
// params: { postId: string }
|
||||
|
||||
const data = postRoute.useLoaderData()
|
||||
// data: exact loader return type
|
||||
|
||||
const search = postRoute.useSearch()
|
||||
// search: exact search param types
|
||||
}
|
||||
```
|
||||
|
||||
## When to Use strict: false
|
||||
|
||||
```tsx
|
||||
// In shared components that work across multiple routes
|
||||
function Breadcrumbs() {
|
||||
// strict: false returns union types but allows component reuse
|
||||
const params = useParams({ strict: false })
|
||||
const location = useLocation()
|
||||
|
||||
// params may or may not have certain values
|
||||
return (
|
||||
<nav>
|
||||
{params.userId && <span>User: {params.userId}</span>}
|
||||
{params.postId && <span>Post: {params.postId}</span>}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Always use `from` in route-specific components for exact types
|
||||
- Use `Route.useParams()` / `Route.useLoaderData()` within route files
|
||||
- Use `getRouteApi()` in components split from route files
|
||||
- Use `strict: false` only in truly generic, cross-route components
|
||||
- The `from` path must match exactly (including params like `$postId`)
|
||||
109
.agent/skills/tanstack-start-best-practices/SKILL.md
Normal file
109
.agent/skills/tanstack-start-best-practices/SKILL.md
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: tanstack-start-best-practices
|
||||
description: TanStack Start best practices for full-stack React applications. Server functions, middleware, SSR, authentication, and deployment patterns. Activate when building full-stack apps with TanStack Start.
|
||||
---
|
||||
|
||||
# TanStack Start Best Practices
|
||||
|
||||
Comprehensive guidelines for implementing TanStack Start patterns in full-stack React applications. These rules cover server functions, middleware, SSR, authentication, and deployment.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- Creating server functions for data mutations
|
||||
- Setting up middleware for auth/logging
|
||||
- Configuring SSR and hydration
|
||||
- Implementing authentication flows
|
||||
- Handling errors across client/server boundary
|
||||
- Organizing full-stack code
|
||||
- Deploying to various platforms
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Rules | Impact |
|
||||
|----------|----------|-------|--------|
|
||||
| CRITICAL | Server Functions | 5 rules | Core data mutation patterns |
|
||||
| CRITICAL | Security | 4 rules | Prevents vulnerabilities |
|
||||
| HIGH | Middleware | 4 rules | Request/response handling |
|
||||
| HIGH | Authentication | 4 rules | Secure user sessions |
|
||||
| MEDIUM | API Routes | 1 rule | External endpoint patterns |
|
||||
| MEDIUM | SSR | 6 rules | Server rendering patterns |
|
||||
| MEDIUM | Error Handling | 3 rules | Graceful failure handling |
|
||||
| MEDIUM | Environment | 1 rule | Configuration management |
|
||||
| LOW | File Organization | 3 rules | Maintainable code structure |
|
||||
| LOW | Deployment | 2 rules | Production readiness |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Server Functions (Prefix: `sf-`)
|
||||
|
||||
- `sf-create-server-fn` — Use createServerFn for server-side logic
|
||||
- `sf-input-validation` — Always validate server function inputs
|
||||
- `sf-method-selection` — Choose appropriate HTTP method
|
||||
- `sf-error-handling` — Handle errors in server functions
|
||||
- `sf-response-headers` — Customize response headers when needed
|
||||
|
||||
### Security (Prefix: `sec-`)
|
||||
|
||||
- `sec-validate-inputs` — Validate all user inputs with schemas
|
||||
- `sec-auth-middleware` — Protect routes with auth middleware
|
||||
- `sec-sensitive-data` — Keep secrets server-side only
|
||||
- `sec-csrf-protection` — Implement CSRF protection for mutations
|
||||
|
||||
### Middleware (Prefix: `mw-`)
|
||||
|
||||
- `mw-request-middleware` — Use request middleware for cross-cutting concerns
|
||||
- `mw-function-middleware` — Use function middleware for server functions
|
||||
- `mw-context-flow` — Properly pass context through middleware
|
||||
- `mw-composability` — Compose middleware effectively
|
||||
|
||||
### Authentication (Prefix: `auth-`)
|
||||
|
||||
- `auth-session-management` — Implement secure session handling
|
||||
- `auth-route-protection` — Protect routes with beforeLoad
|
||||
- `auth-server-functions` — Verify auth in server functions
|
||||
- `auth-cookie-security` — Configure secure cookie settings
|
||||
|
||||
### API Routes (Prefix: `api-`)
|
||||
|
||||
- `api-routes` — Create API routes for external consumers
|
||||
|
||||
### SSR (Prefix: `ssr-`)
|
||||
|
||||
- `ssr-data-loading` — Load data appropriately for SSR
|
||||
- `ssr-hydration-safety` — Prevent hydration mismatches
|
||||
- `ssr-streaming` — Implement streaming SSR for faster TTFB
|
||||
- `ssr-selective` — Apply selective SSR when beneficial
|
||||
- `ssr-prerender` — Configure static prerendering and ISR
|
||||
|
||||
### Environment (Prefix: `env-`)
|
||||
|
||||
- `env-functions` — Use environment functions for configuration
|
||||
|
||||
### Error Handling (Prefix: `err-`)
|
||||
|
||||
- `err-server-errors` — Handle server function errors
|
||||
- `err-redirects` — Use redirects appropriately
|
||||
- `err-not-found` — Handle not-found scenarios
|
||||
|
||||
### File Organization (Prefix: `file-`)
|
||||
|
||||
- `file-separation` — Separate server and client code
|
||||
- `file-functions-file` — Use .functions.ts pattern
|
||||
- `file-shared-validation` — Share validation schemas
|
||||
|
||||
### Deployment (Prefix: `deploy-`)
|
||||
|
||||
- `deploy-env-config` — Configure environment variables
|
||||
- `deploy-adapters` — Choose appropriate deployment adapter
|
||||
|
||||
## How to Use
|
||||
|
||||
Each rule file in the `rules/` directory contains:
|
||||
1. **Explanation** — Why this pattern matters
|
||||
2. **Bad Example** — Anti-pattern to avoid
|
||||
3. **Good Example** — Recommended implementation
|
||||
4. **Context** — When to apply or skip this rule
|
||||
|
||||
## Full Reference
|
||||
|
||||
See individual rule files in `rules/` directory for detailed guidance and code examples.
|
||||
238
.agent/skills/tanstack-start-best-practices/rules/api-routes.md
Normal file
238
.agent/skills/tanstack-start-best-practices/rules/api-routes.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# api-routes: Create Server Routes for External Consumers
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
While server functions are ideal for internal RPC, server routes provide traditional REST endpoints for external consumers, webhooks, and integrations. Use server routes when you need standard HTTP semantics, custom response formats, or third-party compatibility.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Using server functions for webhook endpoints
|
||||
export const stripeWebhook = createServerFn({ method: 'POST' })
|
||||
.handler(async ({ request }) => {
|
||||
// Server functions aren't designed for raw request handling
|
||||
// No easy access to raw body for signature verification
|
||||
// Response format is JSON by default
|
||||
})
|
||||
|
||||
// Or exposing internal functions to external consumers
|
||||
export const getUsers = createServerFn()
|
||||
.handler(async () => {
|
||||
return db.users.findMany()
|
||||
})
|
||||
// No versioning, no standard REST semantics
|
||||
```
|
||||
|
||||
## Good Example: Basic Server Route
|
||||
|
||||
```tsx
|
||||
// routes/api/users.ts
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
|
||||
export const Route = createFileRoute('/api/users')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const users = await db.users.findMany({
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
return json(users, {
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
POST: async ({ request }) => {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate input
|
||||
const parsed = createUserSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return json({ error: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await db.users.create({ data: parsed.data })
|
||||
return json(user, { status: 201 })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Webhook Handler
|
||||
|
||||
```tsx
|
||||
// routes/api/webhooks/stripe.ts
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
|
||||
|
||||
export const Route = createFileRoute('/api/webhooks/stripe')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const signature = request.headers.get('stripe-signature')
|
||||
if (!signature) {
|
||||
return new Response('Missing signature', { status: 400 })
|
||||
}
|
||||
|
||||
// Get raw body for signature verification
|
||||
const rawBody = await request.text()
|
||||
|
||||
let event: Stripe.Event
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Webhook signature verification failed:', err)
|
||||
return new Response('Invalid signature', { status: 400 })
|
||||
}
|
||||
|
||||
// Handle the event
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
await handleCheckoutComplete(event.data.object)
|
||||
break
|
||||
case 'customer.subscription.updated':
|
||||
await handleSubscriptionUpdate(event.data.object)
|
||||
break
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`)
|
||||
}
|
||||
|
||||
return new Response('OK', { status: 200 })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: RESTful Resource with Dynamic Params
|
||||
|
||||
```tsx
|
||||
// routes/api/posts/$postId.ts
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
|
||||
export const Route = createFileRoute('/api/posts/$postId')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ params }) => {
|
||||
const post = await db.posts.findUnique({
|
||||
where: { id: params.postId },
|
||||
})
|
||||
|
||||
if (!post) {
|
||||
return json({ error: 'Post not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return json(post)
|
||||
},
|
||||
|
||||
PUT: async ({ request, params }) => {
|
||||
const body = await request.json()
|
||||
const parsed = updatePostSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return json({ error: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
const post = await db.posts.update({
|
||||
where: { id: params.postId },
|
||||
data: parsed.data,
|
||||
})
|
||||
|
||||
return json(post)
|
||||
},
|
||||
|
||||
DELETE: async ({ params }) => {
|
||||
await db.posts.delete({ where: { id: params.postId } })
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: With Route-Level Middleware
|
||||
|
||||
```tsx
|
||||
// routes/api/protected/data.ts
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { apiKeyMiddleware } from '@/lib/middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/protected/data')({
|
||||
server: {
|
||||
// Middleware applies to all handlers in this route
|
||||
middleware: [apiKeyMiddleware],
|
||||
handlers: {
|
||||
GET: async ({ request, context }) => {
|
||||
// context.client available from middleware
|
||||
const data = await fetchDataForClient(context.client.id)
|
||||
return json(data)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Using createHandlers for Handler-Specific Middleware
|
||||
|
||||
```tsx
|
||||
// routes/api/admin/users.ts
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
|
||||
export const Route = createFileRoute('/api/admin/users')({
|
||||
server: {
|
||||
middleware: [authMiddleware], // All handlers require auth
|
||||
handlers: (createHandlers) => ({
|
||||
GET: createHandlers.GET(async ({ context }) => {
|
||||
const users = await db.users.findMany()
|
||||
return json(users)
|
||||
}),
|
||||
|
||||
// DELETE requires additional admin middleware
|
||||
DELETE: createHandlers.DELETE({
|
||||
middleware: [adminOnlyMiddleware],
|
||||
handler: async ({ request, context }) => {
|
||||
const { userId } = await request.json()
|
||||
await db.users.delete({ where: { id: userId } })
|
||||
return json({ deleted: true })
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Server Functions vs Server Routes
|
||||
|
||||
| Feature | Server Functions | Server Routes |
|
||||
|---------|-----------------|--------------|
|
||||
| Primary use | Internal RPC | External consumers |
|
||||
| Type safety | Full end-to-end | Manual |
|
||||
| Response format | JSON (automatic) | Any (manual) |
|
||||
| Raw request access | Limited | Full |
|
||||
| URL structure | Auto-generated | Explicit paths |
|
||||
| Webhooks | Not ideal | Designed for |
|
||||
|
||||
## Context
|
||||
|
||||
- Server routes use `createFileRoute` with a `server.handlers` property
|
||||
- Support all HTTP methods: GET, POST, PUT, PATCH, DELETE, etc.
|
||||
- Use `json()` helper for JSON responses
|
||||
- Return `Response` objects for custom formats
|
||||
- Handler receives `{ request, params }` object
|
||||
- Ideal for: webhooks, public APIs, file downloads, third-party integrations
|
||||
- Consider versioning: `/api/v1/users` for public APIs
|
||||
@@ -0,0 +1,192 @@
|
||||
# auth-route-protection: Protect Routes with beforeLoad
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
Use `beforeLoad` in route definitions to check authentication before the route loads. This prevents unauthorized access, redirects to login, and can extend context with user data for child routes.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Checking auth in component - too late, data may have loaded
|
||||
function DashboardPage() {
|
||||
const user = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate({ to: '/login' }) // Redirect after render
|
||||
}
|
||||
}, [user])
|
||||
|
||||
if (!user) return null // Flash of content possible
|
||||
|
||||
return <Dashboard user={user} />
|
||||
}
|
||||
|
||||
// No protection on route
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async () => {
|
||||
// Fetches sensitive data even for unauthenticated users
|
||||
return await fetchDashboardData()
|
||||
},
|
||||
component: DashboardPage,
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Route-Level Protection
|
||||
|
||||
```tsx
|
||||
// routes/_authenticated.tsx - Layout route for protected area
|
||||
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
|
||||
import { getSessionData } from '@/lib/session.server'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
beforeLoad: async ({ location }) => {
|
||||
const session = await getSessionData()
|
||||
|
||||
if (!session) {
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
search: {
|
||||
redirect: location.href,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Extend context with user for all child routes
|
||||
return {
|
||||
user: session,
|
||||
}
|
||||
},
|
||||
component: AuthenticatedLayout,
|
||||
})
|
||||
|
||||
function AuthenticatedLayout() {
|
||||
return (
|
||||
<div>
|
||||
<AuthenticatedNav />
|
||||
<main>
|
||||
<Outlet /> {/* Child routes render here */}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// routes/_authenticated/dashboard.tsx
|
||||
// This route is automatically protected by parent
|
||||
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||
loader: async ({ context }) => {
|
||||
// context.user is guaranteed to exist
|
||||
return await fetchDashboardData(context.user.id)
|
||||
},
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
const data = Route.useLoaderData()
|
||||
const { user } = Route.useRouteContext()
|
||||
|
||||
return <Dashboard data={data} user={user} />
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Role-Based Access
|
||||
|
||||
```tsx
|
||||
// routes/_admin.tsx
|
||||
export const Route = createFileRoute('/_admin')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
// context.user comes from parent _authenticated route
|
||||
if (context.user.role !== 'admin') {
|
||||
throw redirect({ to: '/unauthorized' })
|
||||
}
|
||||
},
|
||||
component: AdminLayout,
|
||||
})
|
||||
|
||||
// File structure:
|
||||
// routes/
|
||||
// _authenticated.tsx # Requires login
|
||||
// _authenticated/
|
||||
// dashboard.tsx # /dashboard - any authenticated user
|
||||
// settings.tsx # /settings - any authenticated user
|
||||
// _admin.tsx # Admin layout
|
||||
// _admin/
|
||||
// users.tsx # /users - admin only
|
||||
// analytics.tsx # /analytics - admin only
|
||||
```
|
||||
|
||||
## Good Example: Preserving Redirect URL
|
||||
|
||||
```tsx
|
||||
// routes/login.tsx
|
||||
import { z } from 'zod'
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
validateSearch: z.object({
|
||||
redirect: z.string().optional(),
|
||||
}),
|
||||
component: LoginPage,
|
||||
})
|
||||
|
||||
function LoginPage() {
|
||||
const { redirect } = Route.useSearch()
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: () => {
|
||||
// Redirect to original destination or default
|
||||
navigate({ to: redirect ?? '/dashboard' })
|
||||
},
|
||||
})
|
||||
|
||||
return <LoginForm onSubmit={loginMutation.mutate} />
|
||||
}
|
||||
|
||||
// In protected routes
|
||||
beforeLoad: async ({ location }) => {
|
||||
if (!session) {
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
search: { redirect: location.href },
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Conditional Content Based on Auth
|
||||
|
||||
```tsx
|
||||
// Public route with different content for logged-in users
|
||||
export const Route = createFileRoute('/')({
|
||||
beforeLoad: async () => {
|
||||
const session = await getSessionData()
|
||||
return { user: session?.user ?? null }
|
||||
},
|
||||
component: HomePage,
|
||||
})
|
||||
|
||||
function HomePage() {
|
||||
const { user } = Route.useRouteContext()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Hero />
|
||||
{user ? (
|
||||
<PersonalizedContent user={user} />
|
||||
) : (
|
||||
<SignUpCTA />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- `beforeLoad` runs before route loading begins
|
||||
- Throwing `redirect()` prevents route from loading
|
||||
- Context from `beforeLoad` flows to loader and component
|
||||
- Child routes inherit parent's `beforeLoad` protection
|
||||
- Use pathless layout routes (`_authenticated.tsx`) for grouped protection
|
||||
- Store redirect URL in search params for post-login navigation
|
||||
@@ -0,0 +1,191 @@
|
||||
# auth-session-management: Implement Secure Session Handling
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
Sessions maintain user authentication state across requests. Use HTTP-only cookies with secure settings to prevent XSS and CSRF attacks. Never store sensitive data in client-accessible storage.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Storing auth in localStorage - vulnerable to XSS
|
||||
function login(credentials: Credentials) {
|
||||
const token = await authenticate(credentials)
|
||||
localStorage.setItem('authToken', token) // XSS can steal this
|
||||
}
|
||||
|
||||
// Non-HTTP-only cookie - JavaScript accessible
|
||||
export const setSession = createServerFn({ method: 'POST' })
|
||||
.handler(async ({ data }) => {
|
||||
setResponseHeader('Set-Cookie', `session=${data.token}`) // Not secure
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Secure Session Cookie
|
||||
|
||||
```tsx
|
||||
// lib/session.server.ts
|
||||
import { useSession } from '@tanstack/react-start/server'
|
||||
|
||||
// Configure session with secure defaults
|
||||
export function getSession() {
|
||||
return useSession({
|
||||
password: process.env.SESSION_SECRET!, // At least 32 characters
|
||||
cookie: {
|
||||
name: '__session',
|
||||
httpOnly: true, // Not accessible via JavaScript
|
||||
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
|
||||
sameSite: 'lax', // CSRF protection
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Usage in server function
|
||||
export const login = createServerFn({ method: 'POST' })
|
||||
.validator(loginSchema)
|
||||
.handler(async ({ data }) => {
|
||||
const session = await getSession()
|
||||
|
||||
// Verify credentials
|
||||
const user = await verifyCredentials(data.email, data.password)
|
||||
if (!user) {
|
||||
throw new Error('Invalid credentials')
|
||||
}
|
||||
|
||||
// Store only essential data in session
|
||||
await session.update({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Full Authentication Flow
|
||||
|
||||
```tsx
|
||||
// lib/auth.functions.ts
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { redirect } from '@tanstack/react-router'
|
||||
import { getSession } from './session.server'
|
||||
import { hashPassword, verifyPassword } from './password.server'
|
||||
|
||||
// Login
|
||||
export const login = createServerFn({ method: 'POST' })
|
||||
.validator(z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
}))
|
||||
.handler(async ({ data }) => {
|
||||
const user = await db.users.findUnique({
|
||||
where: { email: data.email },
|
||||
})
|
||||
|
||||
if (!user || !await verifyPassword(data.password, user.passwordHash)) {
|
||||
throw new Error('Invalid email or password')
|
||||
}
|
||||
|
||||
const session = await getSession()
|
||||
await session.update({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
})
|
||||
|
||||
throw redirect({ to: '/dashboard' })
|
||||
})
|
||||
|
||||
// Logout
|
||||
export const logout = createServerFn({ method: 'POST' })
|
||||
.handler(async () => {
|
||||
const session = await getSession()
|
||||
await session.clear()
|
||||
throw redirect({ to: '/' })
|
||||
})
|
||||
|
||||
// Get current user
|
||||
export const getCurrentUser = createServerFn()
|
||||
.handler(async () => {
|
||||
const session = await getSession()
|
||||
const data = await session.data
|
||||
|
||||
if (!data?.userId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const user = await db.users.findUnique({
|
||||
where: { id: data.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
// Don't include passwordHash!
|
||||
},
|
||||
})
|
||||
|
||||
return user
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Session with Role-Based Access
|
||||
|
||||
```tsx
|
||||
// lib/session.server.ts
|
||||
interface SessionData {
|
||||
userId: string
|
||||
email: string
|
||||
role: 'user' | 'admin'
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export async function getSessionData(): Promise<SessionData | null> {
|
||||
const session = await getSession()
|
||||
const data = await session.data
|
||||
|
||||
if (!data?.userId) return null
|
||||
|
||||
// Validate session age
|
||||
const maxAge = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
if (Date.now() - data.createdAt > maxAge) {
|
||||
await session.clear()
|
||||
return null
|
||||
}
|
||||
|
||||
return data as SessionData
|
||||
}
|
||||
|
||||
// Middleware for admin-only routes
|
||||
export const requireAdmin = createMiddleware()
|
||||
.server(async ({ next }) => {
|
||||
const session = await getSessionData()
|
||||
|
||||
if (!session || session.role !== 'admin') {
|
||||
throw redirect({ to: '/unauthorized' })
|
||||
}
|
||||
|
||||
return next({ context: { session } })
|
||||
})
|
||||
```
|
||||
|
||||
## Session Security Checklist
|
||||
|
||||
| Setting | Value | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `httpOnly` | `true` | Prevents XSS from accessing cookie |
|
||||
| `secure` | `true` in prod | Requires HTTPS |
|
||||
| `sameSite` | `'lax'` or `'strict'` | CSRF protection |
|
||||
| `maxAge` | Application-specific | Session duration |
|
||||
| `password` | 32+ random chars | Encryption key |
|
||||
|
||||
## Context
|
||||
|
||||
- Always use HTTP-only cookies for session tokens
|
||||
- Generate `SESSION_SECRET` with `openssl rand -base64 32`
|
||||
- Store minimal data in session - fetch user details on demand
|
||||
- Implement session rotation on privilege changes
|
||||
- Consider session invalidation on password change
|
||||
- Use `sameSite: 'strict'` for highest CSRF protection
|
||||
@@ -0,0 +1,201 @@
|
||||
# deploy-adapters: Choose Appropriate Deployment Adapter
|
||||
|
||||
## Priority: LOW
|
||||
|
||||
## Explanation
|
||||
|
||||
TanStack Start uses deployment adapters to target different hosting platforms. Each adapter optimizes the build output for its platform's runtime, edge functions, and static hosting capabilities.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Not configuring adapter - using defaults may not match your host
|
||||
// app.config.ts
|
||||
export default defineConfig({
|
||||
// No adapter specified
|
||||
// May not work correctly on your deployment platform
|
||||
})
|
||||
|
||||
// Or using wrong adapter for platform
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'node-server', // But deploying to Vercel Edge
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Vercel Deployment
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'vercel',
|
||||
// Vercel-specific options
|
||||
},
|
||||
})
|
||||
|
||||
// vercel.json (optional, for customization)
|
||||
{
|
||||
"framework": null,
|
||||
"buildCommand": "npm run build",
|
||||
"outputDirectory": ".output"
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Cloudflare Pages
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'cloudflare-pages',
|
||||
},
|
||||
})
|
||||
|
||||
// wrangler.toml
|
||||
name = "my-tanstack-app"
|
||||
compatibility_date = "2024-01-01"
|
||||
pages_build_output_dir = ".output/public"
|
||||
|
||||
// For Cloudflare Workers (full control)
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'cloudflare',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Netlify
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'netlify',
|
||||
},
|
||||
})
|
||||
|
||||
// netlify.toml
|
||||
[build]
|
||||
command = "npm run build"
|
||||
publish = ".output/public"
|
||||
|
||||
[functions]
|
||||
directory = ".output/server"
|
||||
```
|
||||
|
||||
## Good Example: Node.js Server
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'node-server',
|
||||
// Optional: customize port
|
||||
},
|
||||
})
|
||||
|
||||
// Dockerfile
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY .output .output
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
|
||||
// Or run directly
|
||||
// node .output/server/index.mjs
|
||||
```
|
||||
|
||||
## Good Example: Static Export (SPA)
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'static',
|
||||
prerender: {
|
||||
routes: ['/'],
|
||||
crawlLinks: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Output: .output/public (static files only)
|
||||
// Host anywhere: GitHub Pages, S3, any static host
|
||||
```
|
||||
|
||||
## Good Example: AWS Lambda
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'aws-lambda',
|
||||
},
|
||||
})
|
||||
|
||||
// Deploy with SST, Serverless Framework, or AWS CDK
|
||||
// serverless.yml example:
|
||||
service: my-tanstack-app
|
||||
provider:
|
||||
name: aws
|
||||
runtime: nodejs20.x
|
||||
functions:
|
||||
app:
|
||||
handler: .output/server/index.handler
|
||||
events:
|
||||
- http: ANY /
|
||||
- http: ANY /{proxy+}
|
||||
```
|
||||
|
||||
## Good Example: Bun Runtime
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'bun',
|
||||
},
|
||||
})
|
||||
|
||||
// Run with: bun .output/server/index.mjs
|
||||
```
|
||||
|
||||
## Adapter Comparison
|
||||
|
||||
| Adapter | Runtime | Edge | Static | Best For |
|
||||
|---------|---------|------|--------|----------|
|
||||
| `vercel` | Node/Edge | Yes | Yes | Vercel hosting |
|
||||
| `cloudflare-pages` | Workers | Yes | Yes | Cloudflare Pages |
|
||||
| `cloudflare` | Workers | Yes | No | Cloudflare Workers |
|
||||
| `netlify` | Node | Yes | Yes | Netlify hosting |
|
||||
| `node-server` | Node | No | No | Docker, VPS, self-host |
|
||||
| `static` | None | No | Yes | Any static host |
|
||||
| `aws-lambda` | Node | No | No | AWS serverless |
|
||||
| `bun` | Bun | No | No | Bun runtime |
|
||||
|
||||
## Context
|
||||
|
||||
- Adapters transform output for target platform
|
||||
- Edge adapters have API limitations (no file system, etc.)
|
||||
- Static preset requires all routes to be prerenderable
|
||||
- Test locally with `npm run build && npm run preview`
|
||||
- Check platform docs for runtime-specific constraints
|
||||
- Some platforms auto-detect TanStack Start (no adapter needed)
|
||||
@@ -0,0 +1,211 @@
|
||||
# env-functions: Use Environment Functions for Configuration
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Environment functions provide type-safe access to environment variables on the server. They ensure secrets stay server-side, provide validation, and enable different configurations per environment (development, staging, production).
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Accessing env vars directly - no validation, potential leaks
|
||||
export const getApiData = createServerFn()
|
||||
.handler(async () => {
|
||||
// No validation - may be undefined
|
||||
const apiKey = process.env.API_KEY
|
||||
|
||||
// Accidentally exposed in error messages
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API_KEY: ${process.env}`)
|
||||
}
|
||||
|
||||
return fetch(url, { headers: { Authorization: apiKey } })
|
||||
})
|
||||
|
||||
// Or importing env in shared files
|
||||
// lib/config.ts
|
||||
export const config = {
|
||||
apiKey: process.env.API_KEY, // Bundled into client!
|
||||
dbUrl: process.env.DATABASE_URL,
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Validated Environment Configuration
|
||||
|
||||
```tsx
|
||||
// lib/env.server.ts
|
||||
import { z } from 'zod'
|
||||
|
||||
const envSchema = z.object({
|
||||
// Required
|
||||
DATABASE_URL: z.string().url(),
|
||||
SESSION_SECRET: z.string().min(32),
|
||||
|
||||
// API Keys
|
||||
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
|
||||
|
||||
// Optional with defaults
|
||||
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
|
||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
|
||||
// Optional
|
||||
SENTRY_DSN: z.string().url().optional(),
|
||||
})
|
||||
|
||||
export type Env = z.infer<typeof envSchema>
|
||||
|
||||
function validateEnv(): Env {
|
||||
const parsed = envSchema.safeParse(process.env)
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error('Invalid environment variables:')
|
||||
console.error(parsed.error.flatten().fieldErrors)
|
||||
throw new Error('Invalid environment configuration')
|
||||
}
|
||||
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
// Validate once at startup
|
||||
export const env = validateEnv()
|
||||
|
||||
// Usage in server functions
|
||||
export const getPaymentIntent = createServerFn({ method: 'POST' })
|
||||
.handler(async () => {
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY)
|
||||
// Type-safe, validated access
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Public vs Private Config
|
||||
|
||||
```tsx
|
||||
// lib/env.server.ts - Server only (secrets)
|
||||
export const serverEnv = {
|
||||
databaseUrl: process.env.DATABASE_URL!,
|
||||
sessionSecret: process.env.SESSION_SECRET!,
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
|
||||
}
|
||||
|
||||
// lib/env.ts - Public config (safe for client)
|
||||
export const publicEnv = {
|
||||
appUrl: process.env.VITE_APP_URL ?? 'http://localhost:3000',
|
||||
stripePublicKey: process.env.VITE_STRIPE_PUBLIC_KEY!,
|
||||
sentryDsn: process.env.VITE_SENTRY_DSN,
|
||||
}
|
||||
|
||||
// Vite exposes VITE_ prefixed vars to client
|
||||
// Non-prefixed vars are server-only
|
||||
```
|
||||
|
||||
## Good Example: Environment-Specific Behavior
|
||||
|
||||
```tsx
|
||||
// lib/env.server.ts
|
||||
export const env = validateEnv()
|
||||
|
||||
export const isDevelopment = env.NODE_ENV === 'development'
|
||||
export const isProduction = env.NODE_ENV === 'production'
|
||||
export const isStaging = env.NODE_ENV === 'staging'
|
||||
|
||||
// lib/logger.server.ts
|
||||
import { env, isDevelopment } from './env.server'
|
||||
|
||||
export function log(level: string, message: string, data?: unknown) {
|
||||
if (isDevelopment) {
|
||||
console.log(`[${level}]`, message, data)
|
||||
return
|
||||
}
|
||||
|
||||
// Production: send to logging service
|
||||
if (env.SENTRY_DSN) {
|
||||
// Send to Sentry
|
||||
}
|
||||
}
|
||||
|
||||
// Server function with environment checks
|
||||
export const debugInfo = createServerFn()
|
||||
.handler(async () => {
|
||||
if (isProduction) {
|
||||
throw new Error('Debug endpoint not available in production')
|
||||
}
|
||||
|
||||
return {
|
||||
nodeVersion: process.version,
|
||||
env: env.NODE_ENV,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Feature Flags via Environment
|
||||
|
||||
```tsx
|
||||
// lib/features.server.ts
|
||||
import { env } from './env.server'
|
||||
|
||||
export const features = {
|
||||
newCheckout: env.FEATURE_NEW_CHECKOUT === 'true',
|
||||
betaDashboard: env.FEATURE_BETA_DASHBOARD === 'true',
|
||||
aiAssistant: env.FEATURE_AI_ASSISTANT === 'true',
|
||||
}
|
||||
|
||||
// Usage in server functions
|
||||
export const getCheckoutUrl = createServerFn()
|
||||
.handler(async () => {
|
||||
if (features.newCheckout) {
|
||||
return '/checkout/v2'
|
||||
}
|
||||
return '/checkout'
|
||||
})
|
||||
|
||||
// Usage in loaders
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async () => {
|
||||
return {
|
||||
showBetaFeatures: features.betaDashboard,
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Type-Safe env.d.ts
|
||||
|
||||
```tsx
|
||||
// env.d.ts - TypeScript declarations for env vars
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
// Required
|
||||
DATABASE_URL: string
|
||||
SESSION_SECRET: string
|
||||
|
||||
// Optional
|
||||
NODE_ENV?: 'development' | 'staging' | 'production'
|
||||
SENTRY_DSN?: string
|
||||
|
||||
// Vite public vars
|
||||
VITE_APP_URL?: string
|
||||
VITE_STRIPE_PUBLIC_KEY: string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variable Checklist
|
||||
|
||||
| Variable | Prefix | Accessible On |
|
||||
|----------|--------|---------------|
|
||||
| `DATABASE_URL` | None | Server only |
|
||||
| `SESSION_SECRET` | None | Server only |
|
||||
| `STRIPE_SECRET_KEY` | None | Server only |
|
||||
| `VITE_APP_URL` | `VITE_` | Server + Client |
|
||||
| `VITE_STRIPE_PUBLIC_KEY` | `VITE_` | Server + Client |
|
||||
|
||||
## Context
|
||||
|
||||
- Never import `.server.ts` files in client code
|
||||
- Use `VITE_` prefix for client-accessible variables
|
||||
- Validate at startup to fail fast on misconfiguration
|
||||
- Use Zod or similar for runtime validation
|
||||
- Keep secrets out of error messages and logs
|
||||
- Consider using `.env.local` for local overrides (gitignored)
|
||||
@@ -0,0 +1,187 @@
|
||||
# err-server-errors: Handle Server Function Errors
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Server function errors cross the network boundary. Handle them gracefully with appropriate error types, status codes, and user-friendly messages. Avoid exposing internal details in production.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Throwing raw errors - exposes internals
|
||||
export const createUser = createServerFn({ method: 'POST' })
|
||||
.validator(createUserSchema)
|
||||
.handler(async ({ data }) => {
|
||||
const user = await db.users.create({ data }) // May throw DB error
|
||||
return user
|
||||
// Prisma error with stack trace sent to client
|
||||
})
|
||||
|
||||
// Generic error handling - no useful info for client
|
||||
export const getPost = createServerFn()
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
return await fetchPost(data.id)
|
||||
} catch (e) {
|
||||
throw new Error('Something went wrong') // Too vague
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Structured Error Handling
|
||||
|
||||
```tsx
|
||||
// lib/errors.ts
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public status: number = 400
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AppError'
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(resource: string) {
|
||||
super(`${resource} not found`, 'NOT_FOUND', 404)
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(message, 'UNAUTHORIZED', 401)
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, public fields?: Record<string, string>) {
|
||||
super(message, 'VALIDATION_ERROR', 400)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Server Function with Error Handling
|
||||
|
||||
```tsx
|
||||
import { createServerFn, notFound } from '@tanstack/react-start'
|
||||
import { setResponseStatus } from '@tanstack/react-start/server'
|
||||
|
||||
export const getPost = createServerFn()
|
||||
.validator(z.object({ id: z.string() }))
|
||||
.handler(async ({ data }) => {
|
||||
const post = await db.posts.findUnique({
|
||||
where: { id: data.id },
|
||||
})
|
||||
|
||||
if (!post) {
|
||||
// Use built-in notFound for 404s
|
||||
throw notFound()
|
||||
}
|
||||
|
||||
return post
|
||||
})
|
||||
|
||||
export const createPost = createServerFn({ method: 'POST' })
|
||||
.validator(createPostSchema)
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
const post = await db.posts.create({ data })
|
||||
return post
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === 'P2002') {
|
||||
// Unique constraint violation
|
||||
setResponseStatus(409)
|
||||
throw new AppError('A post with this title already exists', 'DUPLICATE', 409)
|
||||
}
|
||||
}
|
||||
|
||||
// Log full error server-side
|
||||
console.error('Failed to create post:', error)
|
||||
|
||||
// Return sanitized error to client
|
||||
setResponseStatus(500)
|
||||
throw new AppError('Failed to create post', 'INTERNAL_ERROR', 500)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Client-Side Error Handling
|
||||
|
||||
```tsx
|
||||
function CreatePostForm() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createPost,
|
||||
onError: (error) => {
|
||||
if (error instanceof AppError) {
|
||||
setError(error.message)
|
||||
} else if (error instanceof ValidationError) {
|
||||
// Handle field-specific errors
|
||||
Object.entries(error.fields ?? {}).forEach(([field, message]) => {
|
||||
form.setError(field, { message })
|
||||
})
|
||||
} else {
|
||||
setError('An unexpected error occurred')
|
||||
}
|
||||
},
|
||||
onSuccess: (post) => {
|
||||
navigate({ to: '/posts/$postId', params: { postId: post.id } })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
{/* form fields */}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Using Redirects for Auth Errors
|
||||
|
||||
```tsx
|
||||
export const updateProfile = createServerFn({ method: 'POST' })
|
||||
.validator(updateProfileSchema)
|
||||
.handler(async ({ data }) => {
|
||||
const session = await getSessionData()
|
||||
|
||||
if (!session) {
|
||||
// Redirect to login for auth errors
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
search: { redirect: '/settings' },
|
||||
})
|
||||
}
|
||||
|
||||
return await db.users.update({
|
||||
where: { id: session.userId },
|
||||
data,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Error Response Best Practices
|
||||
|
||||
| Scenario | HTTP Status | Response |
|
||||
|----------|-------------|----------|
|
||||
| Validation failed | 400 | Field-specific errors |
|
||||
| Not authenticated | 401 | Redirect to login |
|
||||
| Not authorized | 403 | Generic forbidden message |
|
||||
| Resource not found | 404 | Use `notFound()` |
|
||||
| Conflict (duplicate) | 409 | Specific conflict message |
|
||||
| Server error | 500 | Generic message, log details |
|
||||
|
||||
## Context
|
||||
|
||||
- Use `notFound()` for 404 errors - integrates with router
|
||||
- Use `redirect()` for auth-related errors
|
||||
- Set status codes with `setResponseStatus()`
|
||||
- Log full errors server-side, sanitize for client
|
||||
- Create custom error classes for consistent handling
|
||||
- Validation errors from `.validator()` are automatic
|
||||
@@ -0,0 +1,152 @@
|
||||
# file-separation: Separate Server and Client Code
|
||||
|
||||
## Priority: LOW
|
||||
|
||||
## Explanation
|
||||
|
||||
Organize code by execution context to prevent server code from accidentally bundling into client builds. Use `.server.ts` for server-only code, `.functions.ts` for server function definitions, and standard `.ts` for shared code.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// lib/posts.ts - Mixed server and client code
|
||||
import { db } from './db' // Database - server only
|
||||
import { formatDate } from './utils' // Utility - shared
|
||||
|
||||
export async function getPosts() {
|
||||
// This uses db, so it's server-only
|
||||
// But file might be imported on client
|
||||
return db.posts.findMany()
|
||||
}
|
||||
|
||||
export function formatPostDate(date: Date) {
|
||||
// This could run anywhere
|
||||
return formatDate(date)
|
||||
}
|
||||
|
||||
// routes/posts.tsx
|
||||
import { getPosts, formatPostDate } from '@/lib/posts'
|
||||
// Importing getPosts pulls db into client bundle (error or bloat)
|
||||
```
|
||||
|
||||
## Good Example: Clear Separation
|
||||
|
||||
```
|
||||
lib/
|
||||
├── posts.ts # Shared types and utilities
|
||||
├── posts.server.ts # Server-only database logic
|
||||
├── posts.functions.ts # Server function definitions
|
||||
└── schemas/
|
||||
└── post.ts # Shared validation schemas
|
||||
```
|
||||
|
||||
```tsx
|
||||
// lib/posts.ts - Shared (safe to import anywhere)
|
||||
export interface Post {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export function formatPostDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
// lib/posts.server.ts - Server only (never import on client)
|
||||
import { db } from './db'
|
||||
import type { Post } from './posts'
|
||||
|
||||
export async function getPostsFromDb(): Promise<Post[]> {
|
||||
return db.posts.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createPostInDb(data: CreatePostInput): Promise<Post> {
|
||||
return db.posts.create({ data })
|
||||
}
|
||||
|
||||
// lib/posts.functions.ts - Server functions (safe to import anywhere)
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { getPostsFromDb, createPostInDb } from './posts.server'
|
||||
import { createPostSchema } from './schemas/post'
|
||||
|
||||
export const getPosts = createServerFn()
|
||||
.handler(async () => {
|
||||
return await getPostsFromDb()
|
||||
})
|
||||
|
||||
export const createPost = createServerFn({ method: 'POST' })
|
||||
.validator(createPostSchema)
|
||||
.handler(async ({ data }) => {
|
||||
return await createPostInDb(data)
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Using in Components
|
||||
|
||||
```tsx
|
||||
// components/PostList.tsx
|
||||
import { getPosts } from '@/lib/posts.functions' // Safe - RPC stub on client
|
||||
import { formatPostDate } from '@/lib/posts' // Safe - shared utility
|
||||
import type { Post } from '@/lib/posts' // Safe - type only
|
||||
|
||||
function PostList() {
|
||||
const postsQuery = useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: () => getPosts(), // Calls server function
|
||||
})
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{postsQuery.data?.map((post) => (
|
||||
<li key={post.id}>
|
||||
{post.title}
|
||||
<span>{formatPostDate(post.createdAt)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## File Convention Summary
|
||||
|
||||
| Suffix | Purpose | Safe to Import on Client |
|
||||
|--------|---------|-------------------------|
|
||||
| `.ts` | Shared utilities, types | Yes |
|
||||
| `.server.ts` | Server-only logic (db, secrets) | No |
|
||||
| `.functions.ts` | Server function wrappers | Yes |
|
||||
| `.client.ts` | Client-only code | Yes (client only) |
|
||||
|
||||
## Good Example: Environment Variables
|
||||
|
||||
```tsx
|
||||
// lib/config.server.ts - Server secrets
|
||||
export const config = {
|
||||
databaseUrl: process.env.DATABASE_URL!,
|
||||
sessionSecret: process.env.SESSION_SECRET!,
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
|
||||
}
|
||||
|
||||
// lib/config.ts - Public config (safe for client)
|
||||
export const publicConfig = {
|
||||
appName: 'My App',
|
||||
apiUrl: process.env.NEXT_PUBLIC_API_URL,
|
||||
stripePublicKey: process.env.NEXT_PUBLIC_STRIPE_KEY,
|
||||
}
|
||||
|
||||
// Never import config.server.ts on client
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- `.server.ts` files should never be directly imported in client code
|
||||
- Server functions in `.functions.ts` are safe - build replaces with RPC
|
||||
- Types from `.server.ts` are safe if using `import type`
|
||||
- TanStack Start's build process validates proper separation
|
||||
- This pattern enables tree-shaking and smaller client bundles
|
||||
- Use consistent naming convention across your team
|
||||
@@ -0,0 +1,166 @@
|
||||
# mw-request-middleware: Use Request Middleware for Cross-Cutting Concerns
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
Request middleware runs before every server request (routes, SSR, server functions). Use it for authentication, logging, rate limiting, and other cross-cutting concerns that apply globally.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Duplicating auth logic in every server function
|
||||
export const getProfile = createServerFn()
|
||||
.handler(async () => {
|
||||
const session = await getSession()
|
||||
if (!session) throw new Error('Unauthorized')
|
||||
// ... rest of handler
|
||||
})
|
||||
|
||||
export const updateProfile = createServerFn({ method: 'POST' })
|
||||
.handler(async ({ data }) => {
|
||||
const session = await getSession()
|
||||
if (!session) throw new Error('Unauthorized')
|
||||
// ... rest of handler
|
||||
})
|
||||
|
||||
export const deleteAccount = createServerFn({ method: 'POST' })
|
||||
.handler(async () => {
|
||||
const session = await getSession()
|
||||
if (!session) throw new Error('Unauthorized')
|
||||
// ... rest of handler
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Authentication Middleware
|
||||
|
||||
```tsx
|
||||
// lib/middleware/auth.ts
|
||||
import { createMiddleware } from '@tanstack/react-start'
|
||||
import { getSession } from './session.server'
|
||||
|
||||
export const authMiddleware = createMiddleware()
|
||||
.server(async ({ next }) => {
|
||||
const session = await getSession()
|
||||
|
||||
// Pass session to downstream handlers via context
|
||||
return next({
|
||||
context: {
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// lib/middleware/requireAuth.ts
|
||||
export const requireAuthMiddleware = createMiddleware()
|
||||
.middleware([authMiddleware]) // Depends on auth middleware
|
||||
.server(async ({ next, context }) => {
|
||||
if (!context.user) {
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: context.user, // Now guaranteed to exist
|
||||
},
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Logging Middleware
|
||||
|
||||
```tsx
|
||||
// lib/middleware/logging.ts
|
||||
export const loggingMiddleware = createMiddleware()
|
||||
.server(async ({ next, request }) => {
|
||||
const start = Date.now()
|
||||
const requestId = crypto.randomUUID()
|
||||
|
||||
console.log(`[${requestId}] ${request.method} ${request.url}`)
|
||||
|
||||
try {
|
||||
const result = await next({
|
||||
context: { requestId },
|
||||
})
|
||||
|
||||
console.log(`[${requestId}] Completed in ${Date.now() - start}ms`)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error(`[${requestId}] Error:`, error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Global Middleware Configuration
|
||||
|
||||
```tsx
|
||||
// app/start.ts
|
||||
import { createStart } from '@tanstack/react-start/server'
|
||||
import { loggingMiddleware } from './middleware/logging'
|
||||
import { authMiddleware } from './middleware/auth'
|
||||
|
||||
export default createStart({
|
||||
// Request middleware runs for all requests
|
||||
requestMiddleware: [
|
||||
loggingMiddleware,
|
||||
authMiddleware,
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Rate Limiting Middleware
|
||||
|
||||
```tsx
|
||||
// lib/middleware/rateLimit.ts
|
||||
import { createMiddleware } from '@tanstack/react-start'
|
||||
|
||||
const rateLimitStore = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
export const rateLimitMiddleware = createMiddleware()
|
||||
.server(async ({ next, request }) => {
|
||||
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
|
||||
const now = Date.now()
|
||||
const windowMs = 60 * 1000 // 1 minute
|
||||
const maxRequests = 100
|
||||
|
||||
let record = rateLimitStore.get(ip)
|
||||
|
||||
if (!record || record.resetAt < now) {
|
||||
record = { count: 0, resetAt: now + windowMs }
|
||||
}
|
||||
|
||||
record.count++
|
||||
rateLimitStore.set(ip, record)
|
||||
|
||||
if (record.count > maxRequests) {
|
||||
throw new Response('Too Many Requests', { status: 429 })
|
||||
}
|
||||
|
||||
return next()
|
||||
})
|
||||
```
|
||||
|
||||
## Middleware Execution Order
|
||||
|
||||
```
|
||||
Request → Middleware 1 → Middleware 2 → Handler → Middleware 2 → Middleware 1 → Response
|
||||
|
||||
// Example with timing:
|
||||
loggingMiddleware.server(async ({ next }) => {
|
||||
console.log('Before handler')
|
||||
const result = await next() // Calls next middleware/handler
|
||||
console.log('After handler')
|
||||
return result
|
||||
})
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Request middleware applies to all server requests
|
||||
- Middleware can add to context using `next({ context: {...} })`
|
||||
- Order matters - first middleware wraps the entire chain
|
||||
- Global middleware defined in `app/start.ts`
|
||||
- Route-specific middleware uses `beforeLoad`
|
||||
- Server function middleware uses separate pattern (see `mw-function-middleware`)
|
||||
@@ -0,0 +1,146 @@
|
||||
# sf-create-server-fn: Use createServerFn for Server-Side Logic
|
||||
|
||||
## Priority: CRITICAL
|
||||
|
||||
## Explanation
|
||||
|
||||
`createServerFn()` creates type-safe server functions that can be called from anywhere - loaders, components, or other server functions. The code inside the handler runs only on the server, with automatic RPC for client calls.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Using fetch directly - no type safety, manual serialization
|
||||
async function createPost(data: CreatePostInput) {
|
||||
const response = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to create post')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Or using API routes - more boilerplate
|
||||
// api/posts.ts
|
||||
export async function POST(request: Request) {
|
||||
const data = await request.json()
|
||||
// No type safety from client
|
||||
const post = await db.posts.create({ data })
|
||||
return new Response(JSON.stringify(post))
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// lib/posts.functions.ts
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
import { db } from './db.server'
|
||||
|
||||
const createPostSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
content: z.string().min(1),
|
||||
published: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export const createPost = createServerFn({ method: 'POST' })
|
||||
.validator(createPostSchema)
|
||||
.handler(async ({ data }) => {
|
||||
// This code only runs on the server
|
||||
const post = await db.posts.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
published: data.published,
|
||||
},
|
||||
})
|
||||
return post
|
||||
})
|
||||
|
||||
// Usage in component
|
||||
function CreatePostForm() {
|
||||
const createPostMutation = useServerFn(createPost)
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
try {
|
||||
const post = await createPostMutation({
|
||||
data: {
|
||||
title: formData.get('title') as string,
|
||||
content: formData.get('content') as string,
|
||||
published: false,
|
||||
},
|
||||
})
|
||||
// post is fully typed
|
||||
console.log('Created post:', post.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to create post:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: GET Function for Data Fetching
|
||||
|
||||
```tsx
|
||||
// lib/posts.functions.ts
|
||||
export const getPosts = createServerFn() // GET is default
|
||||
.handler(async () => {
|
||||
const posts = await db.posts.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
})
|
||||
return posts
|
||||
})
|
||||
|
||||
export const getPost = createServerFn()
|
||||
.validator(z.object({ id: z.string() }))
|
||||
.handler(async ({ data }) => {
|
||||
const post = await db.posts.findUnique({
|
||||
where: { id: data.id },
|
||||
})
|
||||
if (!post) {
|
||||
throw notFound()
|
||||
}
|
||||
return post
|
||||
})
|
||||
|
||||
// Usage in route loader
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
return await getPost({ data: { id: params.postId } })
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: With Context and Dependencies
|
||||
|
||||
```tsx
|
||||
// Compose server functions
|
||||
export const getPostWithComments = createServerFn()
|
||||
.validator(z.object({ postId: z.string() }))
|
||||
.handler(async ({ data }) => {
|
||||
const [post, comments] = await Promise.all([
|
||||
getPost({ data: { id: data.postId } }),
|
||||
getComments({ data: { postId: data.postId } }),
|
||||
])
|
||||
|
||||
return { post, comments }
|
||||
})
|
||||
```
|
||||
|
||||
## Key Benefits
|
||||
|
||||
- **Type safety**: Input/output types flow through client and server
|
||||
- **Automatic serialization**: No manual JSON parsing
|
||||
- **Code splitting**: Server code never reaches client bundle
|
||||
- **Composable**: Call from loaders, components, or other server functions
|
||||
- **Validation**: Built-in input validation with schema libraries
|
||||
|
||||
## Context
|
||||
|
||||
- Default method is GET (idempotent, cacheable)
|
||||
- Use POST for mutations that change data
|
||||
- Server functions are RPC calls under the hood
|
||||
- Validation errors are properly typed and serialized
|
||||
- Import is safe on client - build process replaces with RPC stub
|
||||
@@ -0,0 +1,158 @@
|
||||
# sf-input-validation: Always Validate Server Function Inputs
|
||||
|
||||
## Priority: CRITICAL
|
||||
|
||||
## Explanation
|
||||
|
||||
Server functions receive data across the network boundary. Always validate inputs before processing - never trust client data. Use schema validation libraries like Zod for type-safe validation.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// No validation - trusting client input directly
|
||||
export const updateUser = createServerFn({ method: 'POST' })
|
||||
.handler(async ({ data }) => {
|
||||
// data is unknown/any - no type safety
|
||||
// SQL injection, invalid data, type errors all possible
|
||||
await db.users.update({
|
||||
where: { id: data.id },
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
role: data.role, // Could be set to 'admin' by malicious client!
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Weak validation - type assertion without runtime check
|
||||
export const deletePost = createServerFn({ method: 'POST' })
|
||||
.handler(async ({ data }: { data: { id: string } }) => {
|
||||
// Type assertion doesn't validate at runtime
|
||||
await db.posts.delete({ where: { id: data.id } })
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: With Zod Validation
|
||||
|
||||
```tsx
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
// Don't allow role updates from client input!
|
||||
})
|
||||
|
||||
export const updateUser = createServerFn({ method: 'POST' })
|
||||
.validator(updateUserSchema)
|
||||
.handler(async ({ data }) => {
|
||||
// data is fully typed: { id: string; name: string; email: string }
|
||||
const user = await db.users.update({
|
||||
where: { id: data.id },
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
},
|
||||
})
|
||||
return user
|
||||
})
|
||||
|
||||
// Validation errors are automatically returned to client
|
||||
// with proper status codes and messages
|
||||
```
|
||||
|
||||
## Good Example: Complex Validation
|
||||
|
||||
```tsx
|
||||
const createOrderSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
productId: z.string().uuid(),
|
||||
quantity: z.number().int().min(1).max(100),
|
||||
})).min(1).max(50),
|
||||
shippingAddress: z.object({
|
||||
street: z.string().min(1),
|
||||
city: z.string().min(1),
|
||||
state: z.string().length(2),
|
||||
zip: z.string().regex(/^\d{5}(-\d{4})?$/),
|
||||
}),
|
||||
couponCode: z.string().optional(),
|
||||
})
|
||||
|
||||
export const createOrder = createServerFn({ method: 'POST' })
|
||||
.validator(createOrderSchema)
|
||||
.handler(async ({ data }) => {
|
||||
// All data is validated and typed
|
||||
// Process order safely
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Transform and Refine
|
||||
|
||||
```tsx
|
||||
const registrationSchema = z.object({
|
||||
email: z.string().email().toLowerCase(), // Transform to lowercase
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain uppercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain number'),
|
||||
confirmPassword: z.string(),
|
||||
}).refine(
|
||||
(data) => data.password === data.confirmPassword,
|
||||
{ message: 'Passwords must match', path: ['confirmPassword'] }
|
||||
)
|
||||
|
||||
export const register = createServerFn({ method: 'POST' })
|
||||
.validator(registrationSchema)
|
||||
.handler(async ({ data }) => {
|
||||
// Passwords match, email is lowercase
|
||||
// Only password needed (confirmPassword was for validation)
|
||||
const hashedPassword = await hashPassword(data.password)
|
||||
return await createUser({
|
||||
email: data.email,
|
||||
password: hashedPassword,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Sharing Schemas Between Client and Server
|
||||
|
||||
```tsx
|
||||
// lib/schemas/post.ts - Shared validation schema
|
||||
import { z } from 'zod'
|
||||
|
||||
export const createPostSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
content: z.string().min(1),
|
||||
tags: z.array(z.string()).max(10).optional(),
|
||||
})
|
||||
|
||||
export type CreatePostInput = z.infer<typeof createPostSchema>
|
||||
|
||||
// lib/posts.functions.ts - Server function
|
||||
import { createPostSchema } from './schemas/post'
|
||||
|
||||
export const createPost = createServerFn({ method: 'POST' })
|
||||
.validator(createPostSchema)
|
||||
.handler(async ({ data }) => { /* ... */ })
|
||||
|
||||
// components/CreatePostForm.tsx - Client form validation
|
||||
import { createPostSchema, type CreatePostInput } from '@/lib/schemas/post'
|
||||
|
||||
function CreatePostForm() {
|
||||
const form = useForm<CreatePostInput>({
|
||||
resolver: zodResolver(createPostSchema),
|
||||
})
|
||||
// Same validation client and server side
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Network boundary = trust boundary - always validate
|
||||
- Use `.validator()` before `.handler()` in the chain
|
||||
- Validation errors return proper HTTP status codes
|
||||
- Share schemas between client forms and server functions
|
||||
- Strip or ignore fields clients shouldn't control (like `role`, `isAdmin`)
|
||||
- Consider rate limiting for mutation endpoints
|
||||
@@ -0,0 +1,187 @@
|
||||
# ssr-hydration-safety: Prevent Hydration Mismatches
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Hydration errors occur when server-rendered HTML doesn't match what the client expects. This causes React to discard server HTML and re-render, losing SSR benefits. Ensure consistent rendering between server and client.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Using Date.now() - different on server and client
|
||||
function Timestamp() {
|
||||
return <span>Generated at: {Date.now()}</span>
|
||||
}
|
||||
|
||||
// Using Math.random() - always different
|
||||
function RandomGreeting() {
|
||||
const greetings = ['Hello', 'Hi', 'Hey']
|
||||
return <h1>{greetings[Math.floor(Math.random() * 3)]}</h1>
|
||||
}
|
||||
|
||||
// Checking window - doesn't exist on server
|
||||
function DeviceInfo() {
|
||||
return <span>Width: {window.innerWidth}px</span> // Error on server
|
||||
}
|
||||
|
||||
// Conditional render based on time
|
||||
function TimeBasedContent() {
|
||||
const hour = new Date().getHours()
|
||||
return hour < 12 ? <Morning /> : <Evening />
|
||||
// Server might render Morning, client renders Evening
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Consistent Server/Client Rendering
|
||||
|
||||
```tsx
|
||||
// Pass data from server to avoid mismatch
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async () => {
|
||||
return {
|
||||
generatedAt: Date.now(),
|
||||
}
|
||||
},
|
||||
component: Dashboard,
|
||||
})
|
||||
|
||||
function Dashboard() {
|
||||
const { generatedAt } = Route.useLoaderData()
|
||||
// Both server and client use same value
|
||||
return <span>Generated at: {generatedAt}</span>
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Client-Only Components
|
||||
|
||||
```tsx
|
||||
// Use lazy loading for client-only features
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
const ClientOnlyMap = lazy(() => import('./Map'))
|
||||
|
||||
function LocationPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Our Location</h1>
|
||||
<Suspense fallback={<MapPlaceholder />}>
|
||||
<ClientOnlyMap />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Or use useEffect for client-only state
|
||||
function WindowSize() {
|
||||
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!size) {
|
||||
return <span>Loading dimensions...</span>
|
||||
}
|
||||
|
||||
return <span>{size.width} x {size.height}</span>
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Stable Random Values
|
||||
|
||||
```tsx
|
||||
// Generate random value on server, pass to client
|
||||
export const Route = createFileRoute('/onboarding')({
|
||||
loader: () => ({
|
||||
welcomeVariant: Math.floor(Math.random() * 3),
|
||||
}),
|
||||
component: Onboarding,
|
||||
})
|
||||
|
||||
function Onboarding() {
|
||||
const { welcomeVariant } = Route.useLoaderData()
|
||||
const messages = ['Welcome aboard!', 'Let's get started!', 'Great to have you!']
|
||||
|
||||
return <h1>{messages[welcomeVariant]}</h1> // Same on server and client
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Handling Time Zones
|
||||
|
||||
```tsx
|
||||
// Pass formatted date from server
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await getPost(params.postId)
|
||||
return {
|
||||
...post,
|
||||
// Format on server to avoid timezone mismatch
|
||||
formattedDate: new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'UTC', // Consistent timezone
|
||||
}).format(post.createdAt),
|
||||
}
|
||||
},
|
||||
component: PostPage,
|
||||
})
|
||||
|
||||
// Or use client-only formatting
|
||||
function RelativeTime({ date }: { date: Date }) {
|
||||
const [formatted, setFormatted] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
// Format in user's timezone after hydration
|
||||
setFormatted(formatDistanceToNow(date, { addSuffix: true }))
|
||||
}, [date])
|
||||
|
||||
// Show absolute date initially (same server/client)
|
||||
return <time dateTime={date.toISOString()}>
|
||||
{formatted || date.toISOString().split('T')[0]}
|
||||
</time>
|
||||
}
|
||||
```
|
||||
|
||||
## Common Hydration Mismatch Causes
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| `Date.now()` / `new Date()` | Pass timestamp from loader |
|
||||
| `Math.random()` | Generate on server, pass to client |
|
||||
| `window` / `document` | Use useEffect or lazy loading |
|
||||
| User timezone differences | Use UTC or client-only formatting |
|
||||
| Browser-specific APIs | Check `typeof window !== 'undefined'` |
|
||||
| Extension-injected content | Use `suppressHydrationWarning` |
|
||||
|
||||
## Debugging Hydration Errors
|
||||
|
||||
```tsx
|
||||
// React 18+ provides detailed hydration error messages
|
||||
// Check the console for:
|
||||
// - "Text content does not match"
|
||||
// - "Hydration failed because"
|
||||
// - The specific DOM element causing the issue
|
||||
|
||||
// For difficult cases, use suppressHydrationWarning sparingly
|
||||
function UserContent({ html }: { html: string }) {
|
||||
return (
|
||||
<div
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Hydration compares server HTML with client render
|
||||
- Mismatches force full client re-render (slow, flash)
|
||||
- Use loaders to pass dynamic data consistently
|
||||
- Defer client-only content with useEffect or Suspense
|
||||
- Test SSR by disabling JavaScript and checking render
|
||||
- Development mode shows hydration warnings in console
|
||||
@@ -0,0 +1,199 @@
|
||||
# ssr-prerender: Configure Static Prerendering and ISR
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Static prerendering generates HTML at build time for pages that don't require request-time data. Incremental Static Regeneration (ISR) extends this by revalidating cached pages on a schedule. Use these for better performance and lower server costs.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// SSR for completely static content - wasteful
|
||||
export const Route = createFileRoute('/about')({
|
||||
loader: async () => {
|
||||
// Fetching static content on every request
|
||||
const content = await fetchAboutPageContent()
|
||||
return { content }
|
||||
},
|
||||
})
|
||||
|
||||
// Or no caching headers for semi-static content
|
||||
export const Route = createFileRoute('/blog/$slug')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await fetchPost(params.slug)
|
||||
return { post }
|
||||
// Every request hits the database
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Static Prerendering
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
prerender: {
|
||||
// Routes to prerender at build time
|
||||
routes: [
|
||||
'/',
|
||||
'/about',
|
||||
'/contact',
|
||||
'/pricing',
|
||||
],
|
||||
// Or crawl from root
|
||||
crawlLinks: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// routes/about.tsx - Will be prerendered
|
||||
export const Route = createFileRoute('/about')({
|
||||
loader: async () => {
|
||||
// Runs at BUILD time, not request time
|
||||
const content = await fetchAboutPageContent()
|
||||
return { content }
|
||||
},
|
||||
component: AboutPage,
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Dynamic Prerendering
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
export default defineConfig({
|
||||
server: {
|
||||
prerender: {
|
||||
// Generate routes dynamically
|
||||
routes: async () => {
|
||||
const posts = await db.posts.findMany({
|
||||
where: { published: true },
|
||||
select: { slug: true },
|
||||
})
|
||||
|
||||
return [
|
||||
'/',
|
||||
'/blog',
|
||||
...posts.map(p => `/blog/${p.slug}`),
|
||||
]
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: ISR with Revalidation
|
||||
|
||||
```tsx
|
||||
// routes/blog/$slug.tsx
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { setHeaders } from '@tanstack/react-start/server'
|
||||
|
||||
export const Route = createFileRoute('/blog/$slug')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await fetchPost(params.slug)
|
||||
|
||||
// ISR: Cache for 60 seconds, then revalidate
|
||||
setHeaders({
|
||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
|
||||
})
|
||||
|
||||
return { post }
|
||||
},
|
||||
component: BlogPost,
|
||||
})
|
||||
|
||||
// First request: SSR and cache
|
||||
// Next 60 seconds: Serve cached version
|
||||
// After 60 seconds: Serve stale, revalidate in background
|
||||
// After 300 seconds: Full SSR again
|
||||
```
|
||||
|
||||
## Good Example: Hybrid Static/Dynamic
|
||||
|
||||
```tsx
|
||||
// routes/products.tsx - Prerendered
|
||||
export const Route = createFileRoute('/products')({
|
||||
loader: async () => {
|
||||
// Featured products - prerendered at build
|
||||
const featured = await fetchFeaturedProducts()
|
||||
return { featured }
|
||||
},
|
||||
})
|
||||
|
||||
// routes/products/$productId.tsx - ISR
|
||||
export const Route = createFileRoute('/products/$productId')({
|
||||
loader: async ({ params }) => {
|
||||
const product = await fetchProduct(params.productId)
|
||||
|
||||
if (!product) throw notFound()
|
||||
|
||||
// Cache product pages for 5 minutes
|
||||
setHeaders({
|
||||
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
|
||||
})
|
||||
|
||||
return { product }
|
||||
},
|
||||
})
|
||||
|
||||
// routes/cart.tsx - Always SSR (user-specific)
|
||||
export const Route = createFileRoute('/cart')({
|
||||
loader: async ({ context }) => {
|
||||
// No caching - user-specific data
|
||||
setHeaders({
|
||||
'Cache-Control': 'private, no-store',
|
||||
})
|
||||
|
||||
const cart = await fetchUserCart(context.user.id)
|
||||
return { cart }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: On-Demand Revalidation
|
||||
|
||||
```tsx
|
||||
// API route to trigger revalidation
|
||||
// app/routes/api/revalidate.ts
|
||||
export const APIRoute = createAPIFileRoute('/api/revalidate')({
|
||||
POST: async ({ request }) => {
|
||||
const { secret, path } = await request.json()
|
||||
|
||||
// Verify secret
|
||||
if (secret !== process.env.REVALIDATE_SECRET) {
|
||||
return json({ error: 'Invalid secret' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Trigger revalidation (implementation depends on hosting)
|
||||
await revalidatePath(path)
|
||||
|
||||
return json({ revalidated: true, path })
|
||||
},
|
||||
})
|
||||
|
||||
// Usage: POST /api/revalidate { "secret": "...", "path": "/blog/my-post" }
|
||||
```
|
||||
|
||||
## Cache-Control Directives
|
||||
|
||||
| Directive | Meaning |
|
||||
|-----------|---------|
|
||||
| `s-maxage=N` | CDN cache duration (seconds) |
|
||||
| `max-age=N` | Browser cache duration |
|
||||
| `stale-while-revalidate=N` | Serve stale while fetching fresh |
|
||||
| `private` | Don't cache on CDN (user-specific) |
|
||||
| `no-store` | Never cache |
|
||||
|
||||
## Context
|
||||
|
||||
- Prerendering happens at build time - no request context
|
||||
- ISR requires CDN/edge support (Vercel, Cloudflare, etc.)
|
||||
- Use prerendering for truly static pages (about, pricing)
|
||||
- Use ISR for content that changes but not per-request
|
||||
- Always SSR for user-specific or real-time data
|
||||
- Test with production builds - dev server is always SSR
|
||||
@@ -0,0 +1,201 @@
|
||||
# ssr-streaming: Implement Streaming SSR for Faster TTFB
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Streaming SSR sends HTML chunks to the browser as they're ready, rather than waiting for all data to load. This improves Time to First Byte (TTFB) and perceived performance by showing content progressively.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Blocking SSR - waits for everything
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
// All of these must complete before ANY HTML is sent
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(userQueries.profile()), // 200ms
|
||||
queryClient.ensureQueryData(dashboardQueries.stats()), // 500ms
|
||||
queryClient.ensureQueryData(activityQueries.recent()), // 300ms
|
||||
queryClient.ensureQueryData(notificationQueries.all()), // 400ms
|
||||
])
|
||||
// TTFB: 500ms (slowest query)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Stream Non-Critical Content
|
||||
|
||||
```tsx
|
||||
// routes/dashboard.tsx
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
// Only await critical above-the-fold data
|
||||
await queryClient.ensureQueryData(userQueries.profile())
|
||||
|
||||
// Start fetching but don't await
|
||||
queryClient.prefetchQuery(dashboardQueries.stats())
|
||||
queryClient.prefetchQuery(activityQueries.recent())
|
||||
queryClient.prefetchQuery(notificationQueries.all())
|
||||
|
||||
// HTML starts streaming immediately after profile loads
|
||||
// TTFB: 200ms
|
||||
},
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
// Critical data - ready immediately (from loader)
|
||||
const { data: user } = useSuspenseQuery(userQueries.profile())
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header user={user} />
|
||||
|
||||
{/* Non-critical - streams in with Suspense */}
|
||||
<Suspense fallback={<StatsSkeleton />}>
|
||||
<DashboardStats />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<ActivitySkeleton />}>
|
||||
<RecentActivity />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<NotificationsSkeleton />}>
|
||||
<NotificationsList />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Each section loads independently and streams when ready
|
||||
function DashboardStats() {
|
||||
const { data: stats } = useSuspenseQuery(dashboardQueries.stats())
|
||||
return <StatsDisplay stats={stats} />
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Nested Suspense Boundaries
|
||||
|
||||
```tsx
|
||||
function DashboardPage() {
|
||||
const { data: user } = useSuspenseQuery(userQueries.profile())
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header user={user} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Left column streams together */}
|
||||
<Suspense fallback={<LeftColumnSkeleton />}>
|
||||
<LeftColumn />
|
||||
</Suspense>
|
||||
|
||||
{/* Right column streams independently */}
|
||||
<Suspense fallback={<RightColumnSkeleton />}>
|
||||
<RightColumn />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LeftColumn() {
|
||||
// These load together (same Suspense boundary)
|
||||
const { data: stats } = useSuspenseQuery(dashboardQueries.stats())
|
||||
const { data: chart } = useSuspenseQuery(dashboardQueries.chartData())
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StatsCard stats={stats} />
|
||||
<ChartDisplay data={chart} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Progressive Enhancement
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// Critical: post content (await)
|
||||
await queryClient.ensureQueryData(postQueries.detail(params.postId))
|
||||
|
||||
// Start but don't block: comments, related posts
|
||||
queryClient.prefetchQuery(commentQueries.forPost(params.postId))
|
||||
queryClient.prefetchQuery(postQueries.related(params.postId))
|
||||
},
|
||||
component: PostPage,
|
||||
})
|
||||
|
||||
function PostPage() {
|
||||
const { postId } = Route.useParams()
|
||||
const { data: post } = useSuspenseQuery(postQueries.detail(postId))
|
||||
|
||||
return (
|
||||
<article>
|
||||
{/* Streams immediately */}
|
||||
<PostHeader post={post} />
|
||||
<PostContent content={post.content} />
|
||||
|
||||
{/* Streams when ready */}
|
||||
<Suspense fallback={<CommentsSkeleton />}>
|
||||
<CommentsSection postId={postId} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<RelatedSkeleton />}>
|
||||
<RelatedPosts postId={postId} />
|
||||
</Suspense>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Error Boundaries with Streaming
|
||||
|
||||
```tsx
|
||||
function DashboardPage() {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
|
||||
{/* Each section handles its own errors */}
|
||||
<ErrorBoundary fallback={<StatsError />}>
|
||||
<Suspense fallback={<StatsSkeleton />}>
|
||||
<DashboardStats />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary fallback={<ActivityError />}>
|
||||
<Suspense fallback={<ActivitySkeleton />}>
|
||||
<RecentActivity />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming Timeline
|
||||
|
||||
```
|
||||
Traditional SSR:
|
||||
Request → [Wait for all data...] → Send complete HTML → Render
|
||||
|
||||
Streaming SSR:
|
||||
Request → Send shell HTML → Stream chunk 1 → Stream chunk 2 → Stream chunk 3 → Done
|
||||
↓ ↓ ↓ ↓
|
||||
Browser renders Shows content More content Complete
|
||||
skeleton progressively
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Suspense boundaries define streaming chunks
|
||||
- Place boundaries around slow or non-critical content
|
||||
- Critical path data should still be awaited in loader
|
||||
- Each Suspense boundary can error independently
|
||||
- Works with React 18's streaming SSR
|
||||
- Monitor TTFB to verify streaming is working
|
||||
- Consider network conditions - too many chunks can slow total load
|
||||
391
.agents/skills/interface-design/SKILL.md
Normal file
391
.agents/skills/interface-design/SKILL.md
Normal file
@@ -0,0 +1,391 @@
|
||||
---
|
||||
name: interface-design
|
||||
description: This skill is for interface design — dashboards, admin panels, apps, tools, and interactive products. NOT for marketing design (landing pages, marketing sites, campaigns).
|
||||
---
|
||||
|
||||
# Interface Design
|
||||
|
||||
Build interface design with craft and consistency.
|
||||
|
||||
## Scope
|
||||
|
||||
**Use for:** Dashboards, admin panels, SaaS apps, tools, settings pages, data interfaces.
|
||||
|
||||
**Not for:** Landing pages, marketing sites, campaigns. Redirect those to `/frontend-design`.
|
||||
|
||||
---
|
||||
|
||||
# The Problem
|
||||
|
||||
You will generate generic output. Your training has seen thousands of dashboards. The patterns are strong.
|
||||
|
||||
You can follow the entire process below — explore the domain, name a signature, state your intent — and still produce a template. Warm colors on cold structures. Friendly fonts on generic layouts. "Kitchen feel" that looks like every other app.
|
||||
|
||||
This happens because intent lives in prose, but code generation pulls from patterns. The gap between them is where defaults win.
|
||||
|
||||
The process below helps. But process alone doesn't guarantee craft. You have to catch yourself.
|
||||
|
||||
---
|
||||
|
||||
# Where Defaults Hide
|
||||
|
||||
Defaults don't announce themselves. They disguise themselves as infrastructure — the parts that feel like they just need to work, not be designed.
|
||||
|
||||
**Typography feels like a container.** Pick something readable, move on. But typography isn't holding your design — it IS your design. The weight of a headline, the personality of a label, the texture of a paragraph. These shape how the product feels before anyone reads a word. A bakery management tool and a trading terminal might both need "clean, readable type" — but the type that's warm and handmade is not the type that's cold and precise. If you're reaching for your usual font, you're not designing.
|
||||
|
||||
**Navigation feels like scaffolding.** Build the sidebar, add the links, get to the real work. But navigation isn't around your product — it IS your product. Where you are, where you can go, what matters most. A page floating in space is a component demo, not software. The navigation teaches people how to think about the space they're in.
|
||||
|
||||
**Data feels like presentation.** You have numbers, show numbers. But a number on screen is not design. The question is: what does this number mean to the person looking at it? What will they do with it? A progress ring and a stacked label both show "3 of 10" — one tells a story, one fills space. If you're reaching for number-on-label, you're not designing.
|
||||
|
||||
**Token names feel like implementation detail.** But your CSS variables are design decisions. `--ink` and `--parchment` evoke a world. `--gray-700` and `--surface-2` evoke a template. Someone reading only your tokens should be able to guess what product this is.
|
||||
|
||||
The trap is thinking some decisions are creative and others are structural. There are no structural decisions. Everything is design. The moment you stop asking "why this?" is the moment defaults take over.
|
||||
|
||||
---
|
||||
|
||||
# Intent First
|
||||
|
||||
Before touching code, answer these. Not in your head — out loud, to yourself or the user.
|
||||
|
||||
**Who is this human?**
|
||||
Not "users." The actual person. Where are they when they open this? What's on their mind? What did they do 5 minutes ago, what will they do 5 minutes after? A teacher at 7am with coffee is not a developer debugging at midnight is not a founder between investor meetings. Their world shapes the interface.
|
||||
|
||||
**What must they accomplish?**
|
||||
Not "use the dashboard." The verb. Grade these submissions. Find the broken deployment. Approve the payment. The answer determines what leads, what follows, what hides.
|
||||
|
||||
**What should this feel like?**
|
||||
Say it in words that mean something. "Clean and modern" means nothing — every AI says that. Warm like a notebook? Cold like a terminal? Dense like a trading floor? Calm like a reading app? The answer shapes color, type, spacing, density — everything.
|
||||
|
||||
If you cannot answer these with specifics, stop. Ask the user. Do not guess. Do not default.
|
||||
|
||||
## Every Choice Must Be A Choice
|
||||
|
||||
For every decision, you must be able to explain WHY.
|
||||
|
||||
- Why this layout and not another?
|
||||
- Why this color temperature?
|
||||
- Why this typeface?
|
||||
- Why this spacing scale?
|
||||
- Why this information hierarchy?
|
||||
|
||||
If your answer is "it's common" or "it's clean" or "it works" — you haven't chosen. You've defaulted. Defaults are invisible. Invisible choices compound into generic output.
|
||||
|
||||
**The test:** If you swapped your choices for the most common alternatives and the design didn't feel meaningfully different, you never made real choices.
|
||||
|
||||
## Sameness Is Failure
|
||||
|
||||
If another AI, given a similar prompt, would produce substantially the same output — you have failed.
|
||||
|
||||
This is not about being different for its own sake. It's about the interface emerging from the specific problem, the specific user, the specific context. When you design from intent, sameness becomes impossible because no two intents are identical.
|
||||
|
||||
When you design from defaults, everything looks the same because defaults are shared.
|
||||
|
||||
## Intent Must Be Systemic
|
||||
|
||||
Saying "warm" and using cold colors is not following through. Intent is not a label — it's a constraint that shapes every decision.
|
||||
|
||||
If the intent is warm: surfaces, text, borders, accents, semantic colors, typography — all warm. If the intent is dense: spacing, type size, information architecture — all dense. If the intent is calm: motion, contrast, color saturation — all calm.
|
||||
|
||||
Check your output against your stated intent. Does every token reinforce it? Or did you state an intent and then default anyway?
|
||||
|
||||
---
|
||||
|
||||
# Product Domain Exploration
|
||||
|
||||
This is where defaults get caught — or don't.
|
||||
|
||||
Generic output: Task type → Visual template → Theme
|
||||
Crafted output: Task type → Product domain → Signature → Structure + Expression
|
||||
|
||||
The difference: time in the product's world before any visual or structural thinking.
|
||||
|
||||
## Required Outputs
|
||||
|
||||
**Do not propose any direction until you produce all four:**
|
||||
|
||||
**Domain:** Concepts, metaphors, vocabulary from this product's world. Not features — territory. Minimum 5.
|
||||
|
||||
**Color world:** What colors exist naturally in this product's domain? Not "warm" or "cool" — go to the actual world. If this product were a physical space, what would you see? What colors belong there that don't belong elsewhere? List 5+.
|
||||
|
||||
**Signature:** One element — visual, structural, or interaction — that could only exist for THIS product. If you can't name one, keep exploring.
|
||||
|
||||
**Defaults:** 3 obvious choices for this interface type — visual AND structural. You can't avoid patterns you haven't named.
|
||||
|
||||
## Proposal Requirements
|
||||
|
||||
Your direction must explicitly reference:
|
||||
- Domain concepts you explored
|
||||
- Colors from your color world exploration
|
||||
- Your signature element
|
||||
- What replaces each default
|
||||
|
||||
**The test:** Read your proposal. Remove the product name. Could someone identify what this is for? If not, it's generic. Explore deeper.
|
||||
|
||||
---
|
||||
|
||||
# The Mandate
|
||||
|
||||
**Before showing the user, look at what you made.**
|
||||
|
||||
Ask yourself: "If they said this lacks craft, what would they mean?"
|
||||
|
||||
That thing you just thought of — fix it first.
|
||||
|
||||
Your first output is probably generic. That's normal. The work is catching it before the user has to.
|
||||
|
||||
## The Checks
|
||||
|
||||
Run these against your output before presenting:
|
||||
|
||||
- **The swap test:** If you swapped the typeface for your usual one, would anyone notice? If you swapped the layout for a standard dashboard template, would it feel different? The places where swapping wouldn't matter are the places you defaulted.
|
||||
|
||||
- **The squint test:** Blur your eyes. Can you still perceive hierarchy? Is anything jumping out harshly? Craft whispers.
|
||||
|
||||
- **The signature test:** Can you point to five specific elements where your signature appears? Not "the overall feel" — actual components. A signature you can't locate doesn't exist.
|
||||
|
||||
- **The token test:** Read your CSS variables out loud. Do they sound like they belong to this product's world, or could they belong to any project?
|
||||
|
||||
If any check fails, iterate before showing.
|
||||
|
||||
---
|
||||
|
||||
# Craft Foundations
|
||||
|
||||
## Subtle Layering
|
||||
|
||||
This is the backbone of craft. Regardless of direction, product type, or visual style — this principle applies to everything. You should barely notice the system working. When you look at Vercel's dashboard, you don't think "nice borders." You just understand the structure. The craft is invisible — that's how you know it's working.
|
||||
|
||||
### Surface Elevation
|
||||
|
||||
Surfaces stack. A dropdown sits above a card which sits above the page. Build a numbered system — base, then increasing elevation levels. In dark mode, higher elevation = slightly lighter. In light mode, higher elevation = slightly lighter or uses shadow.
|
||||
|
||||
Each jump should be only a few percentage points of lightness. You can barely see the difference in isolation. But when surfaces stack, the hierarchy emerges. Whisper-quiet shifts that you feel rather than see.
|
||||
|
||||
**Key decisions:**
|
||||
- **Sidebars:** Same background as canvas, not different. Different colors fragment the visual space into "sidebar world" and "content world." A subtle border is enough separation.
|
||||
- **Dropdowns:** One level above their parent surface. If both share the same level, the dropdown blends into the card and layering is lost.
|
||||
- **Inputs:** Slightly darker than their surroundings, not lighter. Inputs are "inset" — they receive content. A darker background signals "type here" without heavy borders.
|
||||
|
||||
### Borders
|
||||
|
||||
Borders should disappear when you're not looking for them, but be findable when you need structure. Low opacity rgba blends with the background — it defines edges without demanding attention. Solid hex borders look harsh in comparison.
|
||||
|
||||
Build a progression — not all borders are equal. Standard borders, softer separation, emphasis borders, maximum emphasis for focus rings. Match intensity to the importance of the boundary.
|
||||
|
||||
**The squint test:** Blur your eyes at the interface. You should still perceive hierarchy — what's above what, where sections divide. But nothing should jump out. No harsh lines. No jarring color shifts. Just quiet structure.
|
||||
|
||||
This separates professional interfaces from amateur ones. Get this wrong and nothing else matters.
|
||||
|
||||
## Infinite Expression
|
||||
|
||||
Every pattern has infinite expressions. **No interface should look the same.**
|
||||
|
||||
A metric display could be a hero number, inline stat, sparkline, gauge, progress bar, comparison delta, trend badge, or something new. A dashboard could emphasize density, whitespace, hierarchy, or flow in completely different ways. Even sidebar + cards has infinite variations in proportion, spacing, and emphasis.
|
||||
|
||||
**Before building, ask:**
|
||||
- What's the ONE thing users do most here?
|
||||
- What products solve similar problems brilliantly? Study them.
|
||||
- Why would this interface feel designed for its purpose, not templated?
|
||||
|
||||
**NEVER produce identical output.** Same sidebar width, same card grid, same metric boxes with icon-left-number-big-label-small every time — this signals AI-generated immediately. It's forgettable.
|
||||
|
||||
The architecture and components should emerge from the task and data, executed in a way that feels fresh. Linear's cards don't look like Notion's. Vercel's metrics don't look like Stripe's. Same concepts, infinite expressions.
|
||||
|
||||
## Color Lives Somewhere
|
||||
|
||||
Every product exists in a world. That world has colors.
|
||||
|
||||
Before you reach for a palette, spend time in the product's world. What would you see if you walked into the physical version of this space? What materials? What light? What objects?
|
||||
|
||||
Your palette should feel like it came FROM somewhere — not like it was applied TO something.
|
||||
|
||||
**Beyond Warm and Cold:** Temperature is one axis. Is this quiet or loud? Dense or spacious? Serious or playful? Geometric or organic? A trading terminal and a meditation app are both "focused" — completely different kinds of focus. Find the specific quality, not the generic label.
|
||||
|
||||
**Color Carries Meaning:** Gray builds structure. Color communicates — status, action, emphasis, identity. Unmotivated color is noise. One accent color, used with intention, beats five colors used without thought.
|
||||
|
||||
---
|
||||
|
||||
# Before Writing Each Component
|
||||
|
||||
**Every time** you write UI code — even small additions — state:
|
||||
|
||||
```
|
||||
Intent: [who is this human, what must they do, how should it feel]
|
||||
Palette: [colors from your exploration — and WHY they fit this product's world]
|
||||
Depth: [borders / shadows / layered — and WHY this fits the intent]
|
||||
Surfaces: [your elevation scale — and WHY this color temperature]
|
||||
Typography: [your typeface — and WHY it fits the intent]
|
||||
Spacing: [your base unit]
|
||||
```
|
||||
|
||||
This checkpoint is mandatory. It forces you to connect every technical choice back to intent.
|
||||
|
||||
If you can't explain WHY for each choice, you're defaulting. Stop and think.
|
||||
|
||||
---
|
||||
|
||||
# Design Principles
|
||||
|
||||
## Token Architecture
|
||||
|
||||
Every color in your interface should trace back to a small set of primitives: foreground (text hierarchy), background (surface elevation), border (separation hierarchy), brand, and semantic (destructive, warning, success). No random hex values — everything maps to primitives.
|
||||
|
||||
### Text Hierarchy
|
||||
|
||||
Don't just have "text" and "gray text." Build four levels — primary, secondary, tertiary, muted. Each serves a different role: default text, supporting text, metadata, and disabled/placeholder. Use all four consistently. If you're only using two, your hierarchy is too flat.
|
||||
|
||||
### Border Progression
|
||||
|
||||
Borders aren't binary. Build a scale that matches intensity to importance — standard separation, softer separation, emphasis, maximum emphasis. Not every boundary deserves the same weight.
|
||||
|
||||
### Control Tokens
|
||||
|
||||
Form controls have specific needs. Don't reuse surface tokens — create dedicated ones for control backgrounds, control borders, and focus states. This lets you tune interactive elements independently from layout surfaces.
|
||||
|
||||
## Spacing
|
||||
|
||||
Pick a base unit and stick to multiples. Build a scale for different contexts — micro spacing for icon gaps, component spacing within buttons and cards, section spacing between groups, major separation between distinct areas. Random values signal no system.
|
||||
|
||||
## Padding
|
||||
|
||||
Keep it symmetrical. If one side has a value, others should match unless content naturally requires asymmetry.
|
||||
|
||||
## Depth
|
||||
|
||||
Choose ONE approach and commit:
|
||||
- **Borders-only** — Clean, technical. For dense tools.
|
||||
- **Subtle shadows** — Soft lift. For approachable products.
|
||||
- **Layered shadows** — Premium, dimensional. For cards that need presence.
|
||||
- **Surface color shifts** — Background tints establish hierarchy without shadows.
|
||||
|
||||
Don't mix approaches.
|
||||
|
||||
## Border Radius
|
||||
|
||||
Sharper feels technical. Rounder feels friendly. Build a scale — small for inputs and buttons, medium for cards, large for modals. Don't mix sharp and soft randomly.
|
||||
|
||||
## Typography
|
||||
|
||||
Build distinct levels distinguishable at a glance. Headlines need weight and tight tracking for presence. Body needs comfortable weight for readability. Labels need medium weight that works at smaller sizes. Data needs monospace with tabular number spacing for alignment. Don't rely on size alone — combine size, weight, and letter-spacing.
|
||||
|
||||
## Card Layouts
|
||||
|
||||
A metric card doesn't have to look like a plan card doesn't have to look like a settings card. Design each card's internal structure for its specific content — but keep the surface treatment consistent: same border weight, shadow depth, corner radius, padding scale.
|
||||
|
||||
## Controls
|
||||
|
||||
Native `<select>` and `<input type="date">` render OS-native elements that cannot be styled. Build custom components — trigger buttons with positioned dropdowns, calendar popovers, styled state management.
|
||||
|
||||
## Iconography
|
||||
|
||||
Icons clarify, not decorate — if removing an icon loses no meaning, remove it. Choose one icon set and stick with it. Give standalone icons presence with subtle background containers.
|
||||
|
||||
## Animation
|
||||
|
||||
Fast micro-interactions, smooth easing. Larger transitions can be slightly longer. Use deceleration easing. Avoid spring/bounce in professional interfaces.
|
||||
|
||||
## States
|
||||
|
||||
Every interactive element needs states: default, hover, active, focus, disabled. Data needs states too: loading, empty, error. Missing states feel broken.
|
||||
|
||||
## Navigation Context
|
||||
|
||||
Screens need grounding. A data table floating in space feels like a component demo, not a product. Include navigation showing where you are in the app, location indicators, and user context. When building sidebars, consider same background as main content with border separation rather than different colors.
|
||||
|
||||
## Dark Mode
|
||||
|
||||
Dark interfaces have different needs. Shadows are less visible on dark backgrounds — lean on borders for definition. Semantic colors (success, warning, error) often need slight desaturation. The hierarchy system still applies, just with inverted values.
|
||||
|
||||
---
|
||||
|
||||
# Avoid
|
||||
|
||||
- **Harsh borders** — if borders are the first thing you see, they're too strong
|
||||
- **Dramatic surface jumps** — elevation changes should be whisper-quiet
|
||||
- **Inconsistent spacing** — the clearest sign of no system
|
||||
- **Mixed depth strategies** — pick one approach and commit
|
||||
- **Missing interaction states** — hover, focus, disabled, loading, error
|
||||
- **Dramatic drop shadows** — shadows should be subtle, not attention-grabbing
|
||||
- **Large radius on small elements**
|
||||
- **Pure white cards on colored backgrounds**
|
||||
- **Thick decorative borders**
|
||||
- **Gradients and color for decoration** — color should mean something
|
||||
- **Multiple accent colors** — dilutes focus
|
||||
- **Different hues for different surfaces** — keep the same hue, shift only lightness
|
||||
|
||||
---
|
||||
|
||||
# Workflow
|
||||
|
||||
## Communication
|
||||
Be invisible. Don't announce modes or narrate process.
|
||||
|
||||
**Never say:** "I'm in ESTABLISH MODE", "Let me check system.md..."
|
||||
|
||||
**Instead:** Jump into work. State suggestions with reasoning.
|
||||
|
||||
## Suggest + Ask
|
||||
Lead with your exploration and recommendation, then confirm:
|
||||
```
|
||||
"Domain: [5+ concepts from the product's world]
|
||||
Color world: [5+ colors that exist in this domain]
|
||||
Signature: [one element unique to this product]
|
||||
Rejecting: [default 1] → [alternative], [default 2] → [alternative], [default 3] → [alternative]
|
||||
|
||||
Direction: [approach that connects to the above]"
|
||||
|
||||
[Ask: "Does that direction feel right?"]
|
||||
```
|
||||
|
||||
## If Project Has system.md
|
||||
Read `.interface-design/system.md` and apply. Decisions are made.
|
||||
|
||||
## If No system.md
|
||||
1. Explore domain — Produce all four required outputs
|
||||
2. Propose — Direction must reference all four
|
||||
3. Confirm — Get user buy-in
|
||||
4. Build — Apply principles
|
||||
5. **Evaluate** — Run the mandate checks before showing
|
||||
6. Offer to save
|
||||
|
||||
---
|
||||
|
||||
# After Completing a Task
|
||||
|
||||
When you finish building something, **always offer to save**:
|
||||
|
||||
```
|
||||
"Want me to save these patterns for future sessions?"
|
||||
```
|
||||
|
||||
If yes, write to `.interface-design/system.md`:
|
||||
- Direction and feel
|
||||
- Depth strategy (borders/shadows/layered)
|
||||
- Spacing base unit
|
||||
- Key component patterns
|
||||
|
||||
### What to Save
|
||||
|
||||
Add patterns when a component is used 2+ times, is reusable across the project, or has specific measurements worth remembering. Don't save one-off components, temporary experiments, or variations better handled with props.
|
||||
|
||||
### Consistency Checks
|
||||
|
||||
If system.md defines values, check against them: spacing on the defined grid, depth using the declared strategy throughout, colors from the defined palette, documented patterns reused instead of reinvented.
|
||||
|
||||
This compounds — each save makes future work faster and more consistent.
|
||||
|
||||
---
|
||||
|
||||
# Deep Dives
|
||||
|
||||
For more detail on specific topics:
|
||||
- `references/principles.md` — Code examples, specific values, dark mode
|
||||
- `references/validation.md` — Memory management, when to update system.md
|
||||
- `references/critique.md` — Post-build craft critique protocol
|
||||
|
||||
# Commands
|
||||
|
||||
- `/interface-design:status` — Current system state
|
||||
- `/interface-design:audit` — Check code against system
|
||||
- `/interface-design:extract` — Extract patterns from code
|
||||
- `/interface-design:critique` — Critique your build for craft, then rebuild what defaulted
|
||||
67
.agents/skills/interface-design/references/critique.md
Normal file
67
.agents/skills/interface-design/references/critique.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Critique
|
||||
|
||||
Your first build shipped the structure. Now look at it the way a design lead reviews a junior's work — not asking "does this work?" but "would I put my name on this?"
|
||||
|
||||
---
|
||||
|
||||
## The Gap
|
||||
|
||||
There's a distance between correct and crafted. Correct means the layout holds, the grid aligns, the colors don't clash. Crafted means someone cared about every decision down to the last pixel. You can feel the difference immediately — the way you tell a hand-thrown mug from an injection-molded one. Both hold coffee. One has presence.
|
||||
|
||||
Your first output lives in correct. This command pulls it toward crafted.
|
||||
|
||||
---
|
||||
|
||||
## See the Composition
|
||||
|
||||
Step back. Look at the whole thing.
|
||||
|
||||
Does the layout have rhythm? Great interfaces breathe unevenly — dense tooling areas give way to open content, heavy elements balance against light ones, the eye travels through the page with purpose. Default layouts are monotone: same card size, same gaps, same density everywhere. Flatness is the sound of no one deciding.
|
||||
|
||||
Are proportions doing work? A 280px sidebar next to full-width content says "navigation serves content." A 360px sidebar says "these are peers." The specific number declares what matters. If you can't articulate what your proportions are saying, they're not saying anything.
|
||||
|
||||
Is there a clear focal point? Every screen has one thing the user came here to do. That thing should dominate — through size, position, contrast, or the space around it. When everything competes equally, nothing wins and the interface feels like a parking lot.
|
||||
|
||||
---
|
||||
|
||||
## See the Craft
|
||||
|
||||
Move close. Pixel-close.
|
||||
|
||||
The spacing grid is non-negotiable — every value a multiple of 4, no exceptions — but correctness alone isn't craft. Craft is knowing that a tool panel at 16px padding feels workbench-tight while the same card at 24px feels like a brochure. The same number can be right in one context and lazy in another. Density is a design decision, not a constant.
|
||||
|
||||
Typography should be legible even squinted. If size is the only thing separating your headline from your body from your label, the hierarchy is too weak. Weight, tracking, and opacity create layers that size alone can't.
|
||||
|
||||
Surfaces should whisper hierarchy. Not thick borders, not dramatic shadows — quiet tonal shifts where you feel the depth without seeing it. Remove every border from your CSS mentally. Can you still perceive the structure through surface color alone? If not, your surfaces aren't working hard enough.
|
||||
|
||||
Interactive elements need life. Every button, link, and clickable region should respond to hover and press. Not dramatically — a subtle shift in background, a gentle darkening. Missing states make an interface feel like a photograph of software instead of software.
|
||||
|
||||
---
|
||||
|
||||
## See the Content
|
||||
|
||||
Read every visible string as a user would. Not checking for typos — checking for truth.
|
||||
|
||||
Does this screen tell one coherent story? Could a real person at a real company be looking at exactly this data right now? Or does the page title belong to one product, the article body to another, and the sidebar metrics to a third?
|
||||
|
||||
Content incoherence breaks the illusion faster than any visual flaw. A beautifully designed interface with nonsensical content is a movie set with no script.
|
||||
|
||||
---
|
||||
|
||||
## See the Structure
|
||||
|
||||
Open the CSS and find the lies — the places that look right but are held together with tape.
|
||||
|
||||
Negative margins undoing a parent's padding. Calc() values that exist only as workarounds. Absolute positioning to escape layout flow. Each is a shortcut where a clean solution exists. Cards with full-width dividers use flex column and section-level padding. Centered content uses max-width with auto margins. The correct answer is always simpler than the hack.
|
||||
|
||||
---
|
||||
|
||||
## Again
|
||||
|
||||
Look at your output one final time.
|
||||
|
||||
Ask: "If they said this lacks craft, what would they point to?"
|
||||
|
||||
That thing you just thought of — fix it. Then ask again.
|
||||
|
||||
The first build was the draft. The critique is the design.
|
||||
86
.agents/skills/interface-design/references/example.md
Normal file
86
.agents/skills/interface-design/references/example.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Craft in Action
|
||||
|
||||
This shows how the subtle layering principle translates to real decisions. Learn the thinking, not the code. Your values will differ — the approach won't.
|
||||
|
||||
---
|
||||
|
||||
## The Subtle Layering Mindset
|
||||
|
||||
Before looking at any example, internalize this: **you should barely notice the system working.**
|
||||
|
||||
When you look at Vercel's dashboard, you don't think "nice borders." You just understand the structure. When you look at Supabase, you don't think "good surface elevation." You just know what's above what. The craft is invisible — that's how you know it's working.
|
||||
|
||||
---
|
||||
|
||||
## Example: Dashboard with Sidebar and Dropdown
|
||||
|
||||
### The Surface Decisions
|
||||
|
||||
**Why so subtle?** Each elevation jump should be only a few percentage points of lightness. You can barely see the difference in isolation. But when surfaces stack, the hierarchy emerges. This is the Vercel/Supabase way — whisper-quiet shifts that you feel rather than see.
|
||||
|
||||
**What NOT to do:** Don't make dramatic jumps between elevations. That's jarring. Don't use different hues for different levels. Keep the same hue, shift only lightness.
|
||||
|
||||
### The Border Decisions
|
||||
|
||||
**Why rgba, not solid colors?** Low opacity borders blend with their background. A low-opacity white border on a dark surface is barely there — it defines the edge without demanding attention. Solid hex borders look harsh in comparison.
|
||||
|
||||
**The test:** Look at your interface from arm's length. If borders are the first thing you notice, reduce opacity. If you can't find where regions end, increase slightly.
|
||||
|
||||
### The Sidebar Decision
|
||||
|
||||
**Why same background as canvas, not different?**
|
||||
|
||||
Many dashboards make the sidebar a different color. This fragments the visual space — now you have "sidebar world" and "content world."
|
||||
|
||||
Better: Same background, subtle border separation. The sidebar is part of the app, not a separate region. Vercel does this. Supabase does this. The border is enough.
|
||||
|
||||
### The Dropdown Decision
|
||||
|
||||
**Why surface-200, not surface-100?**
|
||||
|
||||
The dropdown floats above the card it emerged from. If both were surface-100, the dropdown would blend into the card — you'd lose the sense of layering. Surface-200 is just light enough to feel "above" without being dramatically different.
|
||||
|
||||
**Why border-overlay instead of border-default?**
|
||||
|
||||
Overlays (dropdowns, popovers) often need slightly more definition because they're floating in space. A touch more border opacity helps them feel contained without being harsh.
|
||||
|
||||
---
|
||||
|
||||
## Example: Form Controls
|
||||
|
||||
### Input Background Decision
|
||||
|
||||
**Why darker, not lighter?**
|
||||
|
||||
Inputs are "inset" — they receive content, they don't project it. A slightly darker background signals "type here" without needing heavy borders. This is the alternative-background principle.
|
||||
|
||||
### Focus State Decision
|
||||
|
||||
**Why subtle focus states?**
|
||||
|
||||
Focus needs to be visible, but you don't need a glowing ring or dramatic color. A noticeable increase in border opacity is enough for a clear state change. Subtle-but-noticeable — the same principle as surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Adapt to Context
|
||||
|
||||
Your product might need:
|
||||
- Warmer hues (slight yellow/orange tint)
|
||||
- Cooler hues (blue-gray base)
|
||||
- Different lightness progression
|
||||
- Light mode (principles invert — higher elevation = shadow, not lightness)
|
||||
|
||||
**The principle is constant:** barely different, still distinguishable. The values adapt to context.
|
||||
|
||||
---
|
||||
|
||||
## The Craft Check
|
||||
|
||||
Apply the squint test to your work:
|
||||
|
||||
1. Blur your eyes or step back
|
||||
2. Can you still perceive hierarchy?
|
||||
3. Is anything jumping out at you?
|
||||
4. Can you tell where regions begin and end?
|
||||
|
||||
If hierarchy is visible and nothing is harsh — the subtle layering is working.
|
||||
235
.agents/skills/interface-design/references/principles.md
Normal file
235
.agents/skills/interface-design/references/principles.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Core Craft Principles
|
||||
|
||||
These apply regardless of design direction. This is the quality floor.
|
||||
|
||||
---
|
||||
|
||||
## Surface & Token Architecture
|
||||
|
||||
Professional interfaces don't pick colors randomly — they build systems. Understanding this architecture is the difference between "looks okay" and "feels like a real product."
|
||||
|
||||
### The Primitive Foundation
|
||||
|
||||
Every color in your interface should trace back to a small set of primitives:
|
||||
|
||||
- **Foreground** — text colors (primary, secondary, muted)
|
||||
- **Background** — surface colors (base, elevated, overlay)
|
||||
- **Border** — edge colors (default, subtle, strong)
|
||||
- **Brand** — your primary accent
|
||||
- **Semantic** — functional colors (destructive, warning, success)
|
||||
|
||||
Don't invent new colors. Map everything to these primitives.
|
||||
|
||||
### Surface Elevation Hierarchy
|
||||
|
||||
Surfaces stack. A dropdown sits above a card which sits above the page. Build a numbered system:
|
||||
|
||||
```
|
||||
Level 0: Base background (the app canvas)
|
||||
Level 1: Cards, panels (same visual plane as base)
|
||||
Level 2: Dropdowns, popovers (floating above)
|
||||
Level 3: Nested dropdowns, stacked overlays
|
||||
Level 4: Highest elevation (rare)
|
||||
```
|
||||
|
||||
In dark mode, higher elevation = slightly lighter. In light mode, higher elevation = slightly lighter or uses shadow. The principle: **elevated surfaces need visual distinction from what's beneath them.**
|
||||
|
||||
### The Subtlety Principle
|
||||
|
||||
This is where most interfaces fail. Study Vercel, Supabase, Linear — their surfaces are **barely different** but still distinguishable. Their borders are **light but not invisible**.
|
||||
|
||||
**For surfaces:** The difference between elevation levels should be subtle — a few percentage points of lightness, not dramatic jumps. In dark mode, surface-100 might be 7% lighter than base, surface-200 might be 9%, surface-300 might be 12%. You can barely see it, but you feel it.
|
||||
|
||||
**For borders:** Borders should define regions without demanding attention. Use low opacity (0.05-0.12 alpha for dark mode, slightly higher for light). The border should disappear when you're not looking for it, but be findable when you need to understand the structure.
|
||||
|
||||
**The test:** Squint at your interface. You should still perceive the hierarchy — what's above what, where regions begin and end. But no single border or surface should jump out at you. If borders are the first thing you notice, they're too strong. If you can't find where one region ends and another begins, they're too subtle.
|
||||
|
||||
**Common AI mistakes to avoid:**
|
||||
- Borders that are too visible (1px solid gray instead of subtle rgba)
|
||||
- Surface jumps that are too dramatic (going from dark to light instead of dark to slightly-less-dark)
|
||||
- Using different hues for different surfaces (gray card on blue background)
|
||||
- Harsh dividers where subtle borders would do
|
||||
|
||||
### Text Hierarchy via Tokens
|
||||
|
||||
Don't just have "text" and "gray text." Build four levels:
|
||||
|
||||
- **Primary** — default text, highest contrast
|
||||
- **Secondary** — supporting text, slightly muted
|
||||
- **Tertiary** — metadata, timestamps, less important
|
||||
- **Muted** — disabled, placeholder, lowest contrast
|
||||
|
||||
Use all four consistently. If you're only using two, your hierarchy is too flat.
|
||||
|
||||
### Border Progression
|
||||
|
||||
Borders aren't binary. Build a scale:
|
||||
|
||||
- **Default** — standard borders
|
||||
- **Subtle/Muted** — softer separation
|
||||
- **Strong** — emphasis, hover states
|
||||
- **Stronger** — maximum emphasis, focus rings
|
||||
|
||||
Match border intensity to the importance of the boundary.
|
||||
|
||||
### Dedicated Control Tokens
|
||||
|
||||
Form controls (inputs, checkboxes, selects) have specific needs. Don't just reuse surface tokens — create dedicated ones:
|
||||
|
||||
- **Control background** — often different from surface backgrounds
|
||||
- **Control border** — needs to feel interactive
|
||||
- **Control focus** — clear focus indication
|
||||
|
||||
This separation lets you tune controls independently from layout surfaces.
|
||||
|
||||
### Context-Aware Bases
|
||||
|
||||
Different areas of your app might need different base surfaces:
|
||||
|
||||
- **Marketing pages** — might use darker/richer backgrounds
|
||||
- **Dashboard/app** — might use neutral working backgrounds
|
||||
- **Sidebar** — might differ from main canvas
|
||||
|
||||
The surface hierarchy works the same way — it just starts from a different base.
|
||||
|
||||
### Alternative Backgrounds for Depth
|
||||
|
||||
Beyond shadows, use contrasting backgrounds to create depth. An "alternative" or "inset" background makes content feel recessed. Useful for:
|
||||
|
||||
- Empty states in data grids
|
||||
- Code blocks
|
||||
- Inset panels
|
||||
- Visual grouping without borders
|
||||
|
||||
---
|
||||
|
||||
## Spacing System
|
||||
|
||||
Pick a base unit (4px and 8px are common) and use multiples throughout. The specific number matters less than consistency — every spacing value should be explainable as "X times the base unit."
|
||||
|
||||
Build a scale for different contexts:
|
||||
- Micro spacing (icon gaps, tight element pairs)
|
||||
- Component spacing (within buttons, inputs, cards)
|
||||
- Section spacing (between related groups)
|
||||
- Major separation (between distinct sections)
|
||||
|
||||
## Symmetrical Padding
|
||||
|
||||
TLBR must match. If top padding is 16px, left/bottom/right must also be 16px. Exception: when content naturally creates visual balance.
|
||||
|
||||
```css
|
||||
/* Good */
|
||||
padding: 16px;
|
||||
padding: 12px 16px; /* Only when horizontal needs more room */
|
||||
|
||||
/* Bad */
|
||||
padding: 24px 16px 12px 16px;
|
||||
```
|
||||
|
||||
## Border Radius Consistency
|
||||
|
||||
Sharper corners feel technical, rounder corners feel friendly. Pick a scale that fits your product's personality and use it consistently.
|
||||
|
||||
The key is having a system: small radius for inputs and buttons, medium for cards, large for modals or containers. Don't mix sharp and soft randomly — inconsistent radius is as jarring as inconsistent spacing.
|
||||
|
||||
## Depth & Elevation Strategy
|
||||
|
||||
Match your depth approach to your design direction. Choose ONE and commit:
|
||||
|
||||
**Borders-only (flat)** — Clean, technical, dense. Works for utility-focused tools where information density matters more than visual lift. Linear, Raycast, and many developer tools use almost no shadows — just subtle borders to define regions.
|
||||
|
||||
**Subtle single shadows** — Soft lift without complexity. A simple `0 1px 3px rgba(0,0,0,0.08)` can be enough. Works for approachable products that want gentle depth.
|
||||
|
||||
**Layered shadows** — Rich, premium, dimensional. Multiple shadow layers create realistic depth. Stripe and Mercury use this approach. Best for cards that need to feel like physical objects.
|
||||
|
||||
**Surface color shifts** — Background tints establish hierarchy without any shadows. A card at `#fff` on a `#f8fafc` background already feels elevated.
|
||||
|
||||
```css
|
||||
/* Borders-only approach */
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--border-subtle: rgba(0, 0, 0, 0.05);
|
||||
border: 0.5px solid var(--border);
|
||||
|
||||
/* Single shadow approach */
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* Layered shadow approach */
|
||||
--shadow-layered:
|
||||
0 0 0 0.5px rgba(0, 0, 0, 0.05),
|
||||
0 1px 2px rgba(0, 0, 0, 0.04),
|
||||
0 2px 4px rgba(0, 0, 0, 0.03),
|
||||
0 4px 8px rgba(0, 0, 0, 0.02);
|
||||
```
|
||||
|
||||
## Card Layouts
|
||||
|
||||
Monotonous card layouts are lazy design. A metric card doesn't have to look like a plan card doesn't have to look like a settings card.
|
||||
|
||||
Design each card's internal structure for its specific content — but keep the surface treatment consistent: same border weight, shadow depth, corner radius, padding scale, typography.
|
||||
|
||||
## Isolated Controls
|
||||
|
||||
UI controls deserve container treatment. Date pickers, filters, dropdowns — these should feel like crafted objects.
|
||||
|
||||
**Never use native form elements for styled UI.** Native `<select>`, `<input type="date">`, and similar elements render OS-native dropdowns that cannot be styled. Build custom components instead:
|
||||
|
||||
- Custom select: trigger button + positioned dropdown menu
|
||||
- Custom date picker: input + calendar popover
|
||||
- Custom checkbox/radio: styled div with state management
|
||||
|
||||
Custom select triggers must use `display: inline-flex` with `white-space: nowrap` to keep text and chevron icons on the same row.
|
||||
|
||||
## Typography Hierarchy
|
||||
|
||||
Build distinct levels that are visually distinguishable at a glance:
|
||||
|
||||
- **Headlines** — heavier weight, tighter letter-spacing for presence
|
||||
- **Body** — comfortable weight for readability
|
||||
- **Labels/UI** — medium weight, works at smaller sizes
|
||||
- **Data** — often monospace, needs `tabular-nums` for alignment
|
||||
|
||||
Don't rely on size alone. Combine size, weight, and letter-spacing to create clear hierarchy. If you squint and can't tell headline from body, the hierarchy is too weak.
|
||||
|
||||
## Monospace for Data
|
||||
|
||||
Numbers, IDs, codes, timestamps belong in monospace. Use `tabular-nums` for columnar alignment. Mono signals "this is data."
|
||||
|
||||
## Iconography
|
||||
|
||||
Icons clarify, not decorate — if removing an icon loses no meaning, remove it. Choose a consistent icon set and stick with it throughout the product.
|
||||
|
||||
Give standalone icons presence with subtle background containers. Icons next to text should align optically, not mathematically.
|
||||
|
||||
## Animation
|
||||
|
||||
Keep it fast and functional. Micro-interactions (hover, focus) should feel instant — around 150ms. Larger transitions (modals, panels) can be slightly longer — 200-250ms.
|
||||
|
||||
Use smooth deceleration easing (ease-out variants). Avoid spring/bounce effects in professional interfaces — they feel playful, not serious.
|
||||
|
||||
## Contrast Hierarchy
|
||||
|
||||
Build a four-level system: foreground (primary) → secondary → muted → faint. Use all four consistently.
|
||||
|
||||
## Color Carries Meaning
|
||||
|
||||
Gray builds structure. Color communicates — status, action, emphasis, identity. Unmotivated color is noise. Color that reinforces the product's world is character.
|
||||
|
||||
## Navigation Context
|
||||
|
||||
Screens need grounding. A data table floating in space feels like a component demo, not a product. Consider including:
|
||||
|
||||
- **Navigation** — sidebar or top nav showing where you are in the app
|
||||
- **Location indicator** — breadcrumbs, page title, or active nav state
|
||||
- **User context** — who's logged in, what workspace/org
|
||||
|
||||
When building sidebars, consider using the same background as the main content area. Rely on a subtle border for separation rather than different background colors.
|
||||
|
||||
## Dark Mode
|
||||
|
||||
Dark interfaces have different needs:
|
||||
|
||||
**Borders over shadows** — Shadows are less visible on dark backgrounds. Lean more on borders for definition.
|
||||
|
||||
**Adjust semantic colors** — Status colors (success, warning, error) often need to be slightly desaturated for dark backgrounds.
|
||||
|
||||
**Same structure, different values** — The hierarchy system still applies, just with inverted values.
|
||||
48
.agents/skills/interface-design/references/validation.md
Normal file
48
.agents/skills/interface-design/references/validation.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Memory Management
|
||||
|
||||
When and how to update `.interface-design/system.md`.
|
||||
|
||||
## When to Add Patterns
|
||||
|
||||
Add to system.md when:
|
||||
- Component used 2+ times
|
||||
- Pattern is reusable across the project
|
||||
- Has specific measurements worth remembering
|
||||
|
||||
## Pattern Format
|
||||
|
||||
```markdown
|
||||
### Button Primary
|
||||
- Height: 36px
|
||||
- Padding: 12px 16px
|
||||
- Radius: 6px
|
||||
- Font: 14px, 500 weight
|
||||
```
|
||||
|
||||
## Don't Document
|
||||
|
||||
- One-off components
|
||||
- Temporary experiments
|
||||
- Variations better handled with props
|
||||
|
||||
## Pattern Reuse
|
||||
|
||||
Before creating a component, check system.md:
|
||||
- Pattern exists? Use it.
|
||||
- Need variation? Extend, don't create new.
|
||||
|
||||
Memory compounds: each pattern saved makes future work faster and more consistent.
|
||||
|
||||
---
|
||||
|
||||
# Validation Checks
|
||||
|
||||
If system.md defines specific values, check consistency:
|
||||
|
||||
**Spacing** — All values multiples of the defined base?
|
||||
|
||||
**Depth** — Using the declared strategy throughout? (borders-only means no shadows)
|
||||
|
||||
**Colors** — Using defined palette, not random hex codes?
|
||||
|
||||
**Patterns** — Reusing documented patterns instead of creating new?
|
||||
68
.agents/skills/supabase-postgres-best-practices/AGENTS.md
Normal file
68
.agents/skills/supabase-postgres-best-practices/AGENTS.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Supabase Postgres Best Practices
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
supabase-postgres-best-practices/
|
||||
SKILL.md # Main skill file - read this first
|
||||
AGENTS.md # This navigation guide
|
||||
CLAUDE.md # Symlink to AGENTS.md
|
||||
references/ # Detailed reference files
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. Read `SKILL.md` for the main skill instructions
|
||||
2. Browse `references/` for detailed documentation on specific topics
|
||||
3. Reference files are loaded on-demand - read only what you need
|
||||
|
||||
Comprehensive performance optimization guide for Postgres, maintained by Supabase. Contains rules across 8 categories, prioritized by impact to guide automated query optimization and schema design.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
- Writing SQL queries or designing schemas
|
||||
- Implementing indexes or query optimization
|
||||
- Reviewing database performance issues
|
||||
- Configuring connection pooling or scaling
|
||||
- Optimizing for Postgres-specific features
|
||||
- Working with Row-Level Security (RLS)
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
|----------|----------|--------|--------|
|
||||
| 1 | Query Performance | CRITICAL | `query-` |
|
||||
| 2 | Connection Management | CRITICAL | `conn-` |
|
||||
| 3 | Security & RLS | CRITICAL | `security-` |
|
||||
| 4 | Schema Design | HIGH | `schema-` |
|
||||
| 5 | Concurrency & Locking | MEDIUM-HIGH | `lock-` |
|
||||
| 6 | Data Access Patterns | MEDIUM | `data-` |
|
||||
| 7 | Monitoring & Diagnostics | LOW-MEDIUM | `monitor-` |
|
||||
| 8 | Advanced Features | LOW | `advanced-` |
|
||||
|
||||
## How to Use
|
||||
|
||||
Read individual rule files for detailed explanations and SQL examples:
|
||||
|
||||
```
|
||||
references/query-missing-indexes.md
|
||||
references/schema-partial-indexes.md
|
||||
references/_sections.md
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect SQL example with explanation
|
||||
- Correct SQL example with explanation
|
||||
- Optional EXPLAIN output or metrics
|
||||
- Additional context and references
|
||||
- Supabase-specific notes (when applicable)
|
||||
|
||||
## References
|
||||
|
||||
- https://www.postgresql.org/docs/current/
|
||||
- https://supabase.com/docs
|
||||
- https://wiki.postgresql.org/wiki/Performance_Optimization
|
||||
- https://supabase.com/docs/guides/database/overview
|
||||
- https://supabase.com/docs/guides/auth/row-level-security
|
||||
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
116
.agents/skills/supabase-postgres-best-practices/README.md
Normal file
116
.agents/skills/supabase-postgres-best-practices/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Supabase Postgres Best Practices - Contributor Guide
|
||||
|
||||
This skill contains Postgres performance optimization references optimized for
|
||||
AI agents and LLMs. It follows the [Agent Skills Open Standard](https://agentskills.io/).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# From repository root
|
||||
npm install
|
||||
|
||||
# Validate existing references
|
||||
npm run validate
|
||||
|
||||
# Build AGENTS.md
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Creating a New Reference
|
||||
|
||||
1. **Choose a section prefix** based on the category:
|
||||
- `query-` Query Performance (CRITICAL)
|
||||
- `conn-` Connection Management (CRITICAL)
|
||||
- `security-` Security & RLS (CRITICAL)
|
||||
- `schema-` Schema Design (HIGH)
|
||||
- `lock-` Concurrency & Locking (MEDIUM-HIGH)
|
||||
- `data-` Data Access Patterns (MEDIUM)
|
||||
- `monitor-` Monitoring & Diagnostics (LOW-MEDIUM)
|
||||
- `advanced-` Advanced Features (LOW)
|
||||
|
||||
2. **Copy the template**:
|
||||
```bash
|
||||
cp references/_template.md references/query-your-reference-name.md
|
||||
```
|
||||
|
||||
3. **Fill in the content** following the template structure
|
||||
|
||||
4. **Validate and build**:
|
||||
```bash
|
||||
npm run validate
|
||||
npm run build
|
||||
```
|
||||
|
||||
5. **Review** the generated `AGENTS.md`
|
||||
|
||||
## Skill Structure
|
||||
|
||||
```
|
||||
skills/supabase-postgres-best-practices/
|
||||
├── SKILL.md # Agent-facing skill manifest (Agent Skills spec)
|
||||
├── AGENTS.md # [GENERATED] Compiled references document
|
||||
├── README.md # This file
|
||||
└── references/
|
||||
├── _template.md # Reference template
|
||||
├── _sections.md # Section definitions
|
||||
├── _contributing.md # Writing guidelines
|
||||
└── *.md # Individual references
|
||||
|
||||
packages/skills-build/
|
||||
├── src/ # Generic build system source
|
||||
└── package.json # NPM scripts
|
||||
```
|
||||
|
||||
## Reference File Structure
|
||||
|
||||
See `references/_template.md` for the complete template. Key elements:
|
||||
|
||||
````markdown
|
||||
---
|
||||
title: Clear, Action-Oriented Title
|
||||
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW
|
||||
impactDescription: Quantified benefit (e.g., "10-100x faster")
|
||||
tags: relevant, keywords
|
||||
---
|
||||
|
||||
## [Title]
|
||||
|
||||
[1-2 sentence explanation]
|
||||
|
||||
**Incorrect (description):**
|
||||
|
||||
```sql
|
||||
-- Comment explaining what's wrong
|
||||
[Bad SQL example]
|
||||
```
|
||||
````
|
||||
|
||||
**Correct (description):**
|
||||
|
||||
```sql
|
||||
-- Comment explaining why this is better
|
||||
[Good SQL example]
|
||||
```
|
||||
|
||||
```
|
||||
## Writing Guidelines
|
||||
|
||||
See `references/_contributing.md` for detailed guidelines. Key principles:
|
||||
|
||||
1. **Show concrete transformations** - "Change X to Y", not abstract advice
|
||||
2. **Error-first structure** - Show the problem before the solution
|
||||
3. **Quantify impact** - Include specific metrics (10x faster, 50% smaller)
|
||||
4. **Self-contained examples** - Complete, runnable SQL
|
||||
5. **Semantic naming** - Use meaningful names (users, email), not (table1, col1)
|
||||
|
||||
## Impact Levels
|
||||
|
||||
| Level | Improvement | Examples |
|
||||
|-------|-------------|----------|
|
||||
| CRITICAL | 10-100x | Missing indexes, connection exhaustion |
|
||||
| HIGH | 5-20x | Wrong index types, poor partitioning |
|
||||
| MEDIUM-HIGH | 2-5x | N+1 queries, RLS optimization |
|
||||
| MEDIUM | 1.5-3x | Redundant indexes, stale statistics |
|
||||
| LOW-MEDIUM | 1.2-2x | VACUUM tuning, config tweaks |
|
||||
| LOW | Incremental | Advanced patterns, edge cases |
|
||||
```
|
||||
64
.agents/skills/supabase-postgres-best-practices/SKILL.md
Normal file
64
.agents/skills/supabase-postgres-best-practices/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: supabase-postgres-best-practices
|
||||
description: Postgres performance optimization and best practices from Supabase. Use this skill when writing, reviewing, or optimizing Postgres queries, schema designs, or database configurations.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: supabase
|
||||
version: "1.1.0"
|
||||
organization: Supabase
|
||||
date: January 2026
|
||||
abstract: Comprehensive Postgres performance optimization guide for developers using Supabase and Postgres. Contains performance rules across 8 categories, prioritized by impact from critical (query performance, connection management) to incremental (advanced features). Each rule includes detailed explanations, incorrect vs. correct SQL examples, query plan analysis, and specific performance metrics to guide automated optimization and code generation.
|
||||
---
|
||||
|
||||
# Supabase Postgres Best Practices
|
||||
|
||||
Comprehensive performance optimization guide for Postgres, maintained by Supabase. Contains rules across 8 categories, prioritized by impact to guide automated query optimization and schema design.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
- Writing SQL queries or designing schemas
|
||||
- Implementing indexes or query optimization
|
||||
- Reviewing database performance issues
|
||||
- Configuring connection pooling or scaling
|
||||
- Optimizing for Postgres-specific features
|
||||
- Working with Row-Level Security (RLS)
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
|----------|----------|--------|--------|
|
||||
| 1 | Query Performance | CRITICAL | `query-` |
|
||||
| 2 | Connection Management | CRITICAL | `conn-` |
|
||||
| 3 | Security & RLS | CRITICAL | `security-` |
|
||||
| 4 | Schema Design | HIGH | `schema-` |
|
||||
| 5 | Concurrency & Locking | MEDIUM-HIGH | `lock-` |
|
||||
| 6 | Data Access Patterns | MEDIUM | `data-` |
|
||||
| 7 | Monitoring & Diagnostics | LOW-MEDIUM | `monitor-` |
|
||||
| 8 | Advanced Features | LOW | `advanced-` |
|
||||
|
||||
## How to Use
|
||||
|
||||
Read individual rule files for detailed explanations and SQL examples:
|
||||
|
||||
```
|
||||
references/query-missing-indexes.md
|
||||
references/schema-partial-indexes.md
|
||||
references/_sections.md
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect SQL example with explanation
|
||||
- Correct SQL example with explanation
|
||||
- Optional EXPLAIN output or metrics
|
||||
- Additional context and references
|
||||
- Supabase-specific notes (when applicable)
|
||||
|
||||
## References
|
||||
|
||||
- https://www.postgresql.org/docs/current/
|
||||
- https://supabase.com/docs
|
||||
- https://wiki.postgresql.org/wiki/Performance_Optimization
|
||||
- https://supabase.com/docs/guides/database/overview
|
||||
- https://supabase.com/docs/guides/auth/row-level-security
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Use tsvector for Full-Text Search
|
||||
impact: MEDIUM
|
||||
impactDescription: 100x faster than LIKE, with ranking support
|
||||
tags: full-text-search, tsvector, gin, search
|
||||
---
|
||||
|
||||
## Use tsvector for Full-Text Search
|
||||
|
||||
LIKE with wildcards can't use indexes. Full-text search with tsvector is orders of magnitude faster.
|
||||
|
||||
**Incorrect (LIKE pattern matching):**
|
||||
|
||||
```sql
|
||||
-- Cannot use index, scans all rows
|
||||
select * from articles where content like '%postgresql%';
|
||||
|
||||
-- Case-insensitive makes it worse
|
||||
select * from articles where lower(content) like '%postgresql%';
|
||||
```
|
||||
|
||||
**Correct (full-text search with tsvector):**
|
||||
|
||||
```sql
|
||||
-- Add tsvector column and index
|
||||
alter table articles add column search_vector tsvector
|
||||
generated always as (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,''))) stored;
|
||||
|
||||
create index articles_search_idx on articles using gin (search_vector);
|
||||
|
||||
-- Fast full-text search
|
||||
select * from articles
|
||||
where search_vector @@ to_tsquery('english', 'postgresql & performance');
|
||||
|
||||
-- With ranking
|
||||
select *, ts_rank(search_vector, query) as rank
|
||||
from articles, to_tsquery('english', 'postgresql') query
|
||||
where search_vector @@ query
|
||||
order by rank desc;
|
||||
```
|
||||
|
||||
Search multiple terms:
|
||||
|
||||
```sql
|
||||
-- AND: both terms required
|
||||
to_tsquery('postgresql & performance')
|
||||
|
||||
-- OR: either term
|
||||
to_tsquery('postgresql | mysql')
|
||||
|
||||
-- Prefix matching
|
||||
to_tsquery('post:*')
|
||||
```
|
||||
|
||||
Reference: [Full Text Search](https://supabase.com/docs/guides/database/full-text-search)
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Index JSONB Columns for Efficient Querying
|
||||
impact: MEDIUM
|
||||
impactDescription: 10-100x faster JSONB queries with proper indexing
|
||||
tags: jsonb, gin, indexes, json
|
||||
---
|
||||
|
||||
## Index JSONB Columns for Efficient Querying
|
||||
|
||||
JSONB queries without indexes scan the entire table. Use GIN indexes for containment queries.
|
||||
|
||||
**Incorrect (no index on JSONB):**
|
||||
|
||||
```sql
|
||||
create table products (
|
||||
id bigint primary key,
|
||||
attributes jsonb
|
||||
);
|
||||
|
||||
-- Full table scan for every query
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
select * from products where attributes->>'brand' = 'Nike';
|
||||
```
|
||||
|
||||
**Correct (GIN index for JSONB):**
|
||||
|
||||
```sql
|
||||
-- GIN index for containment operators (@>, ?, ?&, ?|)
|
||||
create index products_attrs_gin on products using gin (attributes);
|
||||
|
||||
-- Now containment queries use the index
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
|
||||
-- For specific key lookups, use expression index
|
||||
create index products_brand_idx on products ((attributes->>'brand'));
|
||||
select * from products where attributes->>'brand' = 'Nike';
|
||||
```
|
||||
|
||||
Choose the right operator class:
|
||||
|
||||
```sql
|
||||
-- jsonb_ops (default): supports all operators, larger index
|
||||
create index idx1 on products using gin (attributes);
|
||||
|
||||
-- jsonb_path_ops: only @> operator, but 2-3x smaller index
|
||||
create index idx2 on products using gin (attributes jsonb_path_ops);
|
||||
```
|
||||
|
||||
Reference: [JSONB Indexes](https://www.postgresql.org/docs/current/datatype-json.html#JSON-INDEXING)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Configure Idle Connection Timeouts
|
||||
impact: HIGH
|
||||
impactDescription: Reclaim 30-50% of connection slots from idle clients
|
||||
tags: connections, timeout, idle, resource-management
|
||||
---
|
||||
|
||||
## Configure Idle Connection Timeouts
|
||||
|
||||
Idle connections waste resources. Configure timeouts to automatically reclaim them.
|
||||
|
||||
**Incorrect (connections held indefinitely):**
|
||||
|
||||
```sql
|
||||
-- No timeout configured
|
||||
show idle_in_transaction_session_timeout; -- 0 (disabled)
|
||||
|
||||
-- Connections stay open forever, even when idle
|
||||
select pid, state, state_change, query
|
||||
from pg_stat_activity
|
||||
where state = 'idle in transaction';
|
||||
-- Shows transactions idle for hours, holding locks
|
||||
```
|
||||
|
||||
**Correct (automatic cleanup of idle connections):**
|
||||
|
||||
```sql
|
||||
-- Terminate connections idle in transaction after 30 seconds
|
||||
alter system set idle_in_transaction_session_timeout = '30s';
|
||||
|
||||
-- Terminate completely idle connections after 10 minutes
|
||||
alter system set idle_session_timeout = '10min';
|
||||
|
||||
-- Reload configuration
|
||||
select pg_reload_conf();
|
||||
```
|
||||
|
||||
For pooled connections, configure at the pooler level:
|
||||
|
||||
```ini
|
||||
# pgbouncer.ini
|
||||
server_idle_timeout = 60
|
||||
client_idle_timeout = 300
|
||||
```
|
||||
|
||||
Reference: [Connection Timeouts](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT)
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Set Appropriate Connection Limits
|
||||
impact: CRITICAL
|
||||
impactDescription: Prevent database crashes and memory exhaustion
|
||||
tags: connections, max-connections, limits, stability
|
||||
---
|
||||
|
||||
## Set Appropriate Connection Limits
|
||||
|
||||
Too many connections exhaust memory and degrade performance. Set limits based on available resources.
|
||||
|
||||
**Incorrect (unlimited or excessive connections):**
|
||||
|
||||
```sql
|
||||
-- Default max_connections = 100, but often increased blindly
|
||||
show max_connections; -- 500 (way too high for 4GB RAM)
|
||||
|
||||
-- Each connection uses 1-3MB RAM
|
||||
-- 500 connections * 2MB = 1GB just for connections!
|
||||
-- Out of memory errors under load
|
||||
```
|
||||
|
||||
**Correct (calculate based on resources):**
|
||||
|
||||
```sql
|
||||
-- Formula: max_connections = (RAM in MB / 5MB per connection) - reserved
|
||||
-- For 4GB RAM: (4096 / 5) - 10 = ~800 theoretical max
|
||||
-- But practically, 100-200 is better for query performance
|
||||
|
||||
-- Recommended settings for 4GB RAM
|
||||
alter system set max_connections = 100;
|
||||
|
||||
-- Also set work_mem appropriately
|
||||
-- work_mem * max_connections should not exceed 25% of RAM
|
||||
alter system set work_mem = '8MB'; -- 8MB * 100 = 800MB max
|
||||
```
|
||||
|
||||
Monitor connection usage:
|
||||
|
||||
```sql
|
||||
select count(*), state from pg_stat_activity group by state;
|
||||
```
|
||||
|
||||
Reference: [Database Connections](https://supabase.com/docs/guides/platform/performance#connection-management)
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Use Connection Pooling for All Applications
|
||||
impact: CRITICAL
|
||||
impactDescription: Handle 10-100x more concurrent users
|
||||
tags: connection-pooling, pgbouncer, performance, scalability
|
||||
---
|
||||
|
||||
## Use Connection Pooling for All Applications
|
||||
|
||||
Postgres connections are expensive (1-3MB RAM each). Without pooling, applications exhaust connections under load.
|
||||
|
||||
**Incorrect (new connection per request):**
|
||||
|
||||
```sql
|
||||
-- Each request creates a new connection
|
||||
-- Application code: db.connect() per request
|
||||
-- Result: 500 concurrent users = 500 connections = crashed database
|
||||
|
||||
-- Check current connections
|
||||
select count(*) from pg_stat_activity; -- 487 connections!
|
||||
```
|
||||
|
||||
**Correct (connection pooling):**
|
||||
|
||||
```sql
|
||||
-- Use a pooler like PgBouncer between app and database
|
||||
-- Application connects to pooler, pooler reuses a small pool to Postgres
|
||||
|
||||
-- Configure pool_size based on: (CPU cores * 2) + spindle_count
|
||||
-- Example for 4 cores: pool_size = 10
|
||||
|
||||
-- Result: 500 concurrent users share 10 actual connections
|
||||
select count(*) from pg_stat_activity; -- 10 connections
|
||||
```
|
||||
|
||||
Pool modes:
|
||||
|
||||
- **Transaction mode**: connection returned after each transaction (best for most apps)
|
||||
- **Session mode**: connection held for entire session (needed for prepared statements, temp tables)
|
||||
|
||||
Reference: [Connection Pooling](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pooler)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Use Prepared Statements Correctly with Pooling
|
||||
impact: HIGH
|
||||
impactDescription: Avoid prepared statement conflicts in pooled environments
|
||||
tags: prepared-statements, connection-pooling, transaction-mode
|
||||
---
|
||||
|
||||
## Use Prepared Statements Correctly with Pooling
|
||||
|
||||
Prepared statements are tied to individual database connections. In transaction-mode pooling, connections are shared, causing conflicts.
|
||||
|
||||
**Incorrect (named prepared statements with transaction pooling):**
|
||||
|
||||
```sql
|
||||
-- Named prepared statement
|
||||
prepare get_user as select * from users where id = $1;
|
||||
|
||||
-- In transaction mode pooling, next request may get different connection
|
||||
execute get_user(123);
|
||||
-- ERROR: prepared statement "get_user" does not exist
|
||||
```
|
||||
|
||||
**Correct (use unnamed statements or session mode):**
|
||||
|
||||
```sql
|
||||
-- Option 1: Use unnamed prepared statements (most ORMs do this automatically)
|
||||
-- The query is prepared and executed in a single protocol message
|
||||
|
||||
-- Option 2: Deallocate after use in transaction mode
|
||||
prepare get_user as select * from users where id = $1;
|
||||
execute get_user(123);
|
||||
deallocate get_user;
|
||||
|
||||
-- Option 3: Use session mode pooling (port 5432 vs 6543)
|
||||
-- Connection is held for entire session, prepared statements persist
|
||||
```
|
||||
|
||||
Check your driver settings:
|
||||
|
||||
```sql
|
||||
-- Many drivers use prepared statements by default
|
||||
-- Node.js pg: { prepare: false } to disable
|
||||
-- JDBC: prepareThreshold=0 to disable
|
||||
```
|
||||
|
||||
Reference: [Prepared Statements with Pooling](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pool-modes)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Batch INSERT Statements for Bulk Data
|
||||
impact: MEDIUM
|
||||
impactDescription: 10-50x faster bulk inserts
|
||||
tags: batch, insert, bulk, performance, copy
|
||||
---
|
||||
|
||||
## Batch INSERT Statements for Bulk Data
|
||||
|
||||
Individual INSERT statements have high overhead. Batch multiple rows in single statements or use COPY.
|
||||
|
||||
**Incorrect (individual inserts):**
|
||||
|
||||
```sql
|
||||
-- Each insert is a separate transaction and round trip
|
||||
insert into events (user_id, action) values (1, 'click');
|
||||
insert into events (user_id, action) values (1, 'view');
|
||||
insert into events (user_id, action) values (2, 'click');
|
||||
-- ... 1000 more individual inserts
|
||||
|
||||
-- 1000 inserts = 1000 round trips = slow
|
||||
```
|
||||
|
||||
**Correct (batch insert):**
|
||||
|
||||
```sql
|
||||
-- Multiple rows in single statement
|
||||
insert into events (user_id, action) values
|
||||
(1, 'click'),
|
||||
(1, 'view'),
|
||||
(2, 'click'),
|
||||
-- ... up to ~1000 rows per batch
|
||||
(999, 'view');
|
||||
|
||||
-- One round trip for 1000 rows
|
||||
```
|
||||
|
||||
For large imports, use COPY:
|
||||
|
||||
```sql
|
||||
-- COPY is fastest for bulk loading
|
||||
copy events (user_id, action, created_at)
|
||||
from '/path/to/data.csv'
|
||||
with (format csv, header true);
|
||||
|
||||
-- Or from stdin in application
|
||||
copy events (user_id, action) from stdin with (format csv);
|
||||
1,click
|
||||
1,view
|
||||
2,click
|
||||
\.
|
||||
```
|
||||
|
||||
Reference: [COPY](https://www.postgresql.org/docs/current/sql-copy.html)
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: Eliminate N+1 Queries with Batch Loading
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 10-100x fewer database round trips
|
||||
tags: n-plus-one, batch, performance, queries
|
||||
---
|
||||
|
||||
## Eliminate N+1 Queries with Batch Loading
|
||||
|
||||
N+1 queries execute one query per item in a loop. Batch them into a single query using arrays or JOINs.
|
||||
|
||||
**Incorrect (N+1 queries):**
|
||||
|
||||
```sql
|
||||
-- First query: get all users
|
||||
select id from users where active = true; -- Returns 100 IDs
|
||||
|
||||
-- Then N queries, one per user
|
||||
select * from orders where user_id = 1;
|
||||
select * from orders where user_id = 2;
|
||||
select * from orders where user_id = 3;
|
||||
-- ... 97 more queries!
|
||||
|
||||
-- Total: 101 round trips to database
|
||||
```
|
||||
|
||||
**Correct (single batch query):**
|
||||
|
||||
```sql
|
||||
-- Collect IDs and query once with ANY
|
||||
select * from orders where user_id = any(array[1, 2, 3, ...]);
|
||||
|
||||
-- Or use JOIN instead of loop
|
||||
select u.id, u.name, o.*
|
||||
from users u
|
||||
left join orders o on o.user_id = u.id
|
||||
where u.active = true;
|
||||
|
||||
-- Total: 1 round trip
|
||||
```
|
||||
|
||||
Application pattern:
|
||||
|
||||
```sql
|
||||
-- Instead of looping in application code:
|
||||
-- for user in users: db.query("SELECT * FROM orders WHERE user_id = $1", user.id)
|
||||
|
||||
-- Pass array parameter:
|
||||
select * from orders where user_id = any($1::bigint[]);
|
||||
-- Application passes: [1, 2, 3, 4, 5, ...]
|
||||
```
|
||||
|
||||
Reference: [N+1 Query Problem](https://supabase.com/docs/guides/database/query-optimization)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Use Cursor-Based Pagination Instead of OFFSET
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Consistent O(1) performance regardless of page depth
|
||||
tags: pagination, cursor, keyset, offset, performance
|
||||
---
|
||||
|
||||
## Use Cursor-Based Pagination Instead of OFFSET
|
||||
|
||||
OFFSET-based pagination scans all skipped rows, getting slower on deeper pages. Cursor pagination is O(1).
|
||||
|
||||
**Incorrect (OFFSET pagination):**
|
||||
|
||||
```sql
|
||||
-- Page 1: scans 20 rows
|
||||
select * from products order by id limit 20 offset 0;
|
||||
|
||||
-- Page 100: scans 2000 rows to skip 1980
|
||||
select * from products order by id limit 20 offset 1980;
|
||||
|
||||
-- Page 10000: scans 200,000 rows!
|
||||
select * from products order by id limit 20 offset 199980;
|
||||
```
|
||||
|
||||
**Correct (cursor/keyset pagination):**
|
||||
|
||||
```sql
|
||||
-- Page 1: get first 20
|
||||
select * from products order by id limit 20;
|
||||
-- Application stores last_id = 20
|
||||
|
||||
-- Page 2: start after last ID
|
||||
select * from products where id > 20 order by id limit 20;
|
||||
-- Uses index, always fast regardless of page depth
|
||||
|
||||
-- Page 10000: same speed as page 1
|
||||
select * from products where id > 199980 order by id limit 20;
|
||||
```
|
||||
|
||||
For multi-column sorting:
|
||||
|
||||
```sql
|
||||
-- Cursor must include all sort columns
|
||||
select * from products
|
||||
where (created_at, id) > ('2024-01-15 10:00:00', 12345)
|
||||
order by created_at, id
|
||||
limit 20;
|
||||
```
|
||||
|
||||
Reference: [Pagination](https://supabase.com/docs/guides/database/pagination)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Use UPSERT for Insert-or-Update Operations
|
||||
impact: MEDIUM
|
||||
impactDescription: Atomic operation, eliminates race conditions
|
||||
tags: upsert, on-conflict, insert, update
|
||||
---
|
||||
|
||||
## Use UPSERT for Insert-or-Update Operations
|
||||
|
||||
Using separate SELECT-then-INSERT/UPDATE creates race conditions. Use INSERT ... ON CONFLICT for atomic upserts.
|
||||
|
||||
**Incorrect (check-then-insert race condition):**
|
||||
|
||||
```sql
|
||||
-- Race condition: two requests check simultaneously
|
||||
select * from settings where user_id = 123 and key = 'theme';
|
||||
-- Both find nothing
|
||||
|
||||
-- Both try to insert
|
||||
insert into settings (user_id, key, value) values (123, 'theme', 'dark');
|
||||
-- One succeeds, one fails with duplicate key error!
|
||||
```
|
||||
|
||||
**Correct (atomic UPSERT):**
|
||||
|
||||
```sql
|
||||
-- Single atomic operation
|
||||
insert into settings (user_id, key, value)
|
||||
values (123, 'theme', 'dark')
|
||||
on conflict (user_id, key)
|
||||
do update set value = excluded.value, updated_at = now();
|
||||
|
||||
-- Returns the inserted/updated row
|
||||
insert into settings (user_id, key, value)
|
||||
values (123, 'theme', 'dark')
|
||||
on conflict (user_id, key)
|
||||
do update set value = excluded.value
|
||||
returning *;
|
||||
```
|
||||
|
||||
Insert-or-ignore pattern:
|
||||
|
||||
```sql
|
||||
-- Insert only if not exists (no update)
|
||||
insert into page_views (page_id, user_id)
|
||||
values (1, 123)
|
||||
on conflict (page_id, user_id) do nothing;
|
||||
```
|
||||
|
||||
Reference: [INSERT ON CONFLICT](https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT)
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Use Advisory Locks for Application-Level Locking
|
||||
impact: MEDIUM
|
||||
impactDescription: Efficient coordination without row-level lock overhead
|
||||
tags: advisory-locks, coordination, application-locks
|
||||
---
|
||||
|
||||
## Use Advisory Locks for Application-Level Locking
|
||||
|
||||
Advisory locks provide application-level coordination without requiring database rows to lock.
|
||||
|
||||
**Incorrect (creating rows just for locking):**
|
||||
|
||||
```sql
|
||||
-- Creating dummy rows to lock on
|
||||
create table resource_locks (
|
||||
resource_name text primary key
|
||||
);
|
||||
|
||||
insert into resource_locks values ('report_generator');
|
||||
|
||||
-- Lock by selecting the row
|
||||
select * from resource_locks where resource_name = 'report_generator' for update;
|
||||
```
|
||||
|
||||
**Correct (advisory locks):**
|
||||
|
||||
```sql
|
||||
-- Session-level advisory lock (released on disconnect or unlock)
|
||||
select pg_advisory_lock(hashtext('report_generator'));
|
||||
-- ... do exclusive work ...
|
||||
select pg_advisory_unlock(hashtext('report_generator'));
|
||||
|
||||
-- Transaction-level lock (released on commit/rollback)
|
||||
begin;
|
||||
select pg_advisory_xact_lock(hashtext('daily_report'));
|
||||
-- ... do work ...
|
||||
commit; -- Lock automatically released
|
||||
```
|
||||
|
||||
Try-lock for non-blocking operations:
|
||||
|
||||
```sql
|
||||
-- Returns immediately with true/false instead of waiting
|
||||
select pg_try_advisory_lock(hashtext('resource_name'));
|
||||
|
||||
-- Use in application
|
||||
if (acquired) {
|
||||
-- Do work
|
||||
select pg_advisory_unlock(hashtext('resource_name'));
|
||||
} else {
|
||||
-- Skip or retry later
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [Advisory Locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS)
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Prevent Deadlocks with Consistent Lock Ordering
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Eliminate deadlock errors, improve reliability
|
||||
tags: deadlocks, locking, transactions, ordering
|
||||
---
|
||||
|
||||
## Prevent Deadlocks with Consistent Lock Ordering
|
||||
|
||||
Deadlocks occur when transactions lock resources in different orders. Always
|
||||
acquire locks in a consistent order.
|
||||
|
||||
**Incorrect (inconsistent lock ordering):**
|
||||
|
||||
```sql
|
||||
-- Transaction A -- Transaction B
|
||||
begin; begin;
|
||||
update accounts update accounts
|
||||
set balance = balance - 100 set balance = balance - 50
|
||||
where id = 1; where id = 2; -- B locks row 2
|
||||
|
||||
update accounts update accounts
|
||||
set balance = balance + 100 set balance = balance + 50
|
||||
where id = 2; -- A waits for B where id = 1; -- B waits for A
|
||||
|
||||
-- DEADLOCK! Both waiting for each other
|
||||
```
|
||||
|
||||
**Correct (lock rows in consistent order first):**
|
||||
|
||||
```sql
|
||||
-- Explicitly acquire locks in ID order before updating
|
||||
begin;
|
||||
select * from accounts where id in (1, 2) order by id for update;
|
||||
|
||||
-- Now perform updates in any order - locks already held
|
||||
update accounts set balance = balance - 100 where id = 1;
|
||||
update accounts set balance = balance + 100 where id = 2;
|
||||
commit;
|
||||
```
|
||||
|
||||
Alternative: use a single statement to update atomically:
|
||||
|
||||
```sql
|
||||
-- Single statement acquires all locks atomically
|
||||
begin;
|
||||
update accounts
|
||||
set balance = balance + case id
|
||||
when 1 then -100
|
||||
when 2 then 100
|
||||
end
|
||||
where id in (1, 2);
|
||||
commit;
|
||||
```
|
||||
|
||||
Detect deadlocks in logs:
|
||||
|
||||
```sql
|
||||
-- Check for recent deadlocks
|
||||
select * from pg_stat_database where deadlocks > 0;
|
||||
|
||||
-- Enable deadlock logging
|
||||
set log_lock_waits = on;
|
||||
set deadlock_timeout = '1s';
|
||||
```
|
||||
|
||||
Reference:
|
||||
[Deadlocks](https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Keep Transactions Short to Reduce Lock Contention
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 3-5x throughput improvement, fewer deadlocks
|
||||
tags: transactions, locking, contention, performance
|
||||
---
|
||||
|
||||
## Keep Transactions Short to Reduce Lock Contention
|
||||
|
||||
Long-running transactions hold locks that block other queries. Keep transactions as short as possible.
|
||||
|
||||
**Incorrect (long transaction with external calls):**
|
||||
|
||||
```sql
|
||||
begin;
|
||||
select * from orders where id = 1 for update; -- Lock acquired
|
||||
|
||||
-- Application makes HTTP call to payment API (2-5 seconds)
|
||||
-- Other queries on this row are blocked!
|
||||
|
||||
update orders set status = 'paid' where id = 1;
|
||||
commit; -- Lock held for entire duration
|
||||
```
|
||||
|
||||
**Correct (minimal transaction scope):**
|
||||
|
||||
```sql
|
||||
-- Validate data and call APIs outside transaction
|
||||
-- Application: response = await paymentAPI.charge(...)
|
||||
|
||||
-- Only hold lock for the actual update
|
||||
begin;
|
||||
update orders
|
||||
set status = 'paid', payment_id = $1
|
||||
where id = $2 and status = 'pending'
|
||||
returning *;
|
||||
commit; -- Lock held for milliseconds
|
||||
```
|
||||
|
||||
Use `statement_timeout` to prevent runaway transactions:
|
||||
|
||||
```sql
|
||||
-- Abort queries running longer than 30 seconds
|
||||
set statement_timeout = '30s';
|
||||
|
||||
-- Or per-session
|
||||
set local statement_timeout = '5s';
|
||||
```
|
||||
|
||||
Reference: [Transaction Management](https://www.postgresql.org/docs/current/tutorial-transactions.html)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Use SKIP LOCKED for Non-Blocking Queue Processing
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 10x throughput for worker queues
|
||||
tags: skip-locked, queue, workers, concurrency
|
||||
---
|
||||
|
||||
## Use SKIP LOCKED for Non-Blocking Queue Processing
|
||||
|
||||
When multiple workers process a queue, SKIP LOCKED allows workers to process different rows without waiting.
|
||||
|
||||
**Incorrect (workers block each other):**
|
||||
|
||||
```sql
|
||||
-- Worker 1 and Worker 2 both try to get next job
|
||||
begin;
|
||||
select * from jobs where status = 'pending' order by created_at limit 1 for update;
|
||||
-- Worker 2 waits for Worker 1's lock to release!
|
||||
```
|
||||
|
||||
**Correct (SKIP LOCKED for parallel processing):**
|
||||
|
||||
```sql
|
||||
-- Each worker skips locked rows and gets the next available
|
||||
begin;
|
||||
select * from jobs
|
||||
where status = 'pending'
|
||||
order by created_at
|
||||
limit 1
|
||||
for update skip locked;
|
||||
|
||||
-- Worker 1 gets job 1, Worker 2 gets job 2 (no waiting)
|
||||
|
||||
update jobs set status = 'processing' where id = $1;
|
||||
commit;
|
||||
```
|
||||
|
||||
Complete queue pattern:
|
||||
|
||||
```sql
|
||||
-- Atomic claim-and-update in one statement
|
||||
update jobs
|
||||
set status = 'processing', worker_id = $1, started_at = now()
|
||||
where id = (
|
||||
select id from jobs
|
||||
where status = 'pending'
|
||||
order by created_at
|
||||
limit 1
|
||||
for update skip locked
|
||||
)
|
||||
returning *;
|
||||
```
|
||||
|
||||
Reference: [SELECT FOR UPDATE SKIP LOCKED](https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE)
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Use EXPLAIN ANALYZE to Diagnose Slow Queries
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Identify exact bottlenecks in query execution
|
||||
tags: explain, analyze, diagnostics, query-plan
|
||||
---
|
||||
|
||||
## Use EXPLAIN ANALYZE to Diagnose Slow Queries
|
||||
|
||||
EXPLAIN ANALYZE executes the query and shows actual timings, revealing the true performance bottlenecks.
|
||||
|
||||
**Incorrect (guessing at performance issues):**
|
||||
|
||||
```sql
|
||||
-- Query is slow, but why?
|
||||
select * from orders where customer_id = 123 and status = 'pending';
|
||||
-- "It must be missing an index" - but which one?
|
||||
```
|
||||
|
||||
**Correct (use EXPLAIN ANALYZE):**
|
||||
|
||||
```sql
|
||||
explain (analyze, buffers, format text)
|
||||
select * from orders where customer_id = 123 and status = 'pending';
|
||||
|
||||
-- Output reveals the issue:
|
||||
-- Seq Scan on orders (cost=0.00..25000.00 rows=50 width=100) (actual time=0.015..450.123 rows=50 loops=1)
|
||||
-- Filter: ((customer_id = 123) AND (status = 'pending'::text))
|
||||
-- Rows Removed by Filter: 999950
|
||||
-- Buffers: shared hit=5000 read=15000
|
||||
-- Planning Time: 0.150 ms
|
||||
-- Execution Time: 450.500 ms
|
||||
```
|
||||
|
||||
Key things to look for:
|
||||
|
||||
```sql
|
||||
-- Seq Scan on large tables = missing index
|
||||
-- Rows Removed by Filter = poor selectivity or missing index
|
||||
-- Buffers: read >> hit = data not cached, needs more memory
|
||||
-- Nested Loop with high loops = consider different join strategy
|
||||
-- Sort Method: external merge = work_mem too low
|
||||
```
|
||||
|
||||
Reference: [EXPLAIN](https://supabase.com/docs/guides/database/inspect)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Enable pg_stat_statements for Query Analysis
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Identify top resource-consuming queries
|
||||
tags: pg-stat-statements, monitoring, statistics, performance
|
||||
---
|
||||
|
||||
## Enable pg_stat_statements for Query Analysis
|
||||
|
||||
pg_stat_statements tracks execution statistics for all queries, helping identify slow and frequent queries.
|
||||
|
||||
**Incorrect (no visibility into query patterns):**
|
||||
|
||||
```sql
|
||||
-- Database is slow, but which queries are the problem?
|
||||
-- No way to know without pg_stat_statements
|
||||
```
|
||||
|
||||
**Correct (enable and query pg_stat_statements):**
|
||||
|
||||
```sql
|
||||
-- Enable the extension
|
||||
create extension if not exists pg_stat_statements;
|
||||
|
||||
-- Find slowest queries by total time
|
||||
select
|
||||
calls,
|
||||
round(total_exec_time::numeric, 2) as total_time_ms,
|
||||
round(mean_exec_time::numeric, 2) as mean_time_ms,
|
||||
query
|
||||
from pg_stat_statements
|
||||
order by total_exec_time desc
|
||||
limit 10;
|
||||
|
||||
-- Find most frequent queries
|
||||
select calls, query
|
||||
from pg_stat_statements
|
||||
order by calls desc
|
||||
limit 10;
|
||||
|
||||
-- Reset statistics after optimization
|
||||
select pg_stat_statements_reset();
|
||||
```
|
||||
|
||||
Key metrics to monitor:
|
||||
|
||||
```sql
|
||||
-- Queries with high mean time (candidates for optimization)
|
||||
select query, mean_exec_time, calls
|
||||
from pg_stat_statements
|
||||
where mean_exec_time > 100 -- > 100ms average
|
||||
order by mean_exec_time desc;
|
||||
```
|
||||
|
||||
Reference: [pg_stat_statements](https://supabase.com/docs/guides/database/extensions/pg_stat_statements)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Maintain Table Statistics with VACUUM and ANALYZE
|
||||
impact: MEDIUM
|
||||
impactDescription: 2-10x better query plans with accurate statistics
|
||||
tags: vacuum, analyze, statistics, maintenance, autovacuum
|
||||
---
|
||||
|
||||
## Maintain Table Statistics with VACUUM and ANALYZE
|
||||
|
||||
Outdated statistics cause the query planner to make poor decisions. VACUUM reclaims space, ANALYZE updates statistics.
|
||||
|
||||
**Incorrect (stale statistics):**
|
||||
|
||||
```sql
|
||||
-- Table has 1M rows but stats say 1000
|
||||
-- Query planner chooses wrong strategy
|
||||
explain select * from orders where status = 'pending';
|
||||
-- Shows: Seq Scan (because stats show small table)
|
||||
-- Actually: Index Scan would be much faster
|
||||
```
|
||||
|
||||
**Correct (maintain fresh statistics):**
|
||||
|
||||
```sql
|
||||
-- Manually analyze after large data changes
|
||||
analyze orders;
|
||||
|
||||
-- Analyze specific columns used in WHERE clauses
|
||||
analyze orders (status, created_at);
|
||||
|
||||
-- Check when tables were last analyzed
|
||||
select
|
||||
relname,
|
||||
last_vacuum,
|
||||
last_autovacuum,
|
||||
last_analyze,
|
||||
last_autoanalyze
|
||||
from pg_stat_user_tables
|
||||
order by last_analyze nulls first;
|
||||
```
|
||||
|
||||
Autovacuum tuning for busy tables:
|
||||
|
||||
```sql
|
||||
-- Increase frequency for high-churn tables
|
||||
alter table orders set (
|
||||
autovacuum_vacuum_scale_factor = 0.05, -- Vacuum at 5% dead tuples (default 20%)
|
||||
autovacuum_analyze_scale_factor = 0.02 -- Analyze at 2% changes (default 10%)
|
||||
);
|
||||
|
||||
-- Check autovacuum status
|
||||
select * from pg_stat_progress_vacuum;
|
||||
```
|
||||
|
||||
Reference: [VACUUM](https://supabase.com/docs/guides/database/database-size#vacuum-operations)
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Create Composite Indexes for Multi-Column Queries
|
||||
impact: HIGH
|
||||
impactDescription: 5-10x faster multi-column queries
|
||||
tags: indexes, composite-index, multi-column, query-optimization
|
||||
---
|
||||
|
||||
## Create Composite Indexes for Multi-Column Queries
|
||||
|
||||
When queries filter on multiple columns, a composite index is more efficient than separate single-column indexes.
|
||||
|
||||
**Incorrect (separate indexes require bitmap scan):**
|
||||
|
||||
```sql
|
||||
-- Two separate indexes
|
||||
create index orders_status_idx on orders (status);
|
||||
create index orders_created_idx on orders (created_at);
|
||||
|
||||
-- Query must combine both indexes (slower)
|
||||
select * from orders where status = 'pending' and created_at > '2024-01-01';
|
||||
```
|
||||
|
||||
**Correct (composite index):**
|
||||
|
||||
```sql
|
||||
-- Single composite index (leftmost column first for equality checks)
|
||||
create index orders_status_created_idx on orders (status, created_at);
|
||||
|
||||
-- Query uses one efficient index scan
|
||||
select * from orders where status = 'pending' and created_at > '2024-01-01';
|
||||
```
|
||||
|
||||
**Column order matters** - place equality columns first, range columns last:
|
||||
|
||||
```sql
|
||||
-- Good: status (=) before created_at (>)
|
||||
create index idx on orders (status, created_at);
|
||||
|
||||
-- Works for: WHERE status = 'pending'
|
||||
-- Works for: WHERE status = 'pending' AND created_at > '2024-01-01'
|
||||
-- Does NOT work for: WHERE created_at > '2024-01-01' (leftmost prefix rule)
|
||||
```
|
||||
|
||||
Reference: [Multicolumn Indexes](https://www.postgresql.org/docs/current/indexes-multicolumn.html)
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: Use Covering Indexes to Avoid Table Lookups
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 2-5x faster queries by eliminating heap fetches
|
||||
tags: indexes, covering-index, include, index-only-scan
|
||||
---
|
||||
|
||||
## Use Covering Indexes to Avoid Table Lookups
|
||||
|
||||
Covering indexes include all columns needed by a query, enabling index-only scans that skip the table entirely.
|
||||
|
||||
**Incorrect (index scan + heap fetch):**
|
||||
|
||||
```sql
|
||||
create index users_email_idx on users (email);
|
||||
|
||||
-- Must fetch name and created_at from table heap
|
||||
select email, name, created_at from users where email = 'user@example.com';
|
||||
```
|
||||
|
||||
**Correct (index-only scan with INCLUDE):**
|
||||
|
||||
```sql
|
||||
-- Include non-searchable columns in the index
|
||||
create index users_email_idx on users (email) include (name, created_at);
|
||||
|
||||
-- All columns served from index, no table access needed
|
||||
select email, name, created_at from users where email = 'user@example.com';
|
||||
```
|
||||
|
||||
Use INCLUDE for columns you SELECT but don't filter on:
|
||||
|
||||
```sql
|
||||
-- Searching by status, but also need customer_id and total
|
||||
create index orders_status_idx on orders (status) include (customer_id, total);
|
||||
|
||||
select status, customer_id, total from orders where status = 'shipped';
|
||||
```
|
||||
|
||||
Reference: [Index-Only Scans](https://www.postgresql.org/docs/current/indexes-index-only-scans.html)
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Choose the Right Index Type for Your Data
|
||||
impact: HIGH
|
||||
impactDescription: 10-100x improvement with correct index type
|
||||
tags: indexes, btree, gin, gist, brin, hash, index-types
|
||||
---
|
||||
|
||||
## Choose the Right Index Type for Your Data
|
||||
|
||||
Different index types excel at different query patterns. The default B-tree isn't always optimal.
|
||||
|
||||
**Incorrect (B-tree for JSONB containment):**
|
||||
|
||||
```sql
|
||||
-- B-tree cannot optimize containment operators
|
||||
create index products_attrs_idx on products (attributes);
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
-- Full table scan - B-tree doesn't support @> operator
|
||||
```
|
||||
|
||||
**Correct (GIN for JSONB):**
|
||||
|
||||
```sql
|
||||
-- GIN supports @>, ?, ?&, ?| operators
|
||||
create index products_attrs_idx on products using gin (attributes);
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
```
|
||||
|
||||
Index type guide:
|
||||
|
||||
```sql
|
||||
-- B-tree (default): =, <, >, BETWEEN, IN, IS NULL
|
||||
create index users_created_idx on users (created_at);
|
||||
|
||||
-- GIN: arrays, JSONB, full-text search
|
||||
create index posts_tags_idx on posts using gin (tags);
|
||||
|
||||
-- GiST: geometric data, range types, nearest-neighbor (KNN) queries
|
||||
create index locations_idx on places using gist (location);
|
||||
|
||||
-- BRIN: large time-series tables (10-100x smaller)
|
||||
create index events_time_idx on events using brin (created_at);
|
||||
|
||||
-- Hash: equality-only (slightly faster than B-tree for =)
|
||||
create index sessions_token_idx on sessions using hash (token);
|
||||
```
|
||||
|
||||
Reference: [Index Types](https://www.postgresql.org/docs/current/indexes-types.html)
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: Add Indexes on WHERE and JOIN Columns
|
||||
impact: CRITICAL
|
||||
impactDescription: 100-1000x faster queries on large tables
|
||||
tags: indexes, performance, sequential-scan, query-optimization
|
||||
---
|
||||
|
||||
## Add Indexes on WHERE and JOIN Columns
|
||||
|
||||
Queries filtering or joining on unindexed columns cause full table scans, which become exponentially slower as tables grow.
|
||||
|
||||
**Incorrect (sequential scan on large table):**
|
||||
|
||||
```sql
|
||||
-- No index on customer_id causes full table scan
|
||||
select * from orders where customer_id = 123;
|
||||
|
||||
-- EXPLAIN shows: Seq Scan on orders (cost=0.00..25000.00 rows=100 width=85)
|
||||
```
|
||||
|
||||
**Correct (index scan):**
|
||||
|
||||
```sql
|
||||
-- Create index on frequently filtered column
|
||||
create index orders_customer_id_idx on orders (customer_id);
|
||||
|
||||
select * from orders where customer_id = 123;
|
||||
|
||||
-- EXPLAIN shows: Index Scan using orders_customer_id_idx (cost=0.42..8.44 rows=100 width=85)
|
||||
```
|
||||
|
||||
For JOIN columns, always index the foreign key side:
|
||||
|
||||
```sql
|
||||
-- Index the referencing column
|
||||
create index orders_customer_id_idx on orders (customer_id);
|
||||
|
||||
select c.name, o.total
|
||||
from customers c
|
||||
join orders o on o.customer_id = c.id;
|
||||
```
|
||||
|
||||
Reference: [Query Optimization](https://supabase.com/docs/guides/database/query-optimization)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user