chore: track repo agent skills

This commit is contained in:
Patrick Erichsen
2026-04-27 14:10:42 -07:00
parent f406c5bf16
commit c5a6d2700f
176 changed files with 31206 additions and 8 deletions
@@ -0,0 +1,288 @@
---
name: convex-create-component
description: Builds reusable Convex components with isolated tables and app-facing APIs. Use for new components, reusable backend modules, integrations, or component boundary work.
---
# Convex Create Component
Create reusable Convex components with clear boundaries and a small app-facing API.
## When to Use
- Creating a new Convex component in an existing app
- Extracting reusable backend logic into a component
- Building a third-party integration that should own its own tables and workflows
- Packaging Convex functionality for reuse across multiple apps
## When Not to Use
- One-off business logic that belongs in the main app
- Thin utilities that do not need Convex tables or functions
- App-level orchestration that should stay in `convex/`
- Cases where a normal TypeScript library is enough
## Workflow
1. Ask the user what they are building and what the end goal is. If the repo already makes the answer obvious, say so and confirm before proceeding.
2. Choose the shape using the decision tree below and read the matching reference file.
3. Decide whether a component is justified. Prefer normal app code or a regular library if the feature does not need isolated tables, backend functions, or reusable persistent state.
4. Make a short plan for:
- what tables the component owns
- what public functions it exposes
- what data must be passed in from the app (auth, env vars, parent IDs)
- what stays in the app as wrappers or HTTP mounts
5. Create the component structure with `convex.config.ts`, `schema.ts`, and function files.
6. Implement functions using the component's own `./_generated/server` imports, not the app's generated files.
7. Wire the component into the app with `app.use(...)`. If the app does not already have `convex/convex.config.ts`, create it.
8. Call the component from the app through `components.<name>` using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction`.
9. If React clients, HTTP callers, or public APIs need access, create wrapper functions in the app instead of exposing component functions directly.
10. Run `npx convex dev` and fix codegen, type, or boundary issues before finishing.
## Choose the Shape
Ask the user, then pick one path:
| Goal | Shape | Reference |
| ------------------------------------------------- | ---------------- | ----------------------------------- |
| Component for this app only | Local | `references/local-components.md` |
| Publish or share across apps | Packaged | `references/packaged-components.md` |
| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` |
| Not sure | Default to local | `references/local-components.md` |
Read exactly one reference file before proceeding.
## Default Approach
Unless the user explicitly wants an npm package, default to a local component:
- Put it under `convex/components/<componentName>/`
- Define it with `defineComponent(...)` in its own `convex.config.ts`
- Install it from the app's `convex/convex.config.ts` with `app.use(...)`
- Let `npx convex dev` generate the component's own `_generated/` files
## Component Skeleton
A minimal local component with a table and two functions, plus the app wiring.
```ts
// convex/components/notifications/convex.config.ts
import { defineComponent } from "convex/server";
export default defineComponent("notifications");
```
```ts
// convex/components/notifications/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
notifications: defineTable({
userId: v.string(),
message: v.string(),
read: v.boolean(),
}).index("by_user", ["userId"]),
});
```
```ts
// convex/components/notifications/lib.ts
import { v } from "convex/values";
import { mutation, query } from "./_generated/server.js";
export const send = mutation({
args: { userId: v.string(), message: v.string() },
returns: v.id("notifications"),
handler: async (ctx, args) => {
return await ctx.db.insert("notifications", {
userId: args.userId,
message: args.message,
read: false,
});
},
});
export const listUnread = query({
args: { userId: v.string() },
returns: v.array(
v.object({
_id: v.id("notifications"),
_creationTime: v.number(),
userId: v.string(),
message: v.string(),
read: v.boolean(),
}),
),
handler: async (ctx, args) => {
return await ctx.db
.query("notifications")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.filter((q) => q.eq(q.field("read"), false))
.collect();
},
});
```
```ts
// convex/convex.config.ts
import { defineApp } from "convex/server";
import notifications from "./components/notifications/convex.config.js";
const app = defineApp();
app.use(notifications);
export default app;
```
```ts
// convex/notifications.ts (app-side wrapper)
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { components } from "./_generated/api";
import { getAuthUserId } from "@convex-dev/auth/server";
export const sendNotification = mutation({
args: { message: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
await ctx.runMutation(components.notifications.lib.send, {
userId,
message: args.message,
});
return null;
},
});
export const myUnread = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
return await ctx.runQuery(components.notifications.lib.listUnread, {
userId,
});
},
});
```
Note the reference path shape: a function in `convex/components/notifications/lib.ts` is called as `components.notifications.lib.send` from the app.
## Critical Rules
- Keep authentication in the app, because `ctx.auth` is not available inside components.
- Keep environment access in the app, because component functions cannot read `process.env`.
- Pass parent app IDs across the boundary as strings, because `Id` types become plain strings in the app-facing `ComponentApi`.
- Do not use `v.id("parentTable")` for app-owned tables inside component args or schema, because the component has no access to the app's table namespace.
- Import `query`, `mutation`, and `action` from the component's own `./_generated/server`, not the app's generated files.
- Do not expose component functions directly to clients. Create app wrappers when client access is needed, because components are internal and need auth/env wiring the app provides.
- If the component defines HTTP handlers, mount the routes in the app's `convex/http.ts`, because components cannot register their own HTTP routes.
- If the component needs pagination, use `paginator` from `convex-helpers` instead of built-in `.paginate()`, because `.paginate()` does not work across the component boundary.
- Add `args` and `returns` validators to all public component functions, because the component boundary requires explicit type contracts.
## Patterns
### Authentication and environment access
```ts
// Bad: component code cannot rely on app auth or env
const identity = await ctx.auth.getUserIdentity();
const apiKey = process.env.OPENAI_API_KEY;
```
```ts
// Good: the app resolves auth and env, then passes explicit values
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
await ctx.runAction(components.translator.translate, {
userId,
apiKey: process.env.OPENAI_API_KEY,
text: args.text,
});
```
### Client-facing API
```ts
// Bad: assuming a component function is directly callable by clients
export const send = components.notifications.send;
```
```ts
// Good: re-export through an app mutation or query
export const sendNotification = mutation({
args: { message: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
await ctx.runMutation(components.notifications.lib.send, {
userId,
message: args.message,
});
return null;
},
});
```
### IDs across the boundary
```ts
// Bad: parent app table IDs are not valid component validators
args: {
userId: v.id("users");
}
```
```ts
// Good: treat parent-owned IDs as strings at the boundary
args: {
userId: v.string();
}
```
### Advanced Patterns
For additional patterns including function handles for callbacks, deriving validators from schema, static configuration with a globals table, and class-based client wrappers, see `references/advanced-patterns.md`.
## Validation
Try validation in this order:
1. `npx convex codegen --component-dir convex/components/<name>`
2. `npx convex codegen`
3. `npx convex dev`
Important:
- Fresh repos may fail these commands until `CONVEX_DEPLOYMENT` is configured.
- Until codegen runs, component-local `./_generated/*` imports and app-side `components.<name>...` references will not typecheck.
- If validation blocks on Convex login or deployment setup, stop and ask the user for that exact step instead of guessing.
## Reference Files
Read exactly one of these after the user confirms the goal:
- `references/local-components.md`
- `references/packaged-components.md`
- `references/hybrid-components.md`
Official docs: [Authoring Components](https://docs.convex.dev/components/authoring)
## Checklist
- [ ] Asked the user what they want to build and confirmed the shape
- [ ] Read the matching reference file
- [ ] Confirmed a component is the right abstraction
- [ ] Planned tables, public API, boundaries, and app wrappers
- [ ] Component lives under `convex/components/<name>/` (or package layout if publishing)
- [ ] Component imports from its own `./_generated/server`
- [ ] Auth, env access, and HTTP routes stay in the app
- [ ] Parent app IDs cross the boundary as `v.string()`
- [ ] Public functions have `args` and `returns` validators
- [ ] Ran `npx convex dev` and fixed codegen or type issues
@@ -0,0 +1,10 @@
interface:
display_name: "Convex Create Component"
short_description: "Design and build reusable Convex components with clear boundaries."
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#14B8A6"
default_prompt: "Help me create a Convex component for this feature. First check that a component is actually justified, then design the tables, API surface, and app-facing wrappers before implementing it."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-2.25-1.313M21 7.5v2.25m0-2.25-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3 2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75 2.25-1.313M12 21.75V19.5m0 2.25-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"/>
</svg>

After

Width:  |  Height:  |  Size: 485 B

@@ -0,0 +1,134 @@
# Advanced Component Patterns
Additional patterns for Convex components that go beyond the basics covered in the main skill file.
## Function Handles for callbacks
When the app needs to pass a callback function to the component, use function handles. This is common for components that run app-defined logic on a schedule or in a workflow.
```ts
// App side: create a handle and pass it to the component
import { createFunctionHandle } from "convex/server";
export const startJob = mutation({
handler: async (ctx) => {
const handle = await createFunctionHandle(internal.myModule.processItem);
await ctx.runMutation(components.workpool.enqueue, {
callback: handle,
});
},
});
```
```ts
// Component side: accept and invoke the handle
import { v } from "convex/values";
import type { FunctionHandle } from "convex/server";
import { mutation } from "./_generated/server.js";
export const enqueue = mutation({
args: { callback: v.string() },
handler: async (ctx, args) => {
const handle = args.callback as FunctionHandle<"mutation">;
await ctx.scheduler.runAfter(0, handle, {});
},
});
```
## Deriving validators from schema
Instead of manually repeating field types in return validators, extend the schema validator:
```ts
import { v } from "convex/values";
import schema from "./schema.js";
const notificationDoc = schema.tables.notifications.validator.extend({
_id: v.id("notifications"),
_creationTime: v.number(),
});
export const getLatest = query({
args: {},
returns: v.nullable(notificationDoc),
handler: async (ctx) => {
return await ctx.db.query("notifications").order("desc").first();
},
});
```
## Static configuration with a globals table
A common pattern for component configuration is a single-document "globals" table:
```ts
// schema.ts
export default defineSchema({
globals: defineTable({
maxRetries: v.number(),
webhookUrl: v.optional(v.string()),
}),
// ... other tables
});
```
```ts
// lib.ts
export const configure = mutation({
args: { maxRetries: v.number(), webhookUrl: v.optional(v.string()) },
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db.query("globals").first();
if (existing) {
await ctx.db.patch(existing._id, args);
} else {
await ctx.db.insert("globals", args);
}
return null;
},
});
```
## Class-based client wrappers
For components with many functions or configuration options, a class-based client provides a cleaner API. This pattern is common in published components.
```ts
// src/client/index.ts
import type { GenericMutationCtx, GenericDataModel } from "convex/server";
import type { ComponentApi } from "../component/_generated/component.js";
type MutationCtx = Pick<GenericMutationCtx<GenericDataModel>, "runMutation">;
export class Notifications {
constructor(
private component: ComponentApi,
private options?: { defaultChannel?: string },
) {}
async send(ctx: MutationCtx, args: { userId: string; message: string }) {
return await ctx.runMutation(this.component.lib.send, {
...args,
channel: this.options?.defaultChannel ?? "default",
});
}
}
```
```ts
// App usage
import { Notifications } from "@convex-dev/notifications";
import { components } from "./_generated/api";
const notifications = new Notifications(components.notifications, {
defaultChannel: "alerts",
});
export const send = mutation({
args: { message: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
await notifications.send(ctx, { userId, message: args.message });
},
});
```
@@ -0,0 +1,37 @@
# Hybrid Convex Components
Read this file only when the user explicitly wants a hybrid setup.
## What This Means
A hybrid component combines a local Convex component with shared library code.
This can help when:
- the user wants a local install but also shared package logic
- the component needs extension points or override hooks
- some logic should live in normal TypeScript code outside the component boundary
## Default Advice
Treat hybrid as an advanced option, not the default.
Before choosing it, ask:
- Why is a plain local component not enough?
- Why is a packaged component not enough?
- What exactly needs to stay overridable or shared?
If the answer is vague, fall back to local or packaged.
## Risks
- More moving parts
- Harder upgrades and backwards compatibility
- Easier to blur the component boundary
## Checklist
- [ ] User explicitly needs hybrid behavior
- [ ] Local-only and packaged-only options were considered first
- [ ] The extension points are clearly defined before coding
@@ -0,0 +1,38 @@
# Local Convex Components
Read this file when the component should live inside the current app and does not need to be published as an npm package.
## When to Choose This
- The user wants the simplest path
- The component only needs to work in this repo
- The goal is extracting app logic into a cleaner boundary
## Default Layout
Use this structure unless the repo already has a clear alternative pattern:
```text
convex/
convex.config.ts
components/
<name>/
convex.config.ts
schema.ts
<feature>.ts
```
## Workflow Notes
- Define the component with `defineComponent("<name>")`
- Install it from the app with `defineApp()` and `app.use(...)`
- Keep auth, env access, public API wrappers, and HTTP route mounting in the app
- Let the component own isolated tables and reusable backend workflows
- Add app wrappers if clients need to call into the component
## Checklist
- [ ] Component is inside `convex/components/<name>/`
- [ ] App installs it with `app.use(...)`
- [ ] Component owns only its own tables
- [ ] App wrappers handle client-facing calls when needed
@@ -0,0 +1,51 @@
# Packaged Convex Components
Read this file when the user wants a reusable npm package or a component shared across multiple apps.
## When to Choose This
- The user wants to publish the component
- The user wants a stable reusable package boundary
- The component will be shared across multiple apps or teams
## Default Approach
- Prefer starting from `npx create-convex@latest --component` when possible
- Keep the official authoring docs as the source of truth for package layout and exports
- Validate the bundled package through an example app, not just the source files
## Build Flow
When building a packaged component, make sure the bundled output exists before the example app tries to consume it.
Recommended order:
1. `npx convex codegen --component-dir ./path/to/component`
2. Run the package build command
3. Run `npx convex dev --typecheck-components` in the example app
Do not assume normal app codegen is enough for packaged component workflows.
## Package Exports
If publishing to npm, make sure the package exposes the entry points apps need:
- package root for client helpers, types, or classes
- `./convex.config.js` for installing the component
- `./_generated/component.js` for the app-facing `ComponentApi` type
- `./test` for testing helpers when applicable
## Testing
- Use `convex-test` for component logic
- Register the component schema and modules with the test instance
- Test app-side wrapper code from an example app that installs the package
- Export a small helper from `./test` if consumers need easy test registration
## Checklist
- [ ] Packaging is actually required
- [ ] Build order avoids bundle and codegen races
- [ ] Package exports include install and typing entry points
- [ ] Example app exercises the packaged component
- [ ] Core behavior is covered by tests
@@ -0,0 +1,149 @@
---
name: convex-migration-helper
description: Plans Convex schema and data migrations with widen-migrate-narrow and @convex-dev/migrations. Use for breaking schema changes, backfills, table reshaping, or zero-downtime rollouts.
---
# Convex Migration Helper
Safely migrate Convex schemas and data when making breaking changes.
## When to Use
- Adding new required fields to existing tables
- Changing field types or structure
- Splitting or merging tables
- Renaming or deleting fields
- Migrating from nested to relational data
## When Not to Use
- Greenfield schema with no existing data in production or dev
- Adding optional fields that do not need backfilling
- Adding new tables with no existing data to migrate
- Adding or removing indexes with no correctness concern
- Questions about Convex schema design without a migration need
## Key Concepts
### Schema Validation Drives the Workflow
Convex will not let you deploy a schema that does not match the data at rest. This is the fundamental constraint that shapes every migration:
- You cannot add a required field if existing documents don't have it
- You cannot change a field's type if existing documents have the old type
- You cannot remove a field from the schema if existing documents still have it
This means migrations follow a predictable pattern: **widen the schema, migrate the data, narrow the schema**.
### Online Migrations
Convex migrations run online, meaning the app continues serving requests while data is updated asynchronously in batches. During the migration window, your code must handle both old and new data formats.
### Prefer New Fields Over Changing Types
When changing the shape of data, create a new field rather than modifying an existing one. This makes the transition safer and easier to roll back.
### Don't Delete Data
Unless you are certain, prefer deprecating fields over deleting them. Mark the field as `v.optional` and add a code comment explaining it is deprecated and why it existed.
## Safe Changes (No Migration Needed)
### Adding Optional Field
```typescript
// Before
users: defineTable({
name: v.string(),
});
// After - safe, new field is optional
users: defineTable({
name: v.string(),
bio: v.optional(v.string()),
});
```
### Adding New Table
```typescript
posts: defineTable({
userId: v.id("users"),
title: v.string(),
}).index("by_user", ["userId"]);
```
### Adding Index
```typescript
users: defineTable({
name: v.string(),
email: v.string(),
}).index("by_email", ["email"]);
```
## Breaking Changes: The Deployment Workflow
Every breaking migration follows the same multi-deploy pattern:
**Deploy 1 - Widen the schema:**
1. Update schema to allow both old and new formats (e.g., add optional new field)
2. Update code to handle both formats when reading
3. Update code to write the new format for new documents
4. Deploy
**Between deploys - Migrate data:**
5. Run migration to backfill existing documents
6. Verify all documents are migrated
**Deploy 2 - Narrow the schema:**
7. Update schema to require the new format only
8. Remove code that handles the old format
9. Deploy
## Using the Migrations Component
For any non-trivial migration, use the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component. It handles batching, cursor-based pagination, state tracking, resume from failure, dry runs, and progress monitoring.
See `references/migrations-component.md` for installation, setup, defining and running migrations, dry runs, status monitoring, and configuration options.
## Common Migration Patterns
See `references/migration-patterns.md` for complete patterns with code examples covering:
- Adding a required field
- Deleting a field
- Changing a field type
- Splitting nested data into a separate table
- Cleaning up orphaned documents
- Zero-downtime strategies (dual write, dual read)
- Small table shortcut (single internalMutation without the component)
- Verifying a migration is complete
## Common Pitfalls
1. **Making a field required before migrating data**: Convex rejects the deploy because existing documents lack the field. Always widen the schema first.
2. **Using `.collect()` on large tables**: Hits transaction limits or causes timeouts. Use the migrations component for proper batched pagination. `.collect()` is only safe for tables you know are small.
3. **Not writing the new format before migrating**: Documents created during the migration window will be missed, leaving unmigrated data after the migration "completes."
4. **Skipping the dry run**: Use `dryRun: true` to validate migration logic before committing changes to production data. Catches bugs before they touch real documents.
5. **Deleting fields prematurely**: Prefer deprecating with `v.optional` and a comment. Only delete after you are confident the data is no longer needed and no code references it.
6. **Using crons for migration batches**: The migrations component handles batching via recursive scheduling internally. Crons require manual cleanup and an extra deploy to remove.
## Migration Checklist
- [ ] Identify the breaking change and plan the multi-deploy workflow
- [ ] Update schema to allow both old and new formats
- [ ] Update code to handle both formats when reading
- [ ] Update code to write the new format for new documents
- [ ] Deploy widened schema and updated code
- [ ] Define migration using the `@convex-dev/migrations` component
- [ ] Test with `dryRun: true`
- [ ] Run migration and monitor status
- [ ] Verify all documents are migrated
- [ ] Update schema to require new format only
- [ ] Clean up code that handled old format
- [ ] Deploy final schema and code
- [ ] Remove migration code once confirmed stable
@@ -0,0 +1,10 @@
interface:
display_name: "Convex Migration Helper"
short_description: "Plan and run safe Convex schema and data migrations."
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#8B5CF6"
default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying the schema change, the existing data shape, and the widen-migrate-narrow path before making edits."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/>
</svg>

After

Width:  |  Height:  |  Size: 386 B

@@ -0,0 +1,231 @@
# Migration Patterns Reference
Common migration patterns, zero-downtime strategies, and verification techniques for Convex schema and data migrations.
## Adding a Required Field
```typescript
// Deploy 1: Schema allows both states
users: defineTable({
name: v.string(),
role: v.optional(v.union(v.literal("user"), v.literal("admin"))),
});
// Migration: backfill the field
export const addDefaultRole = migrations.define({
table: "users",
migrateOne: async (ctx, user) => {
if (user.role === undefined) {
await ctx.db.patch(user._id, { role: "user" });
}
},
});
// Deploy 2: After migration completes, make it required
users: defineTable({
name: v.string(),
role: v.union(v.literal("user"), v.literal("admin")),
});
```
## Deleting a Field
Mark the field optional first, migrate data to remove it, then remove from schema:
```typescript
// Deploy 1: Make optional
// isPro: v.boolean() --> isPro: v.optional(v.boolean())
// Migration
export const removeIsPro = migrations.define({
table: "teams",
migrateOne: async (ctx, team) => {
if (team.isPro !== undefined) {
await ctx.db.patch(team._id, { isPro: undefined });
}
},
});
// Deploy 2: Remove isPro from schema entirely
```
## Changing a Field Type
Prefer creating a new field. You can combine adding and deleting in one migration:
```typescript
// Deploy 1: Add new field, keep old field optional
// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...)
// Migration: convert old field to new field
export const convertToEnum = migrations.define({
table: "teams",
migrateOne: async (ctx, team) => {
if (team.plan === undefined) {
await ctx.db.patch(team._id, {
plan: team.isPro ? "pro" : "basic",
isPro: undefined,
});
}
},
});
// Deploy 2: Remove isPro from schema, make plan required
```
## Splitting Nested Data Into a Separate Table
```typescript
export const extractPreferences = migrations.define({
table: "users",
migrateOne: async (ctx, user) => {
if (user.preferences === undefined) return;
const existing = await ctx.db
.query("userPreferences")
.withIndex("by_user", (q) => q.eq("userId", user._id))
.first();
if (!existing) {
await ctx.db.insert("userPreferences", {
userId: user._id,
...user.preferences,
});
}
await ctx.db.patch(user._id, { preferences: undefined });
},
});
```
Make sure your code is already writing to the new `userPreferences` table for new users before running this migration, so you don't miss documents created during the migration window.
## Cleaning Up Orphaned Documents
```typescript
export const deleteOrphanedEmbeddings = migrations.define({
table: "embeddings",
migrateOne: async (ctx, doc) => {
const chunk = await ctx.db
.query("chunks")
.withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id))
.first();
if (!chunk) {
await ctx.db.delete(doc._id);
}
},
});
```
## Zero-Downtime Strategies
During the migration window, your app must handle both old and new data formats. There are two main strategies.
### Dual Write (Preferred)
Write to both old and new structures. Read from the old structure until migration is complete.
1. Deploy code that writes both formats, reads old format
2. Run migration on existing data
3. Deploy code that reads new format, still writes both
4. Deploy code that only reads and writes new format
This is preferred because you can safely roll back at any point, the old format is always up to date.
```typescript
// Bad: only writing to new structure before migration is done
export const createTeam = mutation({
args: { name: v.string(), isPro: v.boolean() },
handler: async (ctx, args) => {
await ctx.db.insert("teams", {
name: args.name,
plan: args.isPro ? "pro" : "basic",
});
},
});
// Good: writing to both structures during migration
export const createTeam = mutation({
args: { name: v.string(), isPro: v.boolean() },
handler: async (ctx, args) => {
const plan = args.isPro ? "pro" : "basic";
await ctx.db.insert("teams", {
name: args.name,
isPro: args.isPro,
plan,
});
},
});
```
### Dual Read
Read both formats. Write only the new format.
1. Deploy code that reads both formats (preferring new), writes only new format
2. Run migration on existing data
3. Deploy code that reads and writes only new format
This avoids duplicating writes, which is useful when having two copies of data could cause inconsistencies. The downside is that rolling back to before step 1 is harder, since new documents only have the new format.
```typescript
// Good: reading both formats, preferring new
function getTeamPlan(team: Doc<"teams">): "basic" | "pro" {
if (team.plan !== undefined) return team.plan;
return team.isPro ? "pro" : "basic";
}
```
## Small Table Shortcut
For small tables (a few thousand documents at most), you can migrate in a single `internalMutation` without the component:
```typescript
import { internalMutation } from "./_generated/server";
export const backfillSmallTable = internalMutation({
handler: async (ctx) => {
const docs = await ctx.db.query("smallConfig").collect();
for (const doc of docs) {
if (doc.newField === undefined) {
await ctx.db.patch(doc._id, { newField: "default" });
}
}
},
});
```
```bash
npx convex run migrations:backfillSmallTable
```
Only use `.collect()` when you are certain the table is small. For anything larger, use the migrations component.
## Verifying a Migration
Query to check remaining unmigrated documents:
```typescript
import { query } from "./_generated/server";
export const verifyMigration = query({
handler: async (ctx) => {
const remaining = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("role"), undefined))
.take(10);
return {
complete: remaining.length === 0,
sampleRemaining: remaining.map((u) => u._id),
};
},
});
```
Or use the component's built-in status monitoring:
```bash
npx convex run --component migrations lib:getStatus --watch
```
@@ -0,0 +1,169 @@
# Migrations Component Reference
Complete guide to the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component for batched, resumable Convex data migrations.
## Installation
```bash
npm install @convex-dev/migrations
```
## Setup
```typescript
// convex/convex.config.ts
import { defineApp } from "convex/server";
import migrations from "@convex-dev/migrations/convex.config.js";
const app = defineApp();
app.use(migrations);
export default app;
```
```typescript
// convex/migrations.ts
import { Migrations } from "@convex-dev/migrations";
import { components } from "./_generated/api.js";
import { DataModel } from "./_generated/dataModel.js";
export const migrations = new Migrations<DataModel>(components.migrations);
export const run = migrations.runner();
```
The `DataModel` type parameter is optional but provides type safety for migration definitions.
## Define a Migration
The `migrateOne` function processes a single document. The component handles batching and pagination automatically.
```typescript
// convex/migrations.ts
export const addDefaultRole = migrations.define({
table: "users",
migrateOne: async (ctx, user) => {
if (user.role === undefined) {
await ctx.db.patch(user._id, { role: "user" });
}
},
});
```
Shorthand: if you return an object, it is applied as a patch automatically.
```typescript
export const clearDeprecatedField = migrations.define({
table: "users",
migrateOne: () => ({ legacyField: undefined }),
});
```
## Run a Migration
From the CLI:
```bash
# Define a one-off runner in convex/migrations.ts:
# export const runIt = migrations.runner(internal.migrations.addDefaultRole);
npx convex run migrations:runIt
# Or use the general-purpose runner
npx convex run migrations:run '{"fn": "migrations:addDefaultRole"}'
```
Programmatically from another Convex function:
```typescript
await migrations.runOne(ctx, internal.migrations.addDefaultRole);
```
## Run Multiple Migrations in Order
```typescript
export const runAll = migrations.runner([
internal.migrations.addDefaultRole,
internal.migrations.clearDeprecatedField,
internal.migrations.normalizeEmails,
]);
```
```bash
npx convex run migrations:runAll
```
If one fails, it stops and will not continue to the next. Call it again to retry from where it left off. Completed migrations are skipped automatically.
## Dry Run
Test a migration before committing changes:
```bash
npx convex run migrations:runIt '{"dryRun": true}'
```
This runs one batch and then rolls back, so you can see what it would do without changing any data.
## Check Migration Status
```bash
npx convex run --component migrations lib:getStatus --watch
```
## Cancel a Running Migration
```bash
npx convex run --component migrations lib:cancel '{"name": "migrations:addDefaultRole"}'
```
Or programmatically:
```typescript
await migrations.cancel(ctx, internal.migrations.addDefaultRole);
```
## Run Migrations on Deploy
Chain migration execution after deploying:
```bash
npx convex deploy --cmd 'npm run build' && npx convex run migrations:runAll --prod
```
## Configuration Options
### Custom Batch Size
If documents are large or the table has heavy write traffic, reduce the batch size to avoid transaction limits or OCC conflicts:
```typescript
export const migrateHeavyTable = migrations.define({
table: "largeDocuments",
batchSize: 10,
migrateOne: async (ctx, doc) => {
// migration logic
},
});
```
### Migrate a Subset Using an Index
Process only matching documents instead of the full table:
```typescript
export const fixEmptyNames = migrations.define({
table: "users",
customRange: (query) => query.withIndex("by_name", (q) => q.eq("name", "")),
migrateOne: () => ({ name: "<unknown>" }),
});
```
### Parallelize Within a Batch
By default each document in a batch is processed serially. Enable parallel processing if your migration logic does not depend on ordering:
```typescript
export const clearField = migrations.define({
table: "myTable",
parallelize: true,
migrateOne: () => ({ optionalField: undefined }),
});
```
@@ -0,0 +1,143 @@
---
name: convex-performance-audit
description: Audits Convex performance for reads, subscriptions, write contention, and function limits. Use for slow features, insights findings, OCC conflicts, or read amplification.
---
# Convex Performance Audit
Diagnose and fix performance problems in Convex applications, one problem class at a time.
## When to Use
- A Convex page or feature feels slow or expensive
- `npx convex insights --details` reports high bytes read, documents read, or OCC conflicts
- Low-freshness read paths are using reactivity where point-in-time reads would do
- OCC conflict errors or excessive mutation retries
- High subscription count or slow UI updates
- Functions approaching execution or transaction limits
- The same performance pattern needs fixing across sibling functions
## When Not to Use
- Initial Convex setup, auth setup, or component extraction
- Pure schema migrations with no performance goal
- One-off micro-optimizations without a user-visible or deployment-visible problem
## Guardrails
- Prefer simpler code when scale is small, traffic is modest, or the available signals are weak
- Do not recommend digest tables, document splitting, fetch-strategy changes, or migration-heavy rollouts unless there is a measured signal, a clearly unbounded path, or a known hot read/write path
- In Convex, a simple scan on a small table is often acceptable. Do not invent structural work just because a pattern is not ideal at large scale
## First Step: Gather Signals
Start with the strongest signal available:
1. If deployment Health insights are already available from the user or the current context, treat them as a first-class source of performance signals.
2. If CLI insights are available, run `npx convex insights --details`. Use `--prod`, `--preview-name`, or `--deployment-name` when needed.
- If the local repo's Convex CLI is too old to support `insights`, try `npx -y convex@latest insights --details` before giving up.
3. If the repo already uses `convex-doctor`, you may treat its findings as hints. Do not require it, and do not treat it as the source of truth.
4. If runtime signals are unavailable, audit from code anyway, but keep the guardrails above in mind. Lack of insights is not proof of health, but it is also not proof that a large refactor is warranted.
## Signal Routing
After gathering signals, identify the problem class and read the matching reference file.
| Signal | Reference |
| -------------------------------------------------------------- | ----------------------------------------- |
| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` |
| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` |
| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` |
| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` |
| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` |
Multiple problem classes can overlap. Read the most relevant reference first, then check the others if symptoms remain.
## Escalate Larger Fixes
If the likely fix is invasive, cross-cutting, or migration-heavy, stop and present options before editing.
Examples:
- introducing digest or summary tables across multiple flows
- splitting documents to isolate frequently-updated fields
- reworking pagination or fetch strategy across several screens
- switching to a new index or denormalized field that needs migration-safe rollout
When correctness depends on handling old and new states during a rollout, consult `skills/convex-migration-helper/SKILL.md` for the migration workflow.
## Workflow
### 1. Scope the problem
Pick one concrete user flow from the actual project. Look at the codebase, client pages, and API surface to find the flow that matches the symptom.
Write down:
- entrypoint functions
- client callsites using `useQuery`, `usePaginatedQuery`, or `useMutation`
- tables read
- tables written
- whether the path is high-read, high-write, or both
### 2. Trace the full read and write set
For each function in the path:
1. Trace every `ctx.db.get()` and `ctx.db.query()`
2. Trace every `ctx.db.patch()`, `ctx.db.replace()`, and `ctx.db.insert()`
3. Note foreign-key lookups, JS-side filtering, and full-document reads
4. Identify all sibling functions touching the same tables
5. Identify reactive stats, aggregates, or widgets rendered on the same page
In Convex, every extra read increases transaction work, and every write can invalidate reactive subscribers. Treat read amplification and invalidation amplification as first-class problems.
### 3. Apply fixes from the relevant reference
Read the reference file matching your problem class. Each reference includes specific patterns, code examples, and a recommended fix order.
Do not stop at the single function named by an insight. Trace sibling readers and writers touching the same tables.
### 4. Fix sibling functions together
When one function touching a table has a performance bug, audit sibling functions for the same pattern.
After finding one problem, inspect both sibling readers and sibling writers for the same table family, including companion digest or summary tables.
Examples:
- If one list query switches from full docs to a digest table, inspect the other list queries for that table
- If one mutation isolates a frequently-updated field or splits a hot document, inspect the other writers to the same table
- If one read path needs a migration-safe rollout for an unbackfilled field, inspect sibling reads for the same rollout risk
Do not leave one path fixed and another path on the old pattern unless there is a clear product reason.
### 5. Verify before finishing
Confirm all of these:
1. Results are the same as before, no dropped records
2. Eliminated reads or writes are no longer in the path where expected
3. Fallback behavior works when denormalized or indexed fields are missing
4. Frequently-updated fields are isolated from widely-read documents where needed
5. Every relevant sibling reader and writer was inspected, not just the original function
## Reference Files
- `references/hot-path-rules.md` - Read amplification, invalidation, denormalization, indexes, digest tables
- `references/occ-conflicts.md` - Write contention, OCC resolution, hot document splitting
- `references/subscription-cost.md` - Reactive query cost, subscription granularity, point-in-time reads
- `references/function-budget.md` - Execution limits, transaction size, large documents, payload size
Also check the official [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/) page for additional patterns covering argument validation, access control, and code organization that may surface during the audit.
## Checklist
- [ ] Gathered signals from insights, dashboard, or code audit
- [ ] Identified the problem class and read the matching reference
- [ ] Scoped one concrete user flow or function path
- [ ] Traced every read and write in that path
- [ ] Identified sibling functions touching the same tables
- [ ] Applied fixes from the reference, following the recommended fix order
- [ ] Fixed sibling functions consistently
- [ ] Verified behavior and confirmed no regressions
@@ -0,0 +1,10 @@
interface:
display_name: "Convex Performance Audit"
short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits."
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#EF4444"
default_prompt: "Audit this Convex app for performance issues. Start with the strongest signal available, identify the problem class, and suggest the smallest high-impact fix before proposing bigger structural changes."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 490 B

@@ -0,0 +1,232 @@
# Function Budget
Use these rules when functions are hitting execution limits, transaction size errors, or returning excessively large payloads to the client.
## Core Principle
Convex functions run inside transactions with budgets for time, reads, and writes. Staying well within these limits is not just about avoiding errors, it reduces latency and contention.
## Limits to Know
These are the current values from the [Convex limits docs](https://docs.convex.dev/production/state/limits). Check that page for the latest numbers.
| Resource | Limit |
| --------------------------------- | ----------------------------------------------------- |
| Query/mutation execution time | 1 second (user code only, excludes DB operations) |
| Action execution time | 10 minutes |
| Data read per transaction | 16 MiB |
| Data written per transaction | 16 MiB |
| Documents scanned per transaction | 32,000 (includes documents filtered out by `.filter`) |
| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) |
| Documents written per transaction | 16,000 |
| Individual document size | 1 MiB |
| Function return value size | 16 MiB |
## Symptoms
- "Function execution took too long" errors
- "Transaction too large" or read/write set size errors
- Slow queries that read many documents
- Client receiving large payloads that slow down page load
- `npx convex insights --details` showing high bytes read
## Common Causes
### Unbounded collection
A query that calls `.collect()` on a table without a reasonable limit. As the table grows, the query reads more and more documents.
### Large document reads on hot paths
Reading documents with large fields (rich text, embedded media references, long arrays) when only a small subset of the data is needed for the current view.
### Mutation doing too much work
A single mutation that updates hundreds of documents, backfills data, or rebuilds derived state in one transaction.
### Returning too much data to the client
A query returning full documents when the client only needs a few fields.
## Fix Order
### 1. Bound your reads
Never `.collect()` without a limit on a table that can grow unbounded.
```ts
// Bad: unbounded read, breaks as the table grows
const messages = await ctx.db.query("messages").collect();
```
```ts
// Good: paginate or limit
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.take(50);
```
### 2. Read smaller shapes
If the list page only needs title, author, and date, do not read full documents with rich content fields.
Use digest or summary tables for hot list pages. See `hot-path-rules.md` for the digest table pattern.
### 3. Break large mutations into batches
If a mutation needs to update hundreds of documents, split it into a self-scheduling chain.
```ts
// Bad: one mutation updating every row
export const backfillAll = internalMutation({
handler: async (ctx) => {
const docs = await ctx.db.query("items").collect();
for (const doc of docs) {
await ctx.db.patch(doc._id, { newField: computeValue(doc) });
}
},
});
```
```ts
// Good: cursor-based batch processing
export const backfillBatch = internalMutation({
args: { cursor: v.optional(v.string()), batchSize: v.optional(v.number()) },
handler: async (ctx, args) => {
const batchSize = args.batchSize ?? 100;
const result = await ctx.db
.query("items")
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
for (const doc of result.page) {
if (doc.newField === undefined) {
await ctx.db.patch(doc._id, { newField: computeValue(doc) });
}
}
if (!result.isDone) {
await ctx.scheduler.runAfter(0, internal.items.backfillBatch, {
cursor: result.continueCursor,
batchSize,
});
}
},
});
```
### 4. Move heavy work to actions
Queries and mutations run inside Convex's transactional runtime with strict budgets. If you need to do CPU-intensive computation, call external APIs, or process large files, use an action instead.
Actions run outside the transaction and can call mutations to write results back.
```ts
// Bad: heavy computation inside a mutation
export const processUpload = mutation({
handler: async (ctx, args) => {
const result = expensiveComputation(args.data);
await ctx.db.insert("results", result);
},
});
```
```ts
// Good: action for heavy work, mutation for the write
export const processUpload = action({
handler: async (ctx, args) => {
const result = expensiveComputation(args.data);
await ctx.runMutation(internal.results.store, { result });
},
});
```
### 5. Trim return values
Only return what the client needs. If a query fetches full documents but the component only renders a few fields, map the results before returning.
```ts
// Bad: returns full documents including large content fields
export const list = query({
handler: async (ctx) => {
return await ctx.db.query("articles").take(20);
},
});
```
```ts
// Good: project to only the fields the client needs
export const list = query({
handler: async (ctx) => {
const articles = await ctx.db.query("articles").take(20);
return articles.map((a) => ({
_id: a._id,
title: a.title,
author: a.author,
createdAt: a._creationTime,
}));
},
});
```
### 6. Replace `ctx.runQuery` and `ctx.runMutation` with helper functions
Inside queries and mutations, `ctx.runQuery` and `ctx.runMutation` have overhead compared to calling a plain TypeScript helper function. They run in the same transaction but pay extra per-call cost.
```ts
// Bad: unnecessary overhead from ctx.runQuery inside a mutation
export const createProject = mutation({
handler: async (ctx, args) => {
const user = await ctx.runQuery(api.users.getCurrentUser);
await ctx.db.insert("projects", { ...args, ownerId: user._id });
},
});
```
```ts
// Good: plain helper function, no extra overhead
export const createProject = mutation({
handler: async (ctx, args) => {
const user = await getCurrentUser(ctx);
await ctx.db.insert("projects", { ...args, ownerId: user._id });
},
});
```
Exception: components require `ctx.runQuery`/`ctx.runMutation`. Use them there, but prefer helpers everywhere else.
### 7. Avoid unnecessary `runAction` calls
`runAction` from within an action creates a separate function invocation with its own memory and CPU budget. The parent action just sits idle waiting. Replace with a plain TypeScript function call unless you need a different runtime (e.g. calling Node.js code from the Convex runtime).
```ts
// Bad: runAction overhead for no reason
export const processItems = action({
handler: async (ctx, args) => {
for (const item of args.items) {
await ctx.runAction(internal.items.processOne, { item });
}
},
});
```
```ts
// Good: plain function call
export const processItems = action({
handler: async (ctx, args) => {
for (const item of args.items) {
await processOneItem(ctx, { item });
}
},
});
```
## Verification
1. No function execution or transaction size errors
2. `npx convex insights --details` shows reduced bytes read
3. Large mutations are batched and self-scheduling
4. Client payloads are reasonably sized for the UI they serve
5. `ctx.runQuery`/`ctx.runMutation` in queries and mutations replaced with helpers where possible
6. Sibling functions with similar patterns were checked
@@ -0,0 +1,369 @@
# Hot Path Rules
Use these rules when the top-level workflow points to read amplification, denormalization, index rollout, reactive query cost, or invalidation-heavy writes.
## Contents
- Core Principle
- Consistency Rule
- 1. Push Filters To Storage (indexes, migration rule, redundant indexes)
- 2. Minimize Data Sources (denormalization, fallback rule)
- 3. Minimize Row Size (digest tables)
- 4. Skip No-Op Writes
- 5. Match Consistency To Read Patterns (high-read/low-write, high-read/high-write)
- Convex-Specific Notes (reactive queries, point-in-time reads, triggers, aggregates, backfills)
- Verification
## Core Principle
Every byte read or written multiplies with concurrency.
Think:
`cost x calls_per_second x 86400`
In Convex, every write can also fan out into reactive invalidation, replication work, and downstream sync.
## Consistency Rule
If you fix a hot-path pattern for one function, audit sibling functions touching the same tables for the same pattern.
Do this especially for:
- multiple list queries over the same table
- multiple writers to the same table
- public browse and search queries over the same records
- helper functions reused by more than one endpoint
## 1. Push Filters To Storage
Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB scan mean you already paid for the read. The Convex `.filter()` method has the same performance as filtering in JS, it does not push the predicate to the storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the documents scanned.
Prefer:
- `withIndex(...)`
- `.withSearchIndex(...)` for text search
- narrower tables
- summary tables
before accepting a scan-plus-filter pattern.
```ts
// Bad: scans then filters in JavaScript
export const listOpen = query({
args: {},
handler: async (ctx) => {
const tasks = await ctx.db.query("tasks").collect();
return tasks.filter((task) => task.status === "open");
},
});
```
```ts
// Also bad: Convex .filter() does not push to storage either
export const listOpen = query({
args: {},
handler: async (ctx) => {
return await ctx.db
.query("tasks")
.filter((q) => q.eq(q.field("status"), "open"))
.collect();
},
});
```
```ts
// Good: use an index so storage does the filtering
export const listOpen = query({
args: {},
handler: async (ctx) => {
return await ctx.db
.query("tasks")
.withIndex("by_status", (q) => q.eq("status", "open"))
.collect();
},
});
```
### Migration rule for indexes
New indexes on partially backfilled fields can create correctness bugs during rollout.
Important Convex detail:
`undefined !== false`
If an older document is missing a field entirely, it will not match a compound index entry that expects `false`.
Do not trust old comments saying a field is "not backfilled" or "already backfilled". Verify.
If correctness depends on handling old and new states during rollout, do not improvise a partial-backfill workaround in the hot path. Use a migration-safe rollout and consult `skills/convex-migration-helper/SKILL.md`.
```ts
// Bad: optional booleans can miss older rows where the field is undefined
const projects = await ctx.db
.query("projects")
.withIndex("by_archived_and_updated", (q) => q.eq("isArchived", false))
.order("desc")
.take(20);
```
```ts
// Good: switch hot-path reads only after the rollout is migration-safe
// See the migration helper skill for dual-read / backfill / cutover patterns.
```
### Check for redundant indexes
Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need `by_foo_and_bar`, since you can query it with just the `foo` condition and omit `bar`. Extra indexes add storage cost and write overhead on every insert, patch, and delete.
```ts
// Bad: two indexes where one would do
defineTable({ team: v.id("teams"), user: v.id("users") })
.index("by_team", ["team"])
.index("by_team_and_user", ["team", "user"]);
```
```ts
// Good: single compound index serves both query patterns
defineTable({ team: v.id("teams"), user: v.id("users") }).index(
"by_team_and_user",
["team", "user"],
);
```
Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first.
## 2. Minimize Data Sources
Trace every read.
If a function resolves a foreign key for a tiny display field and a denormalized copy already exists, prefer the denormalized field on the hot path.
### When to denormalize
Denormalize when all of these are true:
- the path is hot
- the joined document is much larger than the field you need
- many readers are paying that join cost repeatedly
Useful mental model:
`join_cost = rows_per_page x foreign_doc_size x pages_per_second`
Small-table joins are often fine. Large-document joins for tiny fields on hot list pages are usually not.
### Fallback rule
Denormalized data is an optimization. Live data is the correctness path.
Rules:
- If the denormalized field is missing or null, fall back to the live read
- Do not show placeholders instead of falling back
- In lookup maps, only include fully populated entries
```ts
// Bad: missing denormalized data becomes a placeholder and blocks correctness
const ownerName = project.ownerName ?? "Unknown owner";
```
```ts
// Good: denormalized data is an optimization, not the only source of truth
const ownerName =
project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null;
```
Bad lookup map pattern:
```ts
const ownersById = {
[project.ownerId]: { ownerName: null },
};
```
That blocks fallback because the map says "I have data" when it does not.
Good lookup map pattern:
```ts
const ownersById =
project.ownerName !== undefined && project.ownerName !== null
? { [project.ownerId]: { ownerName: project.ownerName } }
: {};
```
### No denormalized copy yet
Prefer adding fields to an existing summary, companion, or digest table instead of bloating the primary hot-path table.
If introducing the new field or table requires a staged rollout, backfill, or old/new-shape handling, use the migration helper skill for the rollout plan.
Rollout order:
1. Update schema
2. Update write path
3. Backfill
4. Switch read path
## 3. Minimize Row Size
Hot list pages should read the smallest document shape that still answers the UI.
Prefer summary or digest tables over full source tables when:
- the list page only needs a subset of fields
- source documents are large
- the query is high volume
An 800 byte summary row is materially cheaper than a 3 KB full document on a hot page.
Digest tables are a tradeoff, not a default:
- Worth it when the path is clearly hot, the source rows are much larger than the UI needs, or many readers are repeatedly paying the same join and payload cost
- Probably not worth it when an indexed read on the source table is already cheap enough, the table is still small, or the extra write and migration complexity would dominate the benefit
```ts
// Bad: list page reads source docs, then joins owner data per row
const projects = await ctx.db
.query("projects")
.withIndex("by_public", (q) => q.eq("isPublic", true))
.collect();
```
```ts
// Good: list page reads the smaller digest shape first
const projects = await ctx.db
.query("projectDigests")
.withIndex("by_public_and_updated", (q) => q.eq("isPublic", true))
.order("desc")
.take(20);
```
## 4. Isolate Frequently-Updated Fields
Convex already no-ops unchanged writes. The invalidation problem here is real writes hitting documents that many queries subscribe to.
Move high-churn fields like `lastSeen`, counters, presence, or ephemeral status off widely-read documents when most readers do not need them.
Apply this across sibling writers too. Splitting one write path does not help much if three other mutations still update the same widely-read document.
```ts
// Bad: every presence heartbeat invalidates subscribers to the whole profile
await ctx.db.patch(user._id, {
name: args.name,
avatarUrl: args.avatarUrl,
lastSeen: Date.now(),
});
```
```ts
// Good: keep profile reads stable, move heartbeat updates to a separate document
await ctx.db.patch(user._id, {
name: args.name,
avatarUrl: args.avatarUrl,
});
await ctx.db.patch(presence._id, {
lastSeen: Date.now(),
});
```
## 5. Match Consistency To Read Patterns
Choose read strategy based on traffic shape.
### High-read, low-write
Examples:
- public browse pages
- search results
- landing pages
- directory listings
Prefer:
- point-in-time reads where appropriate
- explicit refresh
- local state for pagination
- caching where appropriate
Do not treat subscriptions as automatically wrong here. Prefer point-in-time reads only when the product does not need live freshness and the reactive cost is material. See `subscription-cost.md` for detailed patterns.
### High-read, high-write
Examples:
- collaborative editors
- live dashboards
- presence-heavy views
Reactive queries may be worth the ongoing cost.
## Convex-Specific Notes
### Reactive queries
Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set for the query.
On the client:
- `useQuery` creates a live subscription
- `usePaginatedQuery` creates a live subscription per page
For low-freshness flows, consider a point-in-time read instead of a live subscription only when the product does not need updates pushed automatically.
### Point-in-time reads
Framework helpers, server-rendered fetches, or one-shot client reads can avoid ongoing subscription cost when live updates are not useful.
Use them for:
- aggregate snapshots
- reports
- low-churn listings
- pages where explicit refresh is fine
### Triggers and fan-out
Triggers fire on every write, including writes that did not materially change the document.
When a write exists only to keep derived state in sync:
- diff before patching
- move expensive non-blocking work to `ctx.scheduler.runAfter` when appropriate
### Aggregates
Reactive global counts invalidate frequently on busy tables.
Prefer:
- one-shot aggregate fetches
- periodic recomputation
- precomputed summary rows
for global stats that do not need live updates every second.
### Backfills
For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs or the migrations component.
Deploy code that can handle both states before running the backfill.
During the gap:
- writes should populate the new shape
- reads should fall back safely
## Verification
Before closing the audit, confirm:
1. Same results as before, no dropped records
2. The removed table or lookup is no longer in the hot-path read set
3. Tests or validation cover fallback behavior
4. Migration safety is preserved while fields or indexes are unbackfilled
5. Sibling functions were fixed consistently
@@ -0,0 +1,114 @@
# OCC Conflict Resolution
Use these rules when insights, logs, or dashboard health show OCC (Optimistic Concurrency Control) conflicts, mutation retries, or write contention on hot tables.
## Core Principle
Convex uses optimistic concurrency control. When two transactions read or write overlapping data, one succeeds and the other retries automatically. High contention means wasted work and increased latency.
## Symptoms
- OCC conflict errors in deployment logs or health page
- Mutations retrying multiple times before succeeding
- User-visible latency spikes on write-heavy pages
- `npx convex insights --details` showing high conflict rates
## Common Causes
### Hot documents
Multiple mutations writing to the same document concurrently. Classic examples: a global counter, a shared settings row, or a "last updated" timestamp on a parent record.
### Broad read sets causing false conflicts
A query that scans a large table range creates a broad read set. If any write touches that range, the query's transaction conflicts even if the specific document the query cared about was not modified.
### Fan-out from triggers or cascading writes
A single user action triggers multiple mutations that all touch related documents. Each mutation competes with the others.
Database triggers (e.g. from `convex-helpers`) run inside the same transaction as the mutation that caused them. If a trigger does heavy work, reads extra tables, or writes to many documents, it extends the transaction's read/write set and increases the window for conflicts. Keep trigger logic minimal, or move expensive derived work to a scheduled function.
### Write-then-read chains
A mutation writes a document, then a reactive query re-reads it, then another mutation writes it again. Under load, these chains stack up.
## Fix Order
### 1. Reduce read set size
Narrower reads mean fewer false conflicts.
```ts
// Bad: broad scan creates a wide conflict surface
const allTasks = await ctx.db.query("tasks").collect();
const mine = allTasks.filter((t) => t.ownerId === userId);
```
```ts
// Good: indexed query touches only relevant documents
const mine = await ctx.db
.query("tasks")
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.collect();
```
### 2. Split hot documents
When many writers target the same document, split the contention point.
```ts
// Bad: every vote increments the same counter document
const counter = await ctx.db.get(pollCounterId);
await ctx.db.patch(pollCounterId, { count: counter!.count + 1 });
```
```ts
// Good: shard the counter across multiple documents, aggregate on read
const shardIndex = Math.floor(Math.random() * SHARD_COUNT);
const shardId = shardIds[shardIndex];
const shard = await ctx.db.get(shardId);
await ctx.db.patch(shardId, { count: shard!.count + 1 });
```
Aggregate the shards in a query or scheduled job when you need the total.
### 3. Move non-critical work to scheduled functions
If a mutation does primary work plus secondary bookkeeping (analytics, non-critical notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set.
```ts
// Bad: canonical write and derived work happen in the same transaction
await ctx.db.patch(userId, { name: args.name });
await ctx.db.insert("userUpdateAnalytics", {
userId,
kind: "name_changed",
name: args.name,
});
```
```ts
// Good: keep the primary write small, defer the analytics work
await ctx.db.patch(userId, { name: args.name });
await ctx.scheduler.runAfter(0, internal.users.recordNameChangeAnalytics, {
userId,
name: args.name,
});
```
### 4. Combine competing writes
If two mutations must update the same document atomically, consider whether they can be combined into a single mutation call from the client, reducing round trips and conflict windows.
Do not introduce artificial locks or queues unless the above steps have been tried first.
## Related: Invalidation Scope
Splitting hot documents also reduces subscription invalidation, not just OCC contention. If a document is written frequently and read by many queries, those queries re-run on every write even when the fields they care about have not changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated fields") for that pattern.
## Verification
1. OCC conflict rate has dropped in insights or dashboard
2. Mutation latency is lower and more consistent
3. No data correctness regressions from splitting or scheduling changes
4. Sibling writers to the same hot documents were fixed consistently
@@ -0,0 +1,252 @@
# Subscription Cost
Use these rules when the problem is too many reactive subscriptions, queries invalidating too frequently, or React components re-rendering excessively due to Convex state changes.
## Core Principle
Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The server tracks the query's read set and re-executes the query whenever any document in that read set changes. Subscription cost scales with:
`subscriptions x invalidation_frequency x query_cost`
Subscriptions are not inherently bad. Convex reactivity is often the right default. The goal is to reduce unnecessary invalidation work, not to eliminate subscriptions on principle.
## Symptoms
- Dashboard shows high active subscription count
- UI feels sluggish or laggy despite fast individual queries
- React profiling shows frequent re-renders from Convex state
- Pages with many components each running their own `useQuery`
- Paginated lists where every loaded page stays subscribed
## Common Causes
### Reactive queries on low-freshness flows
Some user flows are read-heavy and do not need live updates every time the underlying data changes. In those cases, ongoing subscriptions may cost more than they are worth.
### Overly broad queries
A query that returns a large result set invalidates whenever any document in that set changes. The broader the query, the more frequent the invalidation.
### Too many subscriptions per page
A page with 20 list items, each running its own `useQuery` to fetch related data, creates 20+ subscriptions per visitor.
### Paginated queries keeping all pages live
`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a page where a user has scrolled through 10 pages, all 10 stay reactive.
### Frequently-updated fields on widely-read documents
A document that many queries touch gets a frequently-updated field (like `lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates every subscription that reads the document, even if those subscriptions never use the field. This is different from OCC conflicts (see `occ-conflicts.md`), which are write-vs-write contention. This is write-vs-subscription: the write succeeds fine, but it forces hundreds of queries to re-run for no reason.
## Fix Order
### 1. Use point-in-time reads when live updates are not valuable
Keep `useQuery` and `usePaginatedQuery` by default when the product benefits from fresh live data.
Consider a point-in-time read instead when all of these are true:
- the flow is high-read
- the underlying data changes less often than users need to see
- explicit refresh, periodic refresh, or a fresh read on navigation is acceptable
Possible implementations depend on environment:
- a server-rendered fetch
- a framework helper like `fetchQuery`
- a point-in-time client read such as `ConvexHttpClient.query()`
```ts
// Reactive by default when fresh live data matters
function TeamPresence() {
const presence = useQuery(api.teams.livePresence, { teamId });
return <PresenceList users={presence} />;
}
```
```ts
// Point-in-time read when explicit refresh is acceptable
import { ConvexHttpClient } from "convex/browser";
const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL);
function SnapshotView() {
const [items, setItems] = useState<Item[]>([]);
useEffect(() => {
client.query(api.items.snapshot).then(setItems);
}, []);
return <ItemGrid items={items} />;
}
```
Good candidates for point-in-time reads:
- aggregate snapshots
- reports
- low-churn listings
- flows where explicit refresh is already acceptable
Keep reactive for:
- collaborative editing
- live dashboards
- presence-heavy views
- any surface where users expect fresh changes to appear automatically
### 2. Batch related data into fewer queries
Instead of N components each fetching their own related data, fetch it in a single query.
```ts
// Bad: each card fetches its own author
function ProjectCard({ project }: { project: Project }) {
const author = useQuery(api.users.get, { id: project.authorId });
return <Card title={project.name} author={author?.name} />;
}
```
```ts
// Good: parent query returns projects with author names included
function ProjectList() {
const projects = useQuery(api.projects.listWithAuthors);
return projects?.map((p) => (
<Card key={p._id} title={p.name} author={p.authorName} />
));
}
```
This can use denormalized fields or server-side joins in the query handler. Either way, it is one subscription instead of N.
This is not automatically better. If the combined query becomes much broader and invalidates much more often, several narrower subscriptions may be the better tradeoff. Optimize for total invalidation cost, not raw subscription count.
### 3. Use skip to avoid unnecessary subscriptions
The `"skip"` value prevents a subscription from being created when the arguments are not ready.
```ts
// Bad: subscribes with undefined args, wastes a subscription slot
const profile = useQuery(api.users.getProfile, { userId: selectedId! });
```
```ts
// Good: skip when there is nothing to fetch
const profile = useQuery(
api.users.getProfile,
selectedId ? { userId: selectedId } : "skip",
);
```
### 4. Isolate frequently-updated fields into separate documents
If a document is widely read but has a field that changes often, move that field to a separate document. Queries that do not need the field will no longer be invalidated by its writes.
```ts
// Bad: lastSeen lives on the user doc, every heartbeat invalidates
// every query that reads this user
const users = defineTable({
name: v.string(),
email: v.string(),
lastSeen: v.number(),
});
```
```ts
// Good: lastSeen lives in a separate heartbeat doc
const users = defineTable({
name: v.string(),
email: v.string(),
heartbeatId: v.id("heartbeats"),
});
const heartbeats = defineTable({
lastSeen: v.number(),
});
```
Queries that only need `name` and `email` no longer re-run on every heartbeat. Queries that actually need online status fetch the heartbeat document explicitly.
For an even further optimization, if you only need a coarse online/offline boolean rather than the exact `lastSeen` timestamp, add a separate presence document with an `isOnline` flag. Update it immediately when a user comes online, and use a cron to batch-mark users offline when their heartbeat goes stale. This way the presence query only invalidates when online status actually changes, not on every heartbeat.
### 5. Use the aggregate component for counts and sums
Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert or delete to the table. The [`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate) component maintains denormalized COUNT, SUM, and MAX values efficiently so you do not need a reactive query scanning the full table.
Use it for leaderboards, totals, "X items" badges, or any stat that would otherwise require scanning many rows reactively.
If the aggregate component is not appropriate, prefer point-in-time reads for global stats, or precomputed summary rows updated by a cron or trigger, over reactive queries that scan large tables.
### 6. Narrow query read sets
Queries that return less data and touch fewer documents invalidate less often.
```ts
// Bad: returns all fields, invalidates on any field change
export const list = query({
handler: async (ctx) => {
return await ctx.db.query("projects").collect();
},
});
```
```ts
// Good: use a digest table with only the fields the list needs
export const listDigests = query({
handler: async (ctx) => {
return await ctx.db.query("projectDigests").collect();
},
});
```
Writes to fields not in the digest table do not invalidate the digest query.
### 7. Remove `Date.now()` from queries
Using `Date.now()` inside a query defeats Convex's query cache. The cache is invalidated frequently to avoid showing stale time-dependent results, which increases database work even when the underlying data has not changed.
```ts
// Bad: Date.now() defeats query caching and causes frequent re-evaluation
const releasedPosts = await ctx.db
.query("posts")
.withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now()))
.take(100);
```
```ts
// Good: use a boolean field updated by a scheduled function
const releasedPosts = await ctx.db
.query("posts")
.withIndex("by_is_released", (q) => q.eq("isReleased", true))
.take(100);
```
If the query must compare against a time value, pass it as an explicit argument from the client and round it to a coarse interval (e.g. the most recent minute) so requests within that window share the same cache entry.
### 8. Consider pagination strategy
For long lists where users scroll through many pages:
- If the data does not need live updates, use point-in-time fetching with manual "load more"
- If it does need live updates, accept the subscription cost but limit the number of loaded pages
- Consider whether older pages can be unloaded as the user scrolls forward
### 9. Separate backend cost from UI churn
If the main problem is loading flash or UI churn when query arguments change, stabilizing the reactive UI behavior may be better than replacing reactivity altogether.
Treat this as a UX problem first when:
- the underlying query is already reasonably cheap
- the complaint is flicker, loading flashes, or re-render churn
- live updates are still desirable once fresh data arrives
## Verification
1. Subscription count in dashboard is lower for the affected pages
2. UI responsiveness has improved
3. React profiling shows fewer unnecessary re-renders
4. Surfaces that do not need live updates are not paying for persistent subscriptions unnecessarily
5. Sibling pages with similar patterns were updated consistently
+347
View File
@@ -0,0 +1,347 @@
---
name: convex-quickstart
description: Creates or adds Convex to an app. Use for new Convex projects, npm create convex@latest, frontend setup, env vars, or the first npx convex dev run.
---
# Convex Quickstart
Set up a working Convex project as fast as possible.
## When to Use
- Starting a brand new project with Convex
- Adding Convex to an existing React, Next.js, Vue, Svelte, or other app
- Scaffolding a Convex app for prototyping
## When Not to Use
- The project already has Convex installed and `convex/` exists - just start building
- You only need to add auth to an existing Convex app - use the `convex-setup-auth` skill
## Workflow
1. Determine the starting point: new project or existing app
2. If new project, pick a template and scaffold with `npm create convex@latest`
3. If existing app, install `convex` and wire up the provider
4. Run `npx convex dev` to connect a deployment and start the dev loop
5. Verify the setup works
## Path 1: New Project (Recommended)
Use the official scaffolding tool. It creates a complete project with the frontend framework, Convex backend, and all config wired together.
### Pick a template
| Template | Stack |
| -------------------------- | ----------------------------------------- |
| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui |
| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui |
| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui |
| `nextjs-clerk` | Next.js + Clerk auth |
| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui |
| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui |
| `bare` | Convex backend only, no frontend |
If the user has not specified a preference, default to `react-vite-shadcn` for simple apps or `nextjs-shadcn` for apps that need SSR or API routes.
You can also use any GitHub repo as a template:
```bash
npm create convex@latest my-app -- -t owner/repo
npm create convex@latest my-app -- -t owner/repo#branch
```
### Scaffold the project
Always pass the project name and template flag to avoid interactive prompts:
```bash
npm create convex@latest my-app -- -t react-vite-shadcn
cd my-app
npm install
```
The scaffolding tool creates files but does not run `npm install`, so you must run it yourself.
To scaffold in the current directory (if it is empty):
```bash
npm create convex@latest . -- -t react-vite-shadcn
npm install
```
### Start the dev loop
`npx convex dev` is a long-running watcher process that syncs backend code to a Convex deployment on every save. It also requires authentication on first run (browser-based OAuth). Both of these make it unsuitable for an agent to run directly.
**Ask the user to run this themselves:**
Tell the user to run `npx convex dev` in their terminal. On first run it will prompt them to log in or develop anonymously. Once running, it will:
- Create a Convex project and dev deployment
- Write the deployment URL to `.env.local`
- Create the `convex/` directory with generated types
- Watch for changes and sync continuously
The user should keep `npx convex dev` running in the background while you work on code. The watcher will automatically pick up any files you create or edit in `convex/`.
**Exception - cloud or headless agents:** Environments that cannot open a browser for interactive login should use Agent Mode (see below) to run anonymously without user interaction.
### Start the frontend
The user should also run the frontend dev server in a separate terminal:
```bash
npm run dev
```
Vite apps serve on `http://localhost:5173`, Next.js on `http://localhost:3000`.
### What you get
After scaffolding, the project structure looks like:
```
my-app/
convex/ # Backend functions and schema
_generated/ # Auto-generated types (check this into git)
schema.ts # Database schema (if template includes one)
src/ # Frontend code (or app/ for Next.js)
package.json
.env.local # CONVEX_URL / VITE_CONVEX_URL / NEXT_PUBLIC_CONVEX_URL
```
The template already has:
- `ConvexProvider` wired into the app root
- Correct env var names for the framework
- Tailwind and shadcn/ui ready (for shadcn templates)
- Auth provider configured (for auth templates)
Proceed to adding schema, functions, and UI.
## Path 2: Add Convex to an Existing App
Use this when the user already has a frontend project and wants to add Convex as the backend.
### Install
```bash
npm install convex
```
### Initialize and start dev loop
Ask the user to run `npx convex dev` in their terminal. This handles login, creates the `convex/` directory, writes the deployment URL to `.env.local`, and starts the file watcher. See the notes in Path 1 about why the agent should not run this directly.
### Wire up the provider
The Convex client must wrap the app at the root. The setup varies by framework.
Create the `ConvexReactClient` at module scope, not inside a component:
```tsx
// Bad: re-creates the client on every render
function App() {
const convex = new ConvexReactClient(
import.meta.env.VITE_CONVEX_URL as string,
);
return <ConvexProvider client={convex}>...</ConvexProvider>;
}
// Good: created once at module scope
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
function App() {
return <ConvexProvider client={convex}>...</ConvexProvider>;
}
```
#### React (Vite)
```tsx
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import App from "./App";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ConvexProvider client={convex}>
<App />
</ConvexProvider>
</StrictMode>,
);
```
#### Next.js (App Router)
```tsx
// app/ConvexClientProvider.tsx
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}
```
```tsx
// app/layout.tsx
import { ConvexClientProvider } from "./ConvexClientProvider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}
```
#### Other frameworks
For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the matching quickstart guide:
- [Vue](https://docs.convex.dev/quickstart/vue)
- [Svelte](https://docs.convex.dev/quickstart/svelte)
- [React Native](https://docs.convex.dev/quickstart/react-native)
- [TanStack Start](https://docs.convex.dev/quickstart/tanstack-start)
- [Remix](https://docs.convex.dev/quickstart/remix)
- [Node.js (no frontend)](https://docs.convex.dev/quickstart/nodejs)
### Environment variables
The env var name depends on the framework:
| Framework | Variable |
| ------------ | ------------------------ |
| Vite | `VITE_CONVEX_URL` |
| Next.js | `NEXT_PUBLIC_CONVEX_URL` |
| Remix | `CONVEX_URL` |
| React Native | `EXPO_PUBLIC_CONVEX_URL` |
`npx convex dev` writes the correct variable to `.env.local` automatically.
## Agent Mode (Cloud and Headless Agents)
When running in a cloud or headless agent environment where interactive browser login is not possible, set `CONVEX_AGENT_MODE=anonymous` to use a local anonymous deployment.
Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline:
```bash
CONVEX_AGENT_MODE=anonymous npx convex dev
```
This runs a local Convex backend on the VM without requiring authentication, and avoids conflicting with the user's personal dev deployment.
## Verify the Setup
After setup, confirm everything is working:
1. The user confirms `npx convex dev` is running without errors
2. The `convex/_generated/` directory exists and has `api.ts` and `server.ts`
3. `.env.local` contains the deployment URL
## Writing Your First Function
Once the project is set up, create a schema and a query to verify the full loop works.
`convex/schema.ts`:
```ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
tasks: defineTable({
text: v.string(),
completed: v.boolean(),
}),
});
```
`convex/tasks.ts`:
```ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("tasks").collect();
},
});
export const create = mutation({
args: { text: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert("tasks", { text: args.text, completed: false });
},
});
```
Use in a React component (adjust the import path based on your file location relative to `convex/`):
```tsx
import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
function Tasks() {
const tasks = useQuery(api.tasks.list);
const create = useMutation(api.tasks.create);
return (
<div>
<button onClick={() => create({ text: "New task" })}>Add</button>
{tasks?.map((t) => (
<div key={t._id}>{t.text}</div>
))}
</div>
);
}
```
## Development vs Production
Always use `npx convex dev` during development. It runs against your personal dev deployment and syncs code on save.
When ready to ship, deploy to production:
```bash
npx convex deploy
```
This pushes to the production deployment, which is separate from dev. Do not use `deploy` during development.
## Next Steps
- Add authentication: use the `convex-setup-auth` skill
- Design your schema: see [Schema docs](https://docs.convex.dev/database/schemas)
- Build components: use the `convex-create-component` skill
- Plan a migration: use the `convex-migration-helper` skill
- Add file storage: see [File Storage docs](https://docs.convex.dev/file-storage)
- Set up cron jobs: see [Scheduling docs](https://docs.convex.dev/scheduling)
## Checklist
- [ ] Determined starting point: new project or existing app
- [ ] If new project: scaffolded with `npm create convex@latest` using appropriate template
- [ ] If existing app: installed `convex` and wired up the provider
- [ ] User has `npx convex dev` running and connected to a deployment
- [ ] `convex/_generated/` directory exists with types
- [ ] `.env.local` has the deployment URL
- [ ] Verified a basic query/mutation round-trip works
@@ -0,0 +1,10 @@
interface:
display_name: "Convex Quickstart"
short_description: "Start a new Convex app or add Convex to an existing frontend."
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#F97316"
default_prompt: "Set up Convex for this project as fast as possible. First decide whether this is a new app or an existing app, then scaffold or integrate Convex and verify the setup works."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

+150
View File
@@ -0,0 +1,150 @@
---
name: convex-setup-auth
description: Sets up Convex auth, identity mapping, and access control. Use for login, auth providers, users tables, protected functions, or roles in a Convex app.
---
# Convex Authentication Setup
Implement secure authentication in Convex with user management and access control.
## When to Use
- Setting up authentication for the first time
- Implementing user management (users table, identity mapping)
- Creating authentication helper functions
- Setting up auth providers (Convex Auth, Clerk, WorkOS AuthKit, Auth0, custom JWT)
## When Not to Use
- Auth for a non-Convex backend
- Pure OAuth/OIDC documentation without a Convex implementation
- Debugging unrelated bugs that happen to surface near auth code
- The auth provider is already fully configured and the user only needs a one-line fix
## First Step: Choose the Auth Provider
Convex supports multiple authentication approaches. Do not assume a provider.
Before writing setup code:
1. Ask the user which auth solution they want, unless the repository already makes it obvious
2. If the repo already uses a provider, continue with that provider unless the user wants to switch
3. If the user has not chosen a provider and the repo does not make it obvious, ask before proceeding
Common options:
- [Convex Auth](https://docs.convex.dev/auth/convex-auth) - good default when the user wants auth handled directly in Convex
- [Clerk](https://docs.convex.dev/auth/clerk) - use when the app already uses Clerk or the user wants Clerk's hosted auth features
- [WorkOS AuthKit](https://docs.convex.dev/auth/authkit/) - use when the app already uses WorkOS or the user wants AuthKit specifically
- [Auth0](https://docs.convex.dev/auth/auth0) - use when the app already uses Auth0
- Custom JWT provider - use when integrating an existing auth system not covered above
Look for signals in the repo before asking:
- Dependencies such as `@clerk/*`, `@workos-inc/*`, `@auth0/*`, or Convex Auth packages
- Existing files such as `convex/auth.config.ts`, auth middleware, provider wrappers, or login components
- Environment variables that clearly point at a provider
## After Choosing a Provider
Read the provider's official guide and the matching local reference file:
- Convex Auth: [official docs](https://docs.convex.dev/auth/convex-auth), then `references/convex-auth.md`
- Clerk: [official docs](https://docs.convex.dev/auth/clerk), then `references/clerk.md`
- WorkOS AuthKit: [official docs](https://docs.convex.dev/auth/authkit/), then `references/workos-authkit.md`
- Auth0: [official docs](https://docs.convex.dev/auth/auth0), then `references/auth0.md`
The local reference files contain the concrete workflow, expected files and env vars, gotchas, and validation checks.
Use those sources for:
- package installation
- client provider wiring
- environment variables
- `convex/auth.config.ts` setup
- login and logout UI patterns
- framework-specific setup for React, Vite, or Next.js
For shared auth behavior, use the official Convex docs as the source of truth:
- [Auth in Functions](https://docs.convex.dev/auth/functions-auth) for `ctx.auth.getUserIdentity()`
- [Storing Users in the Convex Database](https://docs.convex.dev/auth/database-auth) for optional app-level user storage
- [Authentication](https://docs.convex.dev/auth) for general auth and authorization guidance
- [Convex Auth Authorization](https://labs.convex.dev/auth/authz) when the provider is Convex Auth
Prefer official docs over recalled steps, because provider CLIs and Convex Auth internals change between versions. Inventing setup from memory risks outdated patterns.
For third-party providers, only add app-level user storage if the app actually needs user documents in Convex. Not every app needs a `users` table.
For Convex Auth, follow the Convex Auth docs and built-in auth tables rather than adding a parallel `users` table plus `storeUser` flow, because Convex Auth already manages user records internally.
After running provider initialization commands, verify generated files and complete the post-init wiring steps the provider reference calls out. Initialization commands rarely finish the entire integration.
## Core Pattern: Protecting Backend Functions
The most common auth task is checking identity in Convex functions.
```ts
// Bad: trusting a client-provided userId
export const getMyProfile = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
return await ctx.db.get(args.userId);
},
});
```
```ts
// Good: verifying identity server-side
export const getMyProfile = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
return await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier),
)
.unique();
},
});
```
## Workflow
1. Determine the provider, either by asking the user or inferring from the repo
2. Ask whether the user wants local-only setup or production-ready setup now
3. Read the matching provider reference file
4. Follow the official provider docs for current setup details
5. Follow the official Convex docs for shared backend auth behavior, user storage, and authorization patterns
6. Only add app-level user storage if the docs and app requirements call for it
7. Add authorization checks for ownership, roles, or team access only where the app needs them
8. Verify login state, protected queries, environment variables, and production configuration if requested
If the flow blocks on interactive provider or deployment setup, ask the user explicitly for the exact human step needed, then continue after they complete it.
For UI-facing auth flows, offer to validate the real sign-up or sign-in flow after setup is done.
If the environment has browser automation tools, you can use them.
If it does not, give the user a short manual validation checklist instead.
## Reference Files
### Provider References
- `references/convex-auth.md`
- `references/clerk.md`
- `references/workos-authkit.md`
- `references/auth0.md`
## Checklist
- [ ] Chosen the correct auth provider before writing setup code
- [ ] Read the relevant provider reference file
- [ ] Asked whether the user wants local-only setup or production-ready setup
- [ ] Used the official provider docs for provider-specific wiring
- [ ] Used the official Convex docs for shared auth behavior and authorization patterns
- [ ] Only added app-level user storage if the app actually needs it
- [ ] Did not invent a cross-provider `users` table or `storeUser` flow for Convex Auth
- [ ] Added authentication checks in protected backend functions
- [ ] Added authorization checks where the app actually needs them
- [ ] Clear error messages ("Not authenticated", "Unauthorized")
- [ ] Client auth provider configured for the chosen provider
- [ ] If requested, production auth setup is covered too
@@ -0,0 +1,10 @@
interface:
display_name: "Convex Setup Auth"
short_description: "Set up Convex auth, user identity mapping, and access control."
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Set up authentication for this Convex app. Figure out the provider first, then wire up the user model, identity mapping, and access control with the smallest solid implementation."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 394 B

@@ -0,0 +1,116 @@
# Auth0
Official docs:
- https://docs.convex.dev/auth/auth0
- https://auth0.github.io/auth0-cli/
- https://auth0.github.io/auth0-cli/auth0_apps_create.html
Use this when the app already uses Auth0 or the user wants Auth0 specifically.
## Workflow
1. Confirm the user wants Auth0
2. Determine the app framework and whether Auth0 is already partly set up
3. Ask whether the user wants local-only setup or production-ready setup now
4. Read the official Convex and Auth0 guides before making changes
5. Ask whether they want the fastest setup path by installing the Auth0 CLI
6. If they agree, install the Auth0 CLI and do as much of the Auth0 app setup as possible through the CLI
7. If they do not want the CLI path, use the Auth0 dashboard path instead
8. Complete the relevant Auth0 frontend quickstart if the app does not already have Auth0 wired up
9. Configure `convex/auth.config.ts` with the Auth0 domain and client ID
10. Set environment variables for local and production environments
11. Wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0`
12. Gate Convex-backed UI with Convex auth state
13. Try to verify Convex reports the user as authenticated after Auth0 login
14. If the refresh-token path fails, stop improvising and send the user back to the official docs
15. If the user wants production-ready setup, make sure the production Auth0 tenant and env vars are also covered
## What To Do
- Read the official Convex and Auth0 guide before writing setup code
- Prefer the Auth0 CLI path for mechanical setup if the user is willing to install it, but do not present it as a fully validated end-to-end path yet
- Ask the user directly: "The fastest path is to install the Auth0 CLI so I can do more of this for you. If you want, I can install it and then only ask you to log in when needed. Would you like me to do that?"
- Make sure the app has already completed the relevant Auth0 quickstart for its frontend
- Use the official examples for `Auth0Provider` and `ConvexProviderWithAuth0`
- If the Auth0 login or refresh flow starts failing in a way that is not clearly explained by the docs, say that plainly and fall back to the official docs instead of pretending the flow is validated
## Key Setup Areas
- install the Auth0 SDK for the app's framework
- configure `convex/auth.config.ts` with the Auth0 domain and client ID
- set environment variables for local and production environments
- wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0`
- use Convex auth state when gating Convex-backed UI
## Files and Env Vars To Expect
- `convex/auth.config.ts`
- frontend app entry or provider wrapper
- Auth0 CLI install docs: `https://auth0.github.io/auth0-cli/`
- Auth0 environment variables commonly include:
- `AUTH0_DOMAIN`
- `AUTH0_CLIENT_ID`
- `VITE_AUTH0_DOMAIN`
- `VITE_AUTH0_CLIENT_ID`
## Concrete Steps
1. Start by reading `https://docs.convex.dev/auth/auth0` and the relevant Auth0 quickstart for the app's framework
2. Ask whether the user wants the Auth0 CLI path
3. If yes, install Auth0 CLI and have the user authenticate it with `auth0 login`
4. Use `auth0 apps create` with SPA settings, callback URL, logout URL, and web origins if creating a new app
5. If not using the CLI path, complete the relevant Auth0 frontend quickstart and create the Auth0 app in the dashboard
6. Get the Auth0 domain and client ID from the CLI output or the Auth0 dashboard
7. Install the Auth0 SDK for the app's framework
8. Create or update `convex/auth.config.ts` with the Auth0 domain and client ID
9. Set frontend and backend environment variables
10. Wrap the app in `Auth0Provider`
11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithAuth0`
12. Run the normal Convex dev or deploy flow after backend config changes
13. Try the official provider config shown in the Convex docs
14. If login works but Convex auth or token refresh fails in a way you cannot clearly resolve, stop and tell the user to follow the official docs manually for now
15. Only claim success if the user can sign in and Convex recognizes the authenticated session
16. If the user wants production-ready setup, configure the production Auth0 tenant values and production environment variables too
## Gotchas
- The Convex docs assume the Auth0 side is already set up, so do not skip the Auth0 quickstart if the app is starting from scratch
- The Auth0 CLI is often the fastest path for a fresh setup, but it still requires the user to authenticate the CLI to their Auth0 tenant
- If the user agrees to install the Auth0 CLI, do the mechanical setup yourself instead of bouncing them through the dashboard
- If login succeeds but Convex still reports unauthenticated, double-check `convex/auth.config.ts` and whether the backend config was synced
- We were able to automate Auth0 app creation and Convex config wiring, but we did not fully validate the refresh-token path end to end
- In validation, the documented `useRefreshTokens={true}` and `cacheLocation="localstorage"` setup hit refresh-token failures, so do not present that path as settled
- If you hit Auth0 errors like `Unknown or invalid refresh token`, do not keep inventing fixes indefinitely, send the user back to the official docs and explain that this path is still under investigation
- Keep dev and prod tenants separate if the project uses different Auth0 environments
- Do not confuse "Auth0 login works" with "Convex can validate the Auth0 token". Both need to work.
- If the repo already uses Auth0, preserve existing redirect and tenant configuration unless the user asked to change it.
- Do not assume the local Auth0 tenant settings match production. Verify the production domain, client ID, and callback URLs separately.
- For local dev, make sure the Auth0 app settings match the app's real local port for callback URLs, logout URLs, and web origins
## Production
- Ask whether the user wants dev-only setup or production-ready setup
- If the answer is production-ready, make sure the production Auth0 tenant values, callback URLs, and Convex deployment config are all covered
- Verify production environment variables and redirect settings before calling the task complete
- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly.
## Validation
- Verify the user can complete the Auth0 login flow
- Verify Convex-authenticated UI renders only after Convex auth state is ready
- Verify protected Convex queries succeed after login
- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions
- Verify the Auth0 app settings match the real local callback and logout URLs during development
- If the Auth0 refresh-token path fails, mark the setup as not fully validated and direct the user to the official docs instead of claiming the skill completed successfully
- If production-ready setup was requested, verify the production Auth0 configuration is also covered
## Checklist
- [ ] Confirm the user wants Auth0
- [ ] Ask whether the user wants local-only setup or production-ready setup
- [ ] Complete the relevant Auth0 frontend setup
- [ ] Configure `convex/auth.config.ts`
- [ ] Set environment variables
- [ ] Verify Convex authenticated state after login, or explicitly tell the user this path is still under investigation and send them to the official docs
- [ ] If requested, configure the production deployment too
@@ -0,0 +1,113 @@
# Clerk
Official docs:
- https://docs.convex.dev/auth/clerk
- https://clerk.com/docs/guides/development/integrations/databases/convex
Use this when the app already uses Clerk or the user wants Clerk's hosted auth features.
## Workflow
1. Confirm the user wants Clerk
2. Make sure the user has a Clerk account and a Clerk application
3. Determine the app framework:
- React
- Next.js
- TanStack Start
4. Ask whether the user wants local-only setup or production-ready setup now
5. Gather the Clerk keys and the Clerk Frontend API URL
6. Follow the correct framework section in the official docs
7. Complete the backend and client wiring
8. Verify Convex reports the user as authenticated after login
9. If the user wants production-ready setup, make sure the production Clerk config is also covered
## What To Do
- Read the official Convex and Clerk guide before writing setup code
- If the user does not already have Clerk set up, send them to `https://dashboard.clerk.com/sign-up` to create an account and `https://dashboard.clerk.com/apps/new` to create an application
- Send the user to `https://dashboard.clerk.com/apps/setup/convex` if the Convex integration is not already active
- Match the guide to the app's framework, usually React, Next.js, or TanStack Start
- Use the official examples for `ConvexProviderWithClerk`, `ClerkProvider`, and `useAuth`
## Key Setup Areas
- install the Clerk SDK for the framework in use
- configure `convex/auth.config.ts` with the Clerk issuer domain
- set the required Clerk environment variables
- wrap the app with `ClerkProvider` and `ConvexProviderWithClerk`
- use Convex auth-aware UI patterns such as `Authenticated`, `Unauthenticated`, and `AuthLoading`
## Files and Env Vars To Expect
- `convex/auth.config.ts`
- React or Vite client entry such as `src/main.tsx`
- Next.js client wrapper for Convex if using App Router
- Clerk account sign-up page: `https://dashboard.clerk.com/sign-up`
- Clerk app creation page: `https://dashboard.clerk.com/apps/new`
- Clerk Convex integration page: `https://dashboard.clerk.com/apps/setup/convex`
- Clerk API keys page: `https://dashboard.clerk.com/last-active?path=api-keys`
- Clerk environment variables:
- `CLERK_JWT_ISSUER_DOMAIN` for Convex backend validation in the Convex docs
- `CLERK_FRONTEND_API_URL` in the Clerk docs
- `VITE_CLERK_PUBLISHABLE_KEY` for Vite apps
- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js apps
- `CLERK_SECRET_KEY` for Next.js server-side Clerk setup where required
`CLERK_JWT_ISSUER_DOMAIN` and `CLERK_FRONTEND_API_URL` refer to the same Clerk Frontend API URL value. Do not treat them as two different URLs.
## Concrete Steps
1. If needed, create a Clerk account at `https://dashboard.clerk.com/sign-up`
2. If needed, create a Clerk application at `https://dashboard.clerk.com/apps/new`
3. Open `https://dashboard.clerk.com/last-active?path=api-keys` and copy the publishable key, plus the secret key for Next.js where needed
4. Open `https://dashboard.clerk.com/apps/setup/convex`
5. Activate the Convex integration in Clerk if it is not already active
6. Copy the Clerk Frontend API URL shown there
7. Install the Clerk package for the app's framework
8. Create or update `convex/auth.config.ts` so Convex validates Clerk tokens
9. Set the publishable key in the frontend environment
10. Set the issuer domain or Frontend API URL so Convex can validate the JWT
11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithClerk`
12. Wrap the app in `ClerkProvider`
13. Use Convex auth helpers for authenticated rendering
14. Run the normal Convex dev or deploy flow after updating backend auth config
15. If the user wants production-ready setup, configure the production Clerk values and production issuer domain too
## Gotchas
- Prefer `useConvexAuth()` over raw Clerk auth state when deciding whether Convex-authenticated UI can render
- For Next.js, keep server and client boundaries in mind when creating the Convex provider wrapper
- After changing `convex/auth.config.ts`, run the normal Convex dev or deploy flow so the backend picks up the new config
- Do not stop at "Clerk login works". The important check is that Convex also sees the session and can authenticate requests.
- If the repo already uses Clerk, preserve its existing auth flow unless the user asked to change it.
- Do not assume the same Clerk values work for both dev and production. Check the production issuer domain and publishable key separately.
- The Convex setup page is where you get the Clerk Frontend API URL for Convex. Keep using the Clerk API keys page for the publishable key and the secret key.
- If Convex says no auth provider matched the token, first confirm the Clerk Convex integration was activated at `https://dashboard.clerk.com/apps/setup/convex`
- After activating the Clerk Convex integration, sign out completely and sign back in before retesting. An old Clerk session can keep using a token that Convex rejects.
## Production
- Ask whether the user wants dev-only setup or production-ready setup
- If the answer is production-ready, make sure production Clerk keys and issuer configuration are included
- Verify production redirect URLs and any production Clerk domain values before calling the task complete
- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly.
## Validation
- Verify the user can sign in with Clerk
- If the Clerk integration was just activated, verify after a full Clerk sign-out and fresh sign-in
- Verify `useConvexAuth()` reaches the authenticated state after Clerk login
- Verify protected Convex queries run successfully inside authenticated UI
- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions
- If production-ready setup was requested, verify the production Clerk configuration is also covered
## Checklist
- [ ] Confirm the user wants Clerk
- [ ] Ask whether the user wants local-only setup or production-ready setup
- [ ] Follow the correct framework section in the official guide
- [ ] Set Clerk environment variables
- [ ] Configure `convex/auth.config.ts`
- [ ] Verify Convex authenticated state after login
- [ ] If requested, configure the production deployment too
@@ -0,0 +1,143 @@
# Convex Auth
Official docs: https://docs.convex.dev/auth/convex-auth
Setup guide: https://labs.convex.dev/auth/setup
Use this when the user wants auth handled directly in Convex rather than through a third-party provider.
## Workflow
1. Confirm the user wants Convex Auth specifically
2. Determine which sign-in methods the app needs:
- magic links or OTPs
- OAuth providers
- passwords and password reset
3. Ask whether the user wants local-only setup or production-ready setup now
4. Read the Convex Auth setup guide before writing code
5. Make sure the project has a configured Convex deployment:
- run `npx convex dev` first if `CONVEX_DEPLOYMENT` is not set
- if CLI configuration requires interactive human input, stop and ask the user to complete that step before continuing
6. Install the auth packages:
- `npm install @convex-dev/auth @auth/core@0.37.0`
7. Run the initialization command:
- `npx @convex-dev/auth`
8. Confirm the initializer created:
- `convex/auth.config.ts`
- `convex/auth.ts`
- `convex/http.ts`
9. Add the required `authTables` to `convex/schema.ts`
10. Replace plain `ConvexProvider` wiring with `ConvexAuthProvider`
11. Configure at least one auth method in `convex/auth.ts`
12. Run `npx convex dev --once` or the normal dev flow to push the updated schema and generated code
13. Verify the client can sign in successfully
14. Verify Convex receives authenticated identity in backend functions
15. If the user wants production-ready setup, make sure the same auth setup is configured for the production deployment as well
16. Only add a `users` table and `storeUser` flow if the app needs app-level user records inside Convex
## What This Reference Is For
- choosing Convex Auth as the default provider for a new Convex app
- understanding whether the app wants magic links, OTPs, OAuth, or passwords
- keeping the setup provider-specific while using the official Convex Auth docs for identity and authorization behavior
## What To Do
- Read the Convex Auth setup guide before writing setup code
- Follow the setup flow from the docs rather than recreating it from memory
- If the app is new, consider starting from the official starter flow instead of hand-wiring everything
- Treat `npx @convex-dev/auth` as a required initialization step for existing apps, not an optional extra
## Concrete Steps
1. Install `@convex-dev/auth` and `@auth/core@0.37.0`
2. Run `npx convex dev` if the project does not already have a configured deployment
3. If `npx convex dev` blocks on interactive setup, ask the user explicitly to finish configuring the Convex deployment
4. Run `npx @convex-dev/auth`
5. Confirm the generated auth setup is present before continuing:
- `convex/auth.config.ts`
- `convex/auth.ts`
- `convex/http.ts`
6. Add `authTables` to `convex/schema.ts`
7. Replace `ConvexProvider` with `ConvexAuthProvider` in the app entry
8. Configure the selected auth methods in `convex/auth.ts`
9. Run `npx convex dev --once` or the normal dev flow so the updated schema and auth files are pushed
10. Verify login locally
11. If the user wants production-ready setup, repeat the required auth configuration against the production deployment
## Expected Files and Decisions
- `convex/schema.ts`
- frontend app entry such as `src/main.tsx` or the framework-equivalent provider file
- generated Convex Auth setup produced by `npx @convex-dev/auth`
- an existing configured Convex deployment, or the ability to create one with `npx convex dev`
- `convex/auth.ts` starts with `providers: []` until the app configures actual sign-in methods
- Decide whether the user is creating a new app or adding auth to an existing app
- For a new app, prefer the official starter flow instead of rebuilding setup by hand
- Decide which auth methods the app needs:
- magic links or OTPs
- OAuth providers
- passwords
- Decide whether the user wants local-only setup or production-ready setup now
- Decide whether the app actually needs a `users` table inside Convex, or whether provider identity alone is enough
## Gotchas
- Do not assume a specific sign-in method. Ask which methods the app needs before wiring UI and backend behavior.
- `npx @convex-dev/auth` is important because it initializes the auth setup, including the key material. Do not skip it when adding Convex Auth to an existing project.
- `npx @convex-dev/auth` will fail if the project does not already have a configured `CONVEX_DEPLOYMENT`.
- `npx convex dev` may require interactive setup for deployment creation or project selection. If that happens, ask the user explicitly for that human step instead of guessing.
- `npx @convex-dev/auth` does not finish the whole integration by itself. You still need to add `authTables`, swap in `ConvexAuthProvider`, and configure at least one auth method.
- A project can still build even if `convex/auth.ts` still has `providers: []`, so do not treat a successful build as proof that sign-in is fully configured.
- Convex Auth does not mean every app needs a `users` table. If the app only needs authentication gates, `ctx.auth.getUserIdentity()` may be enough.
- If the app is greenfield, starting from the official starter flow is usually better than partially recreating it by hand.
- Do not stop at local dev setup if the user expects production-ready auth. The production deployment needs the auth setup too.
- Keep provider-specific setup and Convex Auth authorization behavior in the official docs instead of inventing shared patterns from memory.
## Production
- Ask whether the user wants dev-only setup or production-ready setup
- If the answer is production-ready, make sure the auth configuration is applied to the production deployment, not just the dev deployment
- Verify production-specific redirect URLs, auth method configuration, and deployment settings before calling the task complete
- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly.
## Human Handoff
If `npx convex dev` or deployment setup requires human input:
- stop and explain exactly what the user needs to do
- say why that step is required
- resume the auth setup immediately after the user confirms it is done
## Validation
- Verify the user can complete a sign-in flow
- Offer to validate sign up, sign out, and sign back in with the configured auth method
- If browser automation is available in the environment, you can do this directly
- If browser automation is not available, give the user a short manual validation checklist instead
- Verify `ctx.auth.getUserIdentity()` returns an identity in protected backend functions
- Verify protected UI only renders after Convex-authenticated state is ready
- Verify environment variables and redirect settings match the current app environment
- Verify `convex/auth.ts` no longer has an empty `providers: []` configuration once the app is meant to support real sign-in
- Run `npx convex dev --once` or the normal dev flow after setup changes and confirm Convex codegen and push succeed
- If production-ready setup was requested, verify the production deployment is also configured correctly
## Checklist
- [ ] Confirm the user wants Convex Auth specifically
- [ ] Ask whether the user wants local-only setup or production-ready setup
- [ ] Ensure a Convex deployment is configured before running auth initialization
- [ ] Install `@convex-dev/auth` and `@auth/core@0.37.0`
- [ ] Run `npx convex dev` first if needed
- [ ] Run `npx @convex-dev/auth`
- [ ] Confirm `convex/auth.config.ts`, `convex/auth.ts`, and `convex/http.ts` were created
- [ ] Follow the setup guide for package install and wiring
- [ ] Add `authTables` to `convex/schema.ts`
- [ ] Replace `ConvexProvider` with `ConvexAuthProvider`
- [ ] Configure at least one auth method in `convex/auth.ts`
- [ ] Run `npx convex dev --once` or the normal dev flow after setup changes
- [ ] Confirm which sign-in methods the app needs
- [ ] Verify the client can sign in and the backend receives authenticated identity
- [ ] Offer end-to-end validation of sign up, sign out, and sign back in
- [ ] If requested, configure the production deployment too
- [ ] Only add extra `users` table sync if the app needs app-level user records
@@ -0,0 +1,114 @@
# WorkOS AuthKit
Official docs:
- https://docs.convex.dev/auth/authkit/
- https://docs.convex.dev/auth/authkit/add-to-app
- https://docs.convex.dev/auth/authkit/auto-provision
Use this when the app already uses WorkOS or the user wants AuthKit specifically.
## Workflow
1. Confirm the user wants WorkOS AuthKit
2. Determine whether they want:
- a Convex-managed WorkOS team
- an existing WorkOS team
3. Ask whether the user wants local-only setup or production-ready setup now
4. Read the official Convex and WorkOS AuthKit guide
5. Create or update `convex.json` for the app's framework and real local port
6. Follow the correct branch of the setup flow based on that choice
7. Configure the required WorkOS environment variables
8. Configure `convex/auth.config.ts` for WorkOS-issued JWTs
9. Wire the client provider and callback flow
10. Verify authenticated requests reach Convex
11. If the user wants production-ready setup, make sure the production WorkOS configuration is covered too
12. Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex
## What To Do
- Read the official Convex and WorkOS AuthKit guide before writing setup code
- Determine whether the user wants a Convex-managed WorkOS team or an existing WorkOS team
- Treat `convex.json` as a first-class part of the AuthKit setup, not an optional extra
- Follow the current setup flow from the docs instead of relying on older examples
## Key Setup Areas
- package installation for the app's framework
- `convex.json` with the `authKit` section for dev, and preview or prod if needed
- environment variables such as `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and redirect configuration
- `convex/auth.config.ts` wiring for WorkOS-issued JWTs
- client provider setup and token flow into Convex
- login callback and redirect configuration
## Files and Env Vars To Expect
- `convex.json`
- `convex/auth.config.ts`
- frontend auth provider wiring
- callback or redirect route setup where the framework requires it
- WorkOS environment variables commonly include:
- `WORKOS_CLIENT_ID`
- `WORKOS_API_KEY`
- `WORKOS_COOKIE_PASSWORD`
- `VITE_WORKOS_CLIENT_ID`
- `VITE_WORKOS_REDIRECT_URI`
- `NEXT_PUBLIC_WORKOS_REDIRECT_URI`
For a managed WorkOS team, `convex dev` can provision the AuthKit environment and write local env vars such as `VITE_WORKOS_CLIENT_ID` and `VITE_WORKOS_REDIRECT_URI` into `.env.local` for Vite apps.
## Concrete Steps
1. Choose Convex-managed or existing WorkOS team
2. Create or update `convex.json` with the `authKit` section for the framework in use
3. Make sure the dev `redirectUris`, `appHomepageUrl`, `corsOrigins`, and local redirect env vars match the app's actual local port
4. For a managed WorkOS team, run `npx convex dev` and follow the interactive onboarding flow
5. For an existing WorkOS team, get `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` from the WorkOS dashboard and set them with `npx convex env set`
6. Create or update `convex/auth.config.ts` for WorkOS JWT validation
7. Run the normal Convex dev or deploy flow so backend config is synced
8. Wire the WorkOS client provider in the app
9. Configure callback and redirect handling
10. Verify the user can sign in and return to the app
11. Verify Convex sees the authenticated user after login
12. If the user wants production-ready setup, configure the production client ID, API key, redirect URI, and deployment settings too
## Gotchas
- The docs split setup between Convex-managed and existing WorkOS teams, so ask which path the user wants if it is not obvious
- Keep dev and prod WorkOS configuration separate where the docs call for different client IDs or API keys
- Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex
- Do not mix dev and prod WorkOS credentials or redirect URIs
- If the repo already contains WorkOS setup, preserve the current tenant model unless the user wants to change it
- For managed WorkOS setup, `convex dev` is interactive the first time. In non-interactive terminals, stop and ask the user to complete the onboarding prompts.
- `convex.json` is not optional for the managed AuthKit flow. It drives redirect URI, homepage URL, CORS configuration, and local env var generation.
- If the frontend starts on a different port than the one in `convex.json`, the hosted WorkOS sign-in flow will point to the wrong callback URL. Update `convex.json`, update the local redirect env var, and run `npx convex dev` again.
- Vite can fall off `5173` if other apps are already running. Do not assume the default port still matches the generated AuthKit config.
- A successful WorkOS sign-in should redirect back to the local callback route and then reach a Convex-authenticated state. Do not stop at "the hosted WorkOS page loaded."
## Production
- Ask whether the user wants dev-only setup or production-ready setup
- If the answer is production-ready, make sure the production WorkOS client ID, API key, redirect URI, and Convex deployment config are all covered
- Verify the production redirect and callback settings before calling the task complete
- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly.
## Validation
- Verify the user can complete the login flow and return to the app
- Verify the callback URL matches the real frontend port in local dev
- Verify Convex receives authenticated requests after login
- Verify `convex.json` matches the framework and chosen WorkOS setup path
- Verify `convex/auth.config.ts` matches the chosen WorkOS setup path
- Verify environment variables differ correctly between local and production where needed
- If production-ready setup was requested, verify the production WorkOS configuration is also covered
## Checklist
- [ ] Confirm the user wants WorkOS AuthKit
- [ ] Ask whether the user wants local-only setup or production-ready setup
- [ ] Choose Convex-managed or existing WorkOS team
- [ ] Create or update `convex.json`
- [ ] Configure WorkOS environment variables
- [ ] Configure `convex/auth.config.ts`
- [ ] Verify authenticated requests reach Convex after login
- [ ] If requested, configure the production deployment too
+47
View File
@@ -0,0 +1,47 @@
---
name: convex
description: Routes general Convex requests to the right project skill. Use when the user asks which Convex skill to use or gives an underspecified Convex app task.
---
# Convex
Use this as the routing skill for Convex work in this repo.
If a more specific Convex skill clearly matches the request, use that instead.
## Start Here
If the project does not already have Convex AI guidance installed, or the existing guidance looks stale, strongly recommend installing it first.
Preferred:
```bash
npx convex ai-files install
```
This installs or refreshes the managed Convex AI files. It is the recommended starting point for getting the official Convex guidelines in place and following the current Convex AI setup described in the docs:
- [Convex AI docs](https://docs.convex.dev/ai)
Simple fallback:
- [convex_rules.txt](https://convex.link/convex_rules.txt)
Prefer `npx convex ai-files install` over copying rules by hand when possible.
## Route to the Right Skill
After that, use the most specific Convex skill for the task:
- New project or adding Convex to an app: `convex-quickstart`
- Authentication setup: `convex-setup-auth`
- Building a reusable Convex component: `convex-create-component`
- Planning or running a migration: `convex-migration-helper`
- Investigating performance issues: `convex-performance-audit`
If one of those clearly matches the user's goal, switch to it instead of staying in this skill.
## When Not to Use
- The user has already named a more specific Convex workflow
- Another Convex skill obviously fits the request better
+359
View File
@@ -0,0 +1,359 @@
---
name: devtools-app-setup
description: >
Install TanStack Devtools, pick framework adapter (React/Vue/Solid/Preact),
register plugins via plugins prop, configure shell (position, hotkeys, theme,
hideUntilHover, requireUrlFlag, eventBusConfig). TanStackDevtools component,
defaultOpen, localStorage persistence.
type: core
library: '@tanstack/devtools'
library_version: '0.10.12'
sources:
- docs/quick-start.md
- docs/installation.md
- docs/configuration.md
- docs/overview.md
- packages/devtools/src/context/devtools-store.ts
- packages/vue-devtools/src/types.ts
- packages/react-devtools/src/devtools.tsx
---
# TanStack Devtools App Setup
## Setup
### React (primary)
Install as dev dependencies:
```bash
npm install -D @tanstack/react-devtools @tanstack/devtools-vite
```
Mount `TanStackDevtools` at the root of your application:
```tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { TanStackDevtools } from '@tanstack/react-devtools'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<TanStackDevtools />
</StrictMode>,
)
```
Add plugins via the `plugins` prop. Each plugin needs `name` (string) and `render` (JSX element or render function):
```tsx
import { TanStackDevtools } from '@tanstack/react-devtools'
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
;<TanStackDevtools
plugins={[
{
name: 'TanStack Query',
render: <ReactQueryDevtoolsPanel />,
},
{
name: 'TanStack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
```
### Vue
```bash
npm install -D @tanstack/vue-devtools
```
Vue uses `component` (not `render`) in plugin definitions. This is the `TanStackDevtoolsVuePlugin` type:
```vue
<script setup lang="ts">
import { TanStackDevtools } from '@tanstack/vue-devtools'
import type { TanStackDevtoolsVuePlugin } from '@tanstack/vue-devtools'
import { VueQueryDevtoolsPanel } from '@tanstack/vue-query-devtools'
const plugins: TanStackDevtoolsVuePlugin[] = [
{ name: 'Vue Query', component: VueQueryDevtoolsPanel },
]
</script>
<template>
<App />
<TanStackDevtools :plugins="plugins" />
</template>
```
The Vite plugin (`@tanstack/devtools-vite`) is optional for Vue but recommended for enhanced console logs and go-to-source.
### Solid
```bash
npm install -D @tanstack/solid-devtools @tanstack/devtools-vite
```
```tsx
import { render } from 'solid-js/web'
import { TanStackDevtools } from '@tanstack/solid-devtools'
import { SolidQueryDevtoolsPanel } from '@tanstack/solid-query-devtools'
import App from './App'
render(
() => (
<>
<App />
<TanStackDevtools
plugins={[
{
name: 'TanStack Query',
render: <SolidQueryDevtoolsPanel />,
},
]}
/>
</>
),
document.getElementById('root')!,
)
```
### Preact
```bash
npm install -D @tanstack/preact-devtools @tanstack/devtools-vite
```
```tsx
import { render } from 'preact'
import { TanStackDevtools } from '@tanstack/preact-devtools'
import App from './App'
render(
<>
<App />
<TanStackDevtools
plugins={[
{
name: 'Your Plugin',
render: <YourPluginComponent />,
},
]}
/>
</>,
document.getElementById('root')!,
)
```
## Core Patterns
### Shell Configuration
Pass a `config` prop to `TanStackDevtools` to set initial shell behavior. These values are persisted to `localStorage` after first load and can be changed through the settings panel at runtime.
Storage keys used internally:
- `tanstack_devtools_settings` -- persisted settings
- `tanstack_devtools_state` -- persisted UI state (active tab, panel height, active plugins, persistOpen)
All config properties are optional. Defaults shown below:
```tsx
<TanStackDevtools
config={{
defaultOpen: false, // open panel on mount
hideUntilHover: false, // hide trigger until mouse hover
position: 'bottom-right', // trigger position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'middle-left' | 'middle-right'
panelLocation: 'bottom', // panel position: 'top' | 'bottom'
openHotkey: ['Control', '~'],
inspectHotkey: ['Shift', 'Alt', 'CtrlOrMeta'],
requireUrlFlag: false, // require URL param to show devtools
urlFlag: 'tanstack-devtools', // the URL param name when requireUrlFlag is true
theme: 'dark', // 'light' | 'dark' (defaults to system preference)
triggerHidden: false, // completely hide trigger (hotkey still works)
}}
/>
```
### Event Bus Configuration
The `eventBusConfig` prop configures the client-side event bus that plugins use for communication:
```tsx
<TanStackDevtools
eventBusConfig={{
debug: false, // enable debug logging for the event bus
connectToServerBus: false, // connect to the Vite plugin server event bus
port: 3000, // port for server event bus connection
}}
/>
```
The server event bus requires the `@tanstack/devtools-vite` plugin to be running.
### Plugin Registration with defaultOpen
Each plugin entry can include a `defaultOpen` flag to control whether that plugin tab is active when devtools first opens:
```tsx
import { TanStackDevtools } from '@tanstack/react-devtools'
import { FormDevtools } from '@tanstack/react-form'
;<TanStackDevtools
config={{ hideUntilHover: true }}
eventBusConfig={{ debug: true }}
plugins={[
{
name: 'TanStack Form',
render: <FormDevtools />,
defaultOpen: true,
},
]}
/>
```
### Conditional Devtools with URL Flag
Use `requireUrlFlag` to hide devtools unless a specific URL parameter is present. This is useful for staging environments or team-internal debugging:
```tsx
<TanStackDevtools
config={{
requireUrlFlag: true,
urlFlag: 'tanstack-devtools', // visit ?tanstack-devtools to enable
}}
/>
```
## Common Mistakes
### CRITICAL: Vue plugin uses `render` instead of `component`
The Vue adapter uses `component` (a Vue component reference) and optional `props`, not JSX `render`. Using `render` produces a silent failure -- the plugin tab appears but renders nothing.
Wrong:
```vue
<!-- This silently fails - render is ignored in Vue adapter -->
<script setup lang="ts">
const plugins = [{ name: 'My Plugin', render: MyComponent }]
</script>
```
Correct:
```vue
<script setup lang="ts">
import type { TanStackDevtoolsVuePlugin } from '@tanstack/vue-devtools'
const plugins: TanStackDevtoolsVuePlugin[] = [
{ name: 'My Plugin', component: MyComponent },
]
</script>
```
The `TanStackDevtoolsVuePlugin` type enforces this at compile time. Always import and use it.
### HIGH: Vite plugin not placed first in plugins array
The `@tanstack/devtools-vite` plugin performs source injection that must run before framework plugins (React, Vue, Solid, etc.) process the code.
Wrong:
```ts
import { devtools } from '@tanstack/devtools-vite'
import react from '@vitejs/plugin-react'
export default {
plugins: [react(), devtools()],
}
```
Correct:
```ts
import { devtools } from '@tanstack/devtools-vite'
import react from '@vitejs/plugin-react'
export default {
plugins: [devtools(), react()],
}
```
### HIGH: Mounting TanStackDevtools in SSR without client guard
The devtools core shell requires DOM APIs (`document`, `window`, `localStorage`). The React adapter includes `'use client'` at its entry point, so standard Next.js/Remix setups work. However, custom SSR setups or frameworks that do not respect the `'use client'` directive need explicit guards.
Wrong:
```tsx
// In a server-rendered component without framework 'use client' support
import { TanStackDevtools } from '@tanstack/react-devtools'
export default function Layout({ children }) {
return (
<>
{children}
<TanStackDevtools />
</>
)
}
```
Correct:
```tsx
import { TanStackDevtools } from '@tanstack/react-devtools'
export default function Layout({ children }) {
return (
<>
{children}
{typeof window !== 'undefined' && <TanStackDevtools />}
</>
)
}
```
Or use dynamic imports / lazy loading to ensure the component only loads on the client.
### MEDIUM: Installing as regular dependency for dev-only use
When using the Vite plugin for production stripping, devtools packages should be dev dependencies. Installing them as regular dependencies increases production bundle size unnecessarily.
Wrong:
```bash
npm install @tanstack/react-devtools
```
Correct:
```bash
npm install -D @tanstack/react-devtools
npm install -D @tanstack/devtools-vite
```
Exception: if you intentionally want devtools in production, install `@tanstack/devtools` (core) as a regular dependency. See the production skill for details.
### MEDIUM: Not keeping devtools packages at latest versions
All `@tanstack/devtools-*` packages share internal protocols (event bus messages, plugin mount lifecycle). Mixing versions can cause silent failures where plugins register but never receive events, or the shell mounts but plugins do not render.
Always update all devtools packages together:
```bash
npm install -D @tanstack/react-devtools@latest @tanstack/devtools-vite@latest
```
When building custom plugins, ensure `@tanstack/devtools-event-client` matches the version of `@tanstack/devtools` used by the shell.
## See Also
- **devtools-vite-plugin** -- Vite plugin configuration: source inspection, console piping, production stripping, server event bus setup
- **devtools-production** -- Production build handling: keeping devtools in prod, tree-shaking, URL flag gating
- **devtools-plugin-panel** -- Building custom plugin panels with the EventClient API
@@ -0,0 +1,472 @@
---
name: devtools-bidirectional
description: Two-way event patterns between devtools panel and application. App-to-devtools observation, devtools-to-app commands, time-travel debugging with snapshots and revert. structuredClone for snapshot safety, distinct event suffixes for observation vs commands, serializable payloads only.
type: core
library: '@tanstack/devtools-event-client'
library_version: '0.10.12'
requires: devtools-event-client
sources:
- packages/event-bus-client/src/plugin.ts
- docs/bidirectional-communication.md
---
# devtools-bidirectional
> **Prerequisite:** Read and understand the `devtools-event-client` skill first. This skill builds on `EventClient`, its event map types, `emit()`/`on()` API, pluginId namespacing, connection lifecycle, and singleton pattern. Everything here assumes you already have a working `EventClient` instance.
Two-way communication between your application and a TanStack Devtools panel using `EventClient`. The same client instance handles both directions: the app emits observation events that the panel listens to, and the panel emits command events that the app listens to.
## Core Concept
`EventClient` is not unidirectional. Both `emit()` and `on()` work from either side -- application code or panel code -- on the same shared event bus. The direction is a convention you establish through your event map design, not a limitation of the API.
```
App code calls: client.emit('state-update', ...) // observation
Panel code calls: client.on('state-update', ...) // observation
Panel code calls: client.emit('set-state', ...) // command
App code calls: client.on('set-state', ...) // command
```
## Core Patterns
### 1. App-to-Devtools Observation
The app emits state changes. The panel listens and renders.
**Event map and client (shared module):**
```ts
import { EventClient } from '@tanstack/devtools-event-client'
type CounterEvents = {
// Observation: app -> panel
'state-update': { count: number; updatedAt: number }
}
class CounterDevtoolsClient extends EventClient<CounterEvents> {
constructor() {
super({
pluginId: 'counter-inspector',
enabled: process.env.NODE_ENV !== 'production',
})
}
}
export const counterClient = new CounterDevtoolsClient()
```
**App side -- emit on state changes:**
```ts
import { counterClient } from './counter-devtools-client'
function increment() {
count += 1
counterClient.emit('state-update', {
count,
updatedAt: Date.now(),
})
}
```
**Panel side -- listen and display:**
```ts
import { counterClient } from './counter-devtools-client'
const cleanup = counterClient.on('state-update', (event) => {
// event.payload.count
// event.payload.updatedAt
renderPanel(event.payload)
})
```
### 2. Devtools-to-App Commands
The panel sends commands. The app listens and mutates state.
**Extend the event map with command events:**
```ts
type CounterEvents = {
// Observation: app -> panel
'state-update': { count: number; updatedAt: number }
// Commands: panel -> app
reset: void
'set-count': { count: number }
}
```
**Panel side -- emit commands on user interaction:**
```ts
import { counterClient } from './counter-devtools-client'
function handleResetClick() {
counterClient.emit('reset', undefined)
}
function handleSetCount(newCount: number) {
counterClient.emit('set-count', { count: newCount })
}
```
**App side -- listen for commands and react:**
```ts
import { counterClient } from './counter-devtools-client'
counterClient.on('reset', () => {
count = 0
// Re-emit observation so panel updates
counterClient.emit('state-update', {
count,
updatedAt: Date.now(),
})
})
counterClient.on('set-count', (event) => {
count = event.payload.count
counterClient.emit('state-update', {
count,
updatedAt: Date.now(),
})
})
```
The command handler re-emits an observation event after mutating state. This closes the loop so the panel sees the result of its own command.
### 3. Time-Travel Debugging
Combine observation (snapshots) with commands (revert) to build a time-travel slider.
**Event map:**
```ts
type TimeTravelEvents = {
// Observation: app -> panel
snapshot: { state: unknown; timestamp: number; label: string }
// Command: panel -> app
revert: { state: unknown }
}
class TimeTravelClient extends EventClient<TimeTravelEvents> {
constructor() {
super({
pluginId: 'time-travel',
enabled: process.env.NODE_ENV !== 'production',
})
}
}
export const timeTravelClient = new TimeTravelClient()
```
**App side -- emit snapshots with structuredClone:**
```ts
import { timeTravelClient } from './time-travel-client'
function applyAction(action: { type: string; payload: unknown }) {
state = reducer(state, action)
timeTravelClient.emit('snapshot', {
state: structuredClone(state),
timestamp: Date.now(),
label: action.type,
})
}
// Listen for revert commands from devtools
timeTravelClient.on('revert', (event) => {
state = event.payload.state
rerender()
})
```
`structuredClone(state)` is required here. Without it, the snapshot payload holds a reference to the live state object. When the app mutates state later, all previously stored snapshots in the panel are corrupted because they point to the same object.
**Panel side -- collect snapshots and revert:**
```tsx
import { timeTravelClient } from './time-travel-client'
function TimeTravelPanel() {
const [snapshots, setSnapshots] = useState<
Array<{ state: unknown; timestamp: number; label: string }>
>([])
const [index, setIndex] = useState(0)
useEffect(() => {
return timeTravelClient.on('snapshot', (event) => {
setSnapshots((prev) => [...prev, event.payload])
setIndex((prev) => prev + 1)
})
}, [])
const handleSliderChange = (newIndex: number) => {
setIndex(newIndex)
timeTravelClient.emit('revert', {
state: snapshots[newIndex].state,
})
}
return (
<div>
<input
type="range"
min={0}
max={snapshots.length - 1}
value={index}
onChange={(e) => handleSliderChange(Number(e.target.value))}
/>
<p>
{snapshots[index]?.label} (
{new Date(snapshots[index]?.timestamp).toLocaleTimeString()})
</p>
<pre>{JSON.stringify(snapshots[index]?.state, null, 2)}</pre>
</div>
)
}
```
After the app handles `revert`, it should re-emit a `snapshot` so the panel timeline stays current. The revert handler in the app side example above does not re-emit -- add it if your UI needs the timeline to update after a revert:
```ts
timeTravelClient.on('revert', (event) => {
state = event.payload.state
rerender()
// Optional: re-emit so the timeline reflects the revert
timeTravelClient.emit('snapshot', {
state: structuredClone(state),
timestamp: Date.now(),
label: 'revert',
})
})
```
### 4. Bidirectional Event Map Design
When a single plugin needs both observation and command events, define them all in one event map. Use naming conventions to distinguish direction:
```ts
type StoreInspectorEvents = {
// Observation: app -> panel (describe what happened)
'state-update': { storeName: string; state: unknown; timestamp: number }
'action-dispatched': { storeName: string; action: string; payload: unknown }
'error-caught': { storeName: string; error: string; stack?: string }
// Commands: panel -> app (describe what to do)
'set-state': { storeName: string; state: unknown }
'dispatch-action': { storeName: string; action: string; payload: unknown }
reset: void
revert: { state: unknown }
}
```
Naming convention:
- **Observation events** describe what happened: `state-update`, `action-dispatched`, `error-caught`, `snapshot`
- **Command events** describe what to do: `set-state`, `dispatch-action`, `reset`, `revert`
This distinction is purely a convention in your event map keys. The `EventClient` API is the same for both. But maintaining it makes your event map self-documenting and prevents confusion about which side emits vs listens.
**Full bidirectional wiring with one client:**
```ts
import { EventClient } from '@tanstack/devtools-event-client'
type StoreInspectorEvents = {
'state-update': { storeName: string; state: unknown; timestamp: number }
'set-state': { storeName: string; state: unknown }
reset: void
}
class StoreInspectorClient extends EventClient<StoreInspectorEvents> {
constructor() {
super({
pluginId: 'store-inspector',
enabled: process.env.NODE_ENV !== 'production',
})
}
}
export const storeInspector = new StoreInspectorClient()
```
**App side:**
```ts
import { storeInspector } from './store-inspector-client'
// Observation: emit state changes
function updateStore(storeName: string, newState: unknown) {
stores[storeName] = newState
storeInspector.emit('state-update', {
storeName,
state: structuredClone(newState),
timestamp: Date.now(),
})
}
// Command handlers: listen for panel commands
storeInspector.on('set-state', (event) => {
const { storeName, state } = event.payload
stores[storeName] = state
storeInspector.emit('state-update', {
storeName,
state: structuredClone(state),
timestamp: Date.now(),
})
})
storeInspector.on('reset', () => {
for (const storeName of Object.keys(stores)) {
stores[storeName] = initialStates[storeName]
storeInspector.emit('state-update', {
storeName,
state: structuredClone(initialStates[storeName]),
timestamp: Date.now(),
})
}
})
```
**Panel side:**
```ts
import { storeInspector } from './store-inspector-client'
// Observation: listen for state changes
storeInspector.on('state-update', (event) => {
renderStore(event.payload.storeName, event.payload.state)
})
// Commands: emit on user action
function handleEditState(storeName: string, newState: unknown) {
storeInspector.emit('set-state', { storeName, state: newState })
}
function handleReset() {
storeInspector.emit('reset', undefined)
}
```
## Debouncing Frequent Observations
High-frequency state changes (e.g., mouse tracking, animation frames) can flood the event bus. Debounce on the emit side:
```ts
import { storeInspector } from './store-inspector-client'
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function emitStateUpdate(storeName: string, state: unknown) {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
storeInspector.emit('state-update', {
storeName,
state: structuredClone(state),
timestamp: Date.now(),
})
}, 16) // ~60fps cap
}
```
Do not debounce command events. Commands are user-initiated and infrequent.
## Common Mistakes
### 1. Not using structuredClone for snapshots (HIGH)
Without `structuredClone`, snapshot payloads hold references to the live state object. When the app mutates state later, every stored snapshot in the panel is silently corrupted.
Wrong:
```ts
timeTravelClient.emit('snapshot', {
state,
timestamp: Date.now(),
label: action.type,
})
```
The panel stores `event.payload.state`, which is a reference to the app's `state` variable. On the next mutation, the panel's stored snapshot now reflects the new state, not the historical state.
Correct:
```ts
timeTravelClient.emit('snapshot', {
state: structuredClone(state),
timestamp: Date.now(),
label: action.type,
})
```
`structuredClone` creates a deep copy. The snapshot is frozen in time regardless of future mutations. This applies to any observation event where the panel accumulates historical data -- not just time-travel.
### 2. Non-serializable payloads in cross-tab scenarios (HIGH)
When using the server event bus (WebSocket/SSE/BroadcastChannel), payloads are serialized for transport. Functions, DOM nodes, class instances with methods, `Map`, `Set`, `WeakRef`, and circular references all fail silently or lose data.
This is especially dangerous in bidirectional patterns because command payloads flow panel-to-app and may cross transport boundaries.
Wrong:
```ts
storeInspector.emit('set-state', {
storeName: 'main',
state: {
items: new Map([['a', 1]]), // Map -- lost on serialization
onClick: () => alert('hi'), // Function -- lost on serialization
ref: document.getElementById('x'), // DOM node -- lost on serialization
},
})
```
Correct:
```ts
storeInspector.emit('set-state', {
storeName: 'main',
state: {
items: Object.fromEntries(new Map([['a', 1]])),
timestamp: Date.now(),
},
})
```
Rule of thumb: if `JSON.parse(JSON.stringify(payload))` does not round-trip cleanly, the payload is not safe for the event bus.
### 3. Not distinguishing observation from command events (MEDIUM)
Mixing naming conventions makes the event map confusing and error-prone. Developers end up emitting observation events from the panel or command events from the app, breaking the communication contract.
Wrong:
```ts
type MyEvents = {
state: unknown // Is this observation or command?
update: unknown // Who emits this?
count: number // Unclear direction
}
```
Correct:
```ts
type MyEvents = {
'state-update': unknown // Observation: describes what happened
'set-state': unknown // Command: describes what to do
'count-changed': number // Observation: past tense / descriptive
reset: void // Command: imperative
}
```
Use observation suffixes that describe what happened (`-update`, `-changed`, `-dispatched`, `-caught`). Use command suffixes that describe what to do (`set-`, `dispatch-`, `reset`, `revert`). The naming convention is not enforced by the API, but consistent naming prevents wiring mistakes.
## See Also
- `devtools-event-client` -- base event system: event maps, `emit()`/`on()`, connection lifecycle, singleton pattern
- `devtools-instrumentation` -- strategic placement of `emit()` calls in library code benefits from bidirectional awareness (knowing that commands will flow back)
@@ -0,0 +1,289 @@
---
name: devtools-event-client
description: Create typed EventClient for a library. Define event maps with typed payloads, pluginId auto-prepend namespacing, emit()/on()/onAll()/onAllPluginEvents() API. Connection lifecycle (5 retries, 300ms), event queuing, enabled/disabled state, SSR fallbacks, singleton pattern. Unique pluginId requirement to avoid event collisions.
type: core
library: '@tanstack/devtools-event-client'
library_version: '0.10.12'
sources:
- packages/event-bus-client/src/plugin.ts
- docs/event-system.md
- docs/building-custom-plugins.md
---
# devtools-event-client
Typed event emitter/listener that connects application code to TanStack Devtools panels. Framework-agnostic. Works in React, Vue, Solid, Preact, and vanilla JS.
## Setup
Install the package:
```bash
npm i @tanstack/devtools-event-client
```
The package exports a single class:
```ts
import { EventClient } from '@tanstack/devtools-event-client'
```
### Constructor Options
| Option | Type | Required | Default | Description |
| ------------------ | --------- | -------- | ------- | ------------------------------------------------------------------------------------- |
| `pluginId` | `string` | Yes | -- | Identifies this plugin in the event system. Must be unique across all plugins. |
| `debug` | `boolean` | No | `false` | Enable verbose console logging prefixed with `[tanstack-devtools:{pluginId}-plugin]`. |
| `enabled` | `boolean` | No | `true` | When `false`, `emit()` is a no-op and `on()` returns a no-op cleanup function. |
| `reconnectEveryMs` | `number` | No | `300` | Interval in ms between connection retry attempts (max 5 retries). |
## Core Patterns
### 1. Define an Event Map and Create a Singleton Client
Define a TypeScript type mapping event suffixes to payload types. Extend `EventClient` and export a single instance at module level.
```ts
import { EventClient } from '@tanstack/devtools-event-client'
type StoreEvents = {
'state-changed': { storeName: string; state: unknown; timestamp: number }
'action-dispatched': { storeName: string; action: string; payload: unknown }
reset: void
}
class StoreInspectorClient extends EventClient<StoreEvents> {
constructor() {
super({ pluginId: 'store-inspector' })
}
}
// Module-level singleton -- one instance per plugin
export const storeInspector = new StoreInspectorClient()
```
Event map keys are suffixes only. The `pluginId` is prepended automatically. With `pluginId: 'store-inspector'` and key `'state-changed'`, the fully qualified event on the bus is `'store-inspector:state-changed'`.
### 2. Emit Events
Call `emit(suffix, payload)` from library code. Pass only the suffix.
```ts
function dispatch(action: string, payload: unknown) {
state = reducer(state, action, payload)
storeInspector.emit('state-changed', {
storeName: 'main',
state,
timestamp: Date.now(),
})
storeInspector.emit('action-dispatched', {
storeName: 'main',
action,
payload,
})
}
```
If the bus is not connected yet, events are queued in memory and flushed once the connection succeeds. If the connection fails after 5 retries (1.5s at default settings), the client gives up and subsequent `emit()` calls are silently dropped.
Connection to the bus is initiated lazily on the first `emit()` call, not on construction or `on()`.
### 3. Listen to Events
All listener methods return a cleanup function.
**`on(suffix, callback)`** -- listen to a specific event from this plugin:
```ts
const cleanup = storeInspector.on('state-changed', (event) => {
// event.type === 'store-inspector:state-changed'
// event.payload === { storeName: string; state: unknown; timestamp: number }
// event.pluginId === 'store-inspector'
console.log(event.payload.state)
})
// Stop listening
cleanup()
```
**`on(suffix, callback, { withEventTarget: true })`** -- also register on an internal EventTarget so events emitted and listened to on the same client instance are delivered immediately without going through the global bus:
```ts
const cleanup = storeInspector.on(
'state-changed',
(event) => {
console.log(event.payload.state)
},
{ withEventTarget: true },
)
```
**`onAll(callback)`** -- listen to all events from all plugins:
```ts
const cleanup = storeInspector.onAll((event) => {
console.log(event.type, event.payload)
})
```
**`onAllPluginEvents(callback)`** -- listen to all events from this plugin only (filtered by `pluginId`):
```ts
const cleanup = storeInspector.onAllPluginEvents((event) => {
// Only fires when event.pluginId === 'store-inspector'
console.log(event.type, event.payload)
})
```
### 4. Connection Lifecycle and Disabling
The connection lifecycle is:
1. First `emit()` dispatches `tanstack-connect` and starts a retry loop.
2. Retries every `reconnectEveryMs` (default 300ms), up to 5 attempts.
3. On `tanstack-connect-success`, queued events are flushed in order.
4. After 5 failed retries, `failedToConnect` is set permanently. All subsequent `emit()` calls are silently dropped (not queued).
To disable the client entirely (e.g., in production):
```ts
class StoreInspectorClient extends EventClient<StoreEvents> {
constructor() {
super({
pluginId: 'store-inspector',
enabled: process.env.NODE_ENV !== 'production',
})
}
}
```
When `enabled` is `false`, `emit()` is a no-op and `on()`/`onAll()`/`onAllPluginEvents()` return no-op cleanup functions.
## Common Mistakes
### 1. Including pluginId prefix in event names (CRITICAL)
`EventClient` auto-prepends the `pluginId` to all event names. Including the prefix manually produces a double-prefixed event name that nothing will match.
Wrong:
```ts
storeInspector.emit('store-inspector:state-changed', data)
// Dispatches 'store-inspector:store-inspector:state-changed'
```
Correct:
```ts
storeInspector.emit('state-changed', data)
// Dispatches 'store-inspector:state-changed'
```
This applies to `on()` as well. Pass only the suffix.
### 2. Creating multiple EventClient instances per plugin (CRITICAL)
Each `EventClient` instance manages its own connection, event queue, and listeners independently. Creating multiple instances for the same plugin causes duplicate handlers, multiple connection attempts, and unpredictable event delivery.
Wrong:
```tsx
function MyComponent() {
// New instance on every render
const client = new StoreInspectorClient()
client.emit('state-changed', data)
}
```
Correct:
```ts
// store-inspector-client.ts
export const storeInspector = new StoreInspectorClient()
// MyComponent.tsx
import { storeInspector } from './store-inspector-client'
function MyComponent() {
storeInspector.emit('state-changed', data)
}
```
### 3. Non-unique pluginId causing event collisions (CRITICAL)
Two plugins with the same `pluginId` share an event namespace. Events emitted by one are received by listeners on the other. Choose a unique, descriptive `pluginId` (e.g., `'my-org-store-inspector'` rather than `'store'`).
### 4. Not realizing events drop after 5 failed retries (HIGH)
After 5 retries (1.5s at default `reconnectEveryMs: 300`), `failedToConnect` is set permanently. Subsequent `emit()` calls are silently dropped -- they are not queued and will never be delivered, even if the bus becomes available later.
If you need events to survive longer startup delays, increase `reconnectEveryMs`:
```ts
super({ pluginId: 'store-inspector', reconnectEveryMs: 1000 })
// 5 retries * 1000ms = 5s window
```
There is no way to increase the retry count (hardcoded to 5).
### 5. Expecting connection on construction or on() (HIGH)
The connection to the event bus is initiated lazily on the first `emit()` call. Calling `on()` alone does not trigger a connection. If your panel calls `on()` but the library side never calls `emit()`, the client never connects to the bus.
This means if you only listen (no emitting), the `on()` handler still works for events dispatched directly on the global event target, but the connection handshake (`tanstack-connect` / `tanstack-connect-success`) never runs.
### 6. Using non-serializable payloads (HIGH)
When the server event bus is enabled, events are serialized via JSON for transport over WebSocket/SSE/BroadcastChannel. Payloads containing functions, DOM nodes, class instances, `Map`/`Set`, or circular references will fail silently or lose data.
Wrong:
```ts
storeInspector.emit('state-changed', {
storeName: 'main',
state,
callback: () => {}, // Function -- not serializable
element: document.body, // DOM node -- not serializable
})
```
Correct:
```ts
storeInspector.emit('state-changed', {
storeName: 'main',
state: JSON.parse(JSON.stringify(state)), // Ensure serializable
timestamp: Date.now(),
})
```
### 7. Not stripping EventClient emit calls for production (HIGH)
The Vite plugin strips adapter imports (e.g., `@tanstack/react-devtools`) from production builds, but it does NOT strip `@tanstack/devtools-event-client` imports or `emit()` calls. Library authors must guard emit calls themselves.
Options:
**Option A:** Use the `enabled` constructor option:
```ts
super({
pluginId: 'store-inspector',
enabled: process.env.NODE_ENV !== 'production',
})
```
**Option B:** Conditional guard at the call site:
```ts
if (process.env.NODE_ENV !== 'production') {
storeInspector.emit('state-changed', data)
}
```
When `enabled` is `false`, `emit()` returns immediately (no event creation, no queuing, no connection attempt). This is the preferred approach.
## See Also
- `devtools-instrumentation` -- after creating a client, instrument library code with strategic emissions
- `devtools-plugin-panel` -- the client emits events, the panel listens using the same event map
- `devtools-bidirectional` -- two-way communication between panel and application using the same EventClient
@@ -0,0 +1,423 @@
---
name: devtools-instrumentation
description: Analyze library codebase for critical architecture and debugging points, add strategic event emissions. Identify middleware boundaries, state transitions, lifecycle hooks. Consolidate events (1 not 15), debounce high-frequency updates, DRY shared payload fields, guard emit() for production. Transparent server/client event bridging.
type: core
library: '@tanstack/devtools-event-client'
library_version: '0.10.12'
requires: devtools-event-client
sources:
- packages/event-bus-client/src/plugin.ts
- packages/event-bus/src/client/client.ts
- packages/event-bus/src/server/server.ts
- packages/devtools-client/src/index.ts
- docs/building-custom-plugins.md
- docs/bidirectional-communication.md
---
# devtools-instrumentation
> **Prerequisite:** Read the `devtools-event-client` skill first for EventClient creation, event maps, and `emit()`/`on()` API.
Strategic placement of `emit()` calls inside a library to send high-value diagnostic data to TanStack Devtools panels. Maximum insight with minimum noise.
## Key Insight
The event bus transparently bridges server/client and cross-tab boundaries. `emit()` on the server arrives on the client via WebSocket/SSE. `emit()` in one tab reaches other tabs via `BroadcastChannel`. No transport code needed -- just emit at the right place.
For prototyping, throw in many events. For production, consolidate down to the fewest events that carry the most information.
## Where to Instrument
Emit at **architecture boundaries**, not inside implementation details:
1. **Middleware/interceptor entry and exit** -- wrap the chain, not each middleware
2. **State transitions** -- when state moves between logical phases (idle -> loading -> success/error)
3. **Lifecycle hooks** -- mount, unmount, connect, disconnect, ready
4. **Error boundaries** -- caught exceptions, retries, fallbacks
5. **User-initiated actions processed** -- after fully applied, not before
Do NOT emit from: internal utility functions, loop iterations, getter/setter accesses, intermediate computation steps.
## Core Patterns
### 1. Middleware/Interceptor Instrumentation
Wrap the pipeline at the boundary, not each middleware individually.
```ts
import { EventClient } from '@tanstack/devtools-event-client'
type RouterEvents = {
'request-processed': {
id: string
method: string
path: string
duration: number
middlewareChain: Array<{ name: string; durationMs: number }>
status: number
error?: string
}
}
class RouterDevtoolsClient extends EventClient<RouterEvents> {
constructor() {
super({
pluginId: 'my-router',
enabled: process.env.NODE_ENV !== 'production',
})
}
}
export const routerDevtools = new RouterDevtoolsClient()
```
```ts
async function runMiddlewarePipeline(
req: Request,
middlewares: Middleware[],
): Promise<Response> {
const requestId = crypto.randomUUID()
const pipelineStart = performance.now()
const chain: Array<{ name: string; durationMs: number }> = []
let status = 200
let error: string | undefined
for (const mw of middlewares) {
const mwStart = performance.now()
try {
await mw.handle(req)
} catch (e) {
error = e instanceof Error ? e.message : String(e)
status = 500
break
}
chain.push({ name: mw.name, durationMs: performance.now() - mwStart })
}
// Single consolidated event at the boundary
routerDevtools.emit('request-processed', {
id: requestId,
method: req.method,
path: req.url,
duration: performance.now() - pipelineStart,
middlewareChain: chain,
status,
error,
})
return new Response(null, { status })
}
```
ONE event per request, not 2N events (start + end for each middleware).
### 2. State Transition Emission
Emit when the state machine moves between phases, not on every internal mutation.
```ts
type QueryEvents = {
'query-lifecycle': {
queryKey: string
from: 'idle' | 'loading' | 'success' | 'error' | 'stale'
to: 'idle' | 'loading' | 'success' | 'error' | 'stale'
data?: unknown
error?: string
fetchDuration?: number
timestamp: number
}
}
class QueryDevtoolsClient extends EventClient<QueryEvents> {
constructor() {
super({
pluginId: 'my-query-lib',
enabled: process.env.NODE_ENV !== 'production',
})
}
}
export const queryDevtools = new QueryDevtoolsClient()
```
```ts
class Query {
#state: QueryState = 'idle'
private transition(
to: QueryState,
extra?: Partial<QueryEvents['query-lifecycle']>,
) {
const from = this.#state
if (from === to) return // No transition, no event
this.#state = to
queryDevtools.emit('query-lifecycle', {
queryKey: this.key,
from,
to,
timestamp: Date.now(),
...extra,
})
}
async fetch() {
this.transition('loading')
const start = performance.now()
try {
const data = await this.fetcher()
this.transition('success', {
data: structuredClone(data),
fetchDuration: performance.now() - start,
})
} catch (e) {
this.transition('error', {
error: e instanceof Error ? e.message : String(e),
fetchDuration: performance.now() - start,
})
}
}
}
```
### 3. Consolidated Events with DRY Payloads
When multiple events share fields, build a shared base and spread it.
```ts
class Store {
private basePayload() {
return {
storeName: this.#name,
version: this.#version,
sessionId: this.#sessionId,
timestamp: Date.now(),
}
}
dispatch(
action: string,
updater: (s: Record<string, unknown>) => Record<string, unknown>,
) {
const prevState = structuredClone(this.#state)
this.#state = updater(this.#state)
this.#version++
storeDevtools.emit('store-updated', {
...this.basePayload(),
action,
prevState,
nextState: structuredClone(this.#state),
})
}
reset(initial: Record<string, unknown>) {
this.#state = initial
this.#version++
storeDevtools.emit('store-reset', this.basePayload())
}
}
```
### 4. Debouncing High-Frequency Emissions
Reactive systems, scroll handlers, and streaming data can trigger hundreds of emissions per second. Debounce or throttle these.
```ts
function createDebouncedEmitter<TEvents extends Record<string, any>>(
client: EventClient<TEvents>,
delayMs: number,
) {
const timers = new Map<string, ReturnType<typeof setTimeout>>()
return function debouncedEmit<K extends keyof TEvents & string>(
event: K,
payload: TEvents[K],
) {
const existing = timers.get(event)
if (existing) clearTimeout(existing)
timers.set(
event,
setTimeout(() => {
client.emit(event, payload)
timers.delete(event)
}, delayMs),
)
}
}
const debouncedEmit = createDebouncedEmitter(storeDevtools, 100)
signal.subscribe((value) => {
debouncedEmit('signal-updated', { value, timestamp: Date.now() })
})
```
For leading+trailing (throttle), use the same pattern with a `lastEmit` timestamp check to emit immediately on the leading edge.
### 5. Production Guarding
`enabled: false` is the primary guard -- `emit()` returns immediately with no allocation, no queuing, no connection.
```ts
class MyLibDevtools extends EventClient<MyEvents> {
constructor() {
super({
pluginId: 'my-lib',
enabled: process.env.NODE_ENV !== 'production',
})
}
}
```
For expensive payload construction (e.g., `structuredClone` of large state), guard at the call site:
```ts
if (process.env.NODE_ENV !== 'production') {
myDevtools.emit('state-snapshot', {
state: structuredClone(largeState),
timestamp: Date.now(),
})
}
```
**Important:** The Vite plugin strips `@tanstack/react-devtools` from production but does NOT strip `@tanstack/devtools-event-client`. You must guard yourself.
### 6. Server/Client Transparent Bridging
The same `emit()` works on server and client:
- **Client**: dispatches `CustomEvent` on `window` -> `ClientEventBus` -> other tabs via `BroadcastChannel` + server via WebSocket
- **Server**: dispatches on `globalThis.__TANSTACK_EVENT_TARGET__` -> `ServerEventBus` -> all WebSocket/SSE clients
```ts
// Server-side (e.g., SSR handler) -- arrives in browser devtools panel automatically
routerDevtools.emit('request-processed', {
id: crypto.randomUUID(),
method: req.method,
path: new URL(req.url).pathname,
duration: performance.now() - start,
middlewareChain: chain,
status: 200,
})
```
## Instrumentation Checklist
1. Map architecture boundaries (middleware chain, state machine, lifecycle hooks, error paths)
2. Design ONE consolidated event per boundary with full context payload
3. Keep event map small (3-7 types typical, not 15-30)
4. Create EventClient with `enabled: process.env.NODE_ENV !== 'production'`
5. Use shared base payloads (DRY) for fields common across events
6. Debounce any emission point that fires >10 times/second
7. Guard expensive payload construction with `process.env.NODE_ENV` check
8. Test with `debug: true` to see `[tanstack-devtools:{pluginId}-plugin]` prefixed logs
## Common Mistakes
### HIGH: Emitting too many granular events
Wrong -- 15 events per request:
```ts
routerDevtools.emit('request-start', { id, method, path })
routerDevtools.emit('middleware-1-start', { id, name: 'auth' })
routerDevtools.emit('middleware-1-end', { id, name: 'auth', duration: 5 })
// ... 10 more ...
routerDevtools.emit('response-end', { id, duration: 50 })
```
Correct -- 1 event with all data:
```ts
routerDevtools.emit('request-processed', {
id,
method,
path,
duration: 50,
middlewareChain: [
{ name: 'auth', durationMs: 5 },
{ name: 'cors', durationMs: 1 },
],
status: 200,
})
```
Source: maintainer interview
### HIGH: Emitting in hot loops without debouncing
Wrong:
```ts
signal.subscribe((value) => {
devtools.emit('signal-updated', { value, timestamp: Date.now() }) // 60+ times/sec
})
```
Correct:
```ts
const debouncedEmit = createDebouncedEmitter(devtools, 100)
signal.subscribe((value) => {
debouncedEmit('signal-updated', { value, timestamp: Date.now() })
})
```
Source: docs/bidirectional-communication.md
### MEDIUM: Not emitting at architecture boundaries
Wrong -- instrumented inside a helper:
```ts
function parseQueryString(url: string) {
const params = new URLSearchParams(url)
devtools.emit('query-parsed', { params: Object.fromEntries(params) })
return params
}
```
Correct -- instrumented at the handler boundary:
```ts
function handleRequest(req: Request) {
const params = parseQueryString(req.url)
const result = processRequest(params)
devtools.emit('request-processed', {
path: req.url,
params: Object.fromEntries(params),
result: result.summary,
duration: performance.now() - start,
})
}
```
Source: maintainer interview
### MEDIUM: Hardcoding repeated payload fields
Wrong:
```ts
devtools.emit('action-a', {
storeName: this.name,
version: this.version,
sessionId: this.sessionId,
timestamp: Date.now(),
data,
})
devtools.emit('action-b', {
storeName: this.name,
version: this.version,
sessionId: this.sessionId,
timestamp: Date.now(),
other,
})
```
Correct:
```ts
const base = this.basePayload()
devtools.emit('action-a', { ...base, data })
devtools.emit('action-b', { ...base, other })
```
Source: maintainer interview
@@ -0,0 +1,390 @@
---
name: devtools-marketplace
description: >
Publish plugin to npm and submit to TanStack Devtools Marketplace.
PluginMetadata registry format, plugin-registry.ts, pluginImport (importName, type),
requires (packageName, minVersion), framework tagging, multi-framework submissions,
featured plugins.
type: lifecycle
library: '@tanstack/devtools'
library_version: '0.10.12'
requires:
- devtools-plugin-panel
sources:
- docs/third-party-plugins.md
- packages/devtools/src/tabs/plugin-registry.ts
- packages/devtools/src/tabs/marketplace/types.ts
- packages/devtools/src/tabs/marketplace/plugin-utils.ts
- packages/devtools-vite/src/inject-plugin.ts
- packages/devtools-client/src/index.ts
---
# TanStack Devtools Marketplace
> **Prerequisite:** Build a working plugin first using the **devtools-plugin-panel** skill. The marketplace submission assumes you already have a published npm package that exports either a JSX panel component or a function-based plugin.
## Overview
The TanStack Devtools Marketplace is a built-in registry inside the devtools shell. Users browse it from the Marketplace tab, and can install plugins with a single click. Submission is a PR to the `packages/devtools/src/tabs/plugin-registry.ts` file in the [TanStack/devtools](https://github.com/TanStack/devtools) repository.
## PluginMetadata Interface
Every marketplace entry conforms to the `PluginMetadata` interface exported from `packages/devtools/src/tabs/plugin-registry.ts`:
```ts
export interface PluginMetadata {
/** Package name on npm (e.g., '@acme/react-analytics-devtools') */
packageName: string
/** Display title shown on the marketplace card */
title: string
/** Short description of what the plugin does */
description?: string
/** URL to a logo image (SVG, PNG, etc.) */
logoUrl?: string
/** Required base package dependency */
requires?: {
/** Required package name (e.g., '@tanstack/react-query') */
packageName: string
/** Minimum required version (semver) */
minVersion: string
/** Maximum version (if there's a known breaking change) */
maxVersion?: string
}
/** Plugin import configuration -- enables one-click auto-install */
pluginImport?: {
/** The exact export name to import from the package
* (e.g., 'FormDevtoolsPlugin' or 'ReactQueryDevtoolsPanel') */
importName: string
/** 'jsx' = component rendered via { name, render: <Component /> }
* 'function' = called directly as FnName() in the plugins array */
type: 'jsx' | 'function'
}
/** Custom plugin ID for matching against registered plugins.
* The default behavior lowercases the package name and replaces
* non-alphanumeric characters with '-'.
* Example: pluginId: 'tanstack-form' matches 'tanstack-form-4'. */
pluginId?: string
/** URL to the plugin's documentation */
docsUrl?: string
/** Plugin author/maintainer */
author?: string
/** Repository URL */
repoUrl?: string
/** Framework this plugin supports */
framework: 'react' | 'solid' | 'vue' | 'svelte' | 'angular' | 'other'
/** Mark as featured -- appears in the Featured section with animated border.
* Reserved for official TanStack partners. */
featured?: boolean
/** Mark as new -- shows a "New" banner on the card */
isNew?: boolean
/** Tags for filtering and categorization */
tags?: Array<string>
}
```
### Required vs Optional Fields
Only two fields are strictly required by the TypeScript interface: `packageName`, `title`, and `framework`. In practice, always provide `requires`, `pluginImport`, and `description` -- without them the marketplace card is functional but auto-install cannot wire up the plugin.
## Registry Entry Examples
### React Plugin (function-based)
A function-based plugin exports a factory function that returns a plugin object. The auto-injector calls it as `FormDevtoolsPlugin()` inside the `plugins` array:
```ts
// In packages/devtools/src/tabs/plugin-registry.ts
'@acme/react-analytics-devtools': {
packageName: '@acme/react-analytics-devtools',
title: 'Acme Analytics Devtools',
description: 'Inspect analytics events, funnels, and session data',
requires: {
packageName: '@acme/react-analytics',
minVersion: '2.0.0',
},
pluginImport: {
importName: 'AnalyticsDevtoolsPlugin',
type: 'function',
},
pluginId: 'acme-analytics',
docsUrl: 'https://acme.dev/analytics/devtools',
repoUrl: 'https://github.com/acme/analytics',
author: 'Acme Corp',
framework: 'react',
isNew: true,
tags: ['Analytics', 'Tracking'],
},
```
When a user clicks "Install" in the marketplace, the Vite plugin:
1. Runs the package manager to install `@acme/react-analytics-devtools`
2. Finds the file containing `<TanStackDevtools />`
3. Adds `import { AnalyticsDevtoolsPlugin } from '@acme/react-analytics-devtools'`
4. Injects `AnalyticsDevtoolsPlugin()` into the `plugins` array
### React Plugin (JSX-based)
A JSX-based plugin exports a React component. The auto-injector wraps it in `{ name, render: <Component /> }`:
```ts
'@acme/react-state-devtools': {
packageName: '@acme/react-state-devtools',
title: 'Acme State Inspector',
description: 'Real-time state tree visualization',
requires: {
packageName: '@acme/react-state',
minVersion: '1.5.0',
},
pluginImport: {
importName: 'AcmeStateDevtoolsPanel',
type: 'jsx',
},
author: 'Acme Corp',
framework: 'react',
tags: ['State Management'],
},
```
The injected code looks like:
```tsx
import { AcmeStateDevtoolsPanel } from '@acme/react-state-devtools'
;<TanStackDevtools
plugins={[
{ name: 'Acme State Inspector', render: <AcmeStateDevtoolsPanel /> },
]}
/>
```
### Multi-Framework Submission (React + Solid)
When your devtools package supports multiple frameworks, add one entry per framework. Each entry is keyed by its own npm package name:
```ts
'@acme/react-analytics-devtools': {
packageName: '@acme/react-analytics-devtools',
title: 'Acme Analytics Devtools',
description: 'Inspect analytics events, funnels, and session data',
requires: {
packageName: '@acme/react-analytics',
minVersion: '2.0.0',
},
pluginImport: {
importName: 'AnalyticsDevtoolsPlugin',
type: 'function',
},
pluginId: 'acme-analytics',
author: 'Acme Corp',
framework: 'react',
isNew: true,
tags: ['Analytics', 'Tracking'],
},
'@acme/solid-analytics-devtools': {
packageName: '@acme/solid-analytics-devtools',
title: 'Acme Analytics Devtools',
description: 'Inspect analytics events, funnels, and session data',
requires: {
packageName: '@acme/solid-analytics',
minVersion: '2.0.0',
},
pluginImport: {
importName: 'AnalyticsDevtoolsPlugin',
type: 'function',
},
pluginId: 'acme-analytics',
author: 'Acme Corp',
framework: 'solid',
isNew: true,
tags: ['Analytics', 'Tracking'],
},
```
The marketplace auto-detects the user's framework from their `package.json` dependencies and shows only matching entries. Users can still browse other frameworks via the filter controls.
## How Auto-Install Works
The auto-install pipeline lives in `packages/devtools-vite/src/inject-plugin.ts`. Understanding it clarifies why `pluginImport` matters:
1. **Package installation** -- The Vite plugin detects the project's package manager and runs the appropriate install command.
2. **File detection** -- It scans project files for imports from `@tanstack/react-devtools`, `@tanstack/solid-devtools`, `@tanstack/vue-devtools`, etc.
3. **AST transformation** -- It parses the file with Babel, finds the `<TanStackDevtools />` JSX element, and modifies the `plugins` prop.
4. **Import insertion** -- It adds `import { <importName> } from '<packageName>'` after the last existing import.
5. **Plugin injection** -- Based on `pluginImport.type`:
- `'function'`: Appends `ImportName()` directly to the plugins array
- `'jsx'`: Appends `{ name: '<title>', render: <ImportName /> }` to the plugins array
If `pluginImport` is missing, step 3-5 are skipped entirely. The package gets installed but the user must manually wire it into the `plugins` prop.
## PR Submission Process
1. **Publish your package to npm.** The marketplace links to npm for installation; the package must be publicly available.
2. **Fork and clone** the [TanStack/devtools](https://github.com/TanStack/devtools) repository.
3. **Edit `packages/devtools/src/tabs/plugin-registry.ts`.** Add your entry to the `PLUGIN_REGISTRY` object under the `THIRD-PARTY PLUGINS` comment section:
```ts
// ==========================================
// THIRD-PARTY PLUGINS - Examples
// ==========================================
// External contributors can add their plugins below!
```
4. **Open a PR** against the `main` branch. Title format: `feat(marketplace): add <your-plugin-name>`.
5. **The PR will be reviewed** by TanStack maintainers. Common review feedback:
- Missing `pluginImport` -- reviewers will ask you to add it
- Missing `framework` -- required for marketplace filtering
- Missing `requires.minVersion` -- avoids runtime errors for users on older versions
- Incorrect `importName` -- must match the exact named export from your package
## Framework Detection
The marketplace determines the user's current framework by scanning their `package.json` dependencies for known framework packages:
| Framework | Detected packages |
| --------- | -------------------- |
| react | `react`, `react-dom` |
| solid | `solid-js` |
| vue | `vue`, `@vue/core` |
| svelte | `svelte` |
| angular | `@angular/core` |
Plugins with `framework: 'other'` are shown regardless of the detected framework.
## Featured Plugins
The `featured` field is reserved for official TanStack partners and select library authors. Featured plugins appear in a dedicated section at the top of the marketplace with an animated border.
To request featured status, email <partners+devtools@tanstack.com>.
Do not set `featured: true` in your PR submission -- it will be rejected. The TanStack team sets this flag.
## Plugin ID Matching
When the marketplace checks if a plugin is already active, it uses `pluginId` for matching. The matching logic in `packages/devtools/src/tabs/marketplace/plugin-utils.ts` does:
1. If `pluginId` is set, checks whether any registered plugin's ID starts with or contains the `pluginId` (case-insensitive).
2. Otherwise falls back to matching on `packageName` and extracting keyword segments.
Set a custom `pluginId` when your plugin registers with an ID that differs from the default (lowercased package name with non-alphanumeric characters replaced by `-`). For example, `@tanstack/react-form-devtools` registers as `tanstack-form-4` at runtime, so the registry entry uses `pluginId: 'tanstack-form'` to match it.
## Common Mistakes
### HIGH: Missing pluginImport metadata for auto-install
Without `pluginImport.importName` and `pluginImport.type`, the marketplace auto-install pipeline installs the npm package but cannot inject the plugin into the user's code. The user sees a successful install but the plugin tab never appears -- they must manually add the import and wire it into the `plugins` prop.
Wrong -- no pluginImport:
```ts
'@acme/react-analytics-devtools': {
packageName: '@acme/react-analytics-devtools',
title: 'Acme Analytics Devtools',
requires: {
packageName: '@acme/react-analytics',
minVersion: '2.0.0',
},
author: 'Acme Corp',
framework: 'react',
},
```
Correct -- pluginImport provided:
```ts
'@acme/react-analytics-devtools': {
packageName: '@acme/react-analytics-devtools',
title: 'Acme Analytics Devtools',
requires: {
packageName: '@acme/react-analytics',
minVersion: '2.0.0',
},
pluginImport: {
importName: 'AnalyticsDevtoolsPlugin',
type: 'function',
},
author: 'Acme Corp',
framework: 'react',
},
```
The `importName` must be the exact named export from your package. The `type` must match how the export is consumed:
- `'function'` if your export is a factory like `export function AnalyticsDevtoolsPlugin() { return { name: '...', ... } }`
- `'jsx'` if your export is a component like `export function AnalyticsDevtoolsPanel() { return <div>...</div> }`
### MEDIUM: Not specifying requires.minVersion
When `requires` is present but `minVersion` is omitted or set too low, users running older versions of the base package get runtime errors when the devtools plugin tries to access APIs that do not exist in their version.
Wrong -- missing minVersion:
```ts
requires: {
packageName: '@acme/react-analytics',
},
```
This does not type-check -- `minVersion` is a required field inside `requires`. But setting it to `'0.0.0'` or an arbitrarily low version has the same practical effect: the marketplace shows the plugin as installable even when the user's version lacks the APIs your devtools plugin depends on.
Correct -- specify the actual minimum version your plugin is tested against:
```ts
requires: {
packageName: '@acme/react-analytics',
minVersion: '2.0.0',
},
```
If there is a known breaking change in a later version, also set `maxVersion`:
```ts
requires: {
packageName: '@acme/react-analytics',
minVersion: '2.0.0',
maxVersion: '3.0.0',
},
```
The marketplace uses semver comparison (`packages/devtools/src/tabs/semver-utils.ts`) to determine if the user's installed version satisfies the range. When it does not, the card shows a "Bump Version" action instead of "Install".
### MEDIUM: Submitting without framework field
The `framework` field enables marketplace filtering. Without it (or with it set incorrectly), users cannot find your plugin when browsing by framework, and the marketplace cannot determine whether to show it for the current project.
The framework is required by the TypeScript interface, so omitting it is a compile error. The real mistake is setting it to `'other'` when the plugin is framework-specific. A React-only plugin tagged `'other'` will appear for Solid, Vue, and Angular users who cannot use it.
Wrong:
```ts
framework: 'other', // but the plugin only works with React
```
Correct:
```ts
framework: 'react',
```
Use `'other'` only for truly framework-agnostic plugins that work in any environment.
## See Also
- **devtools-plugin-panel** -- Build a working devtools plugin panel before submitting to the marketplace
- **devtools-app-setup** -- TanStackDevtools component setup, plugins prop format, framework adapters
@@ -0,0 +1,429 @@
---
name: devtools-plugin-panel
description: >
Build devtools panel components that display emitted event data. Listen via
EventClient.on(), handle theme (light/dark), use @tanstack/devtools-ui
components. Plugin registration (name, render, id, defaultOpen), lifecycle
(mount, activate, destroy), max 3 active plugins. Two paths: Solid.js core
with devtools-ui for multi-framework support, or framework-specific panels.
type: core
library: tanstack-devtools
library_version: '0.10.12'
requires:
- devtools-event-client
sources:
- 'TanStack/devtools:docs/building-custom-plugins.md'
- 'TanStack/devtools:docs/plugin-lifecycle.md'
- 'TanStack/devtools:docs/plugin-configuration.md'
- 'TanStack/devtools:packages/devtools/src/context/devtools-context.tsx'
---
## TanStackDevtoolsPlugin Interface
The low-level contract every plugin implements. Framework adapters wrap this automatically.
```ts
// Source: packages/devtools/src/context/devtools-context.tsx
interface TanStackDevtoolsPlugin {
id?: string
name: string | ((el: HTMLHeadingElement, theme: 'dark' | 'light') => void)
render: (el: HTMLDivElement, theme: 'dark' | 'light') => void
destroy?: (pluginId: string) => void
defaultOpen?: boolean
}
```
- **`name`** (required) -- String tab title, or function receiving `(el, theme)` for custom rendering.
- **`render`** (required) -- Called on activation with a `<div>` container and theme. Called again on theme change.
- **`id`** (optional) -- Stable identifier. If omitted: `name.toLowerCase().replace(' ', '-')-{index}`. Explicit ids persist selection across reloads.
- **`defaultOpen`** (optional) -- Opens panel on first load when no saved state. Max 3 open. Does not override saved preferences.
- **`destroy`** (optional) -- Called on deactivation or unmount. Framework adapters handle cleanup automatically.
---
## Two Development Paths
### Path 1: Solid.js Core + Framework Adapters (Multi-Framework)
Build the panel in Solid.js using `@tanstack/devtools-ui` components. Use `constructCoreClass` for lazy loading, then `createReactPanel`/`createSolidPanel` to wrap for each framework. The devtools core is Solid, so Solid panels run natively.
### Path 2: Framework-Specific Panel (Single Framework)
Build directly in your framework and use `createReactPlugin`/`createVuePlugin`/`createSolidPlugin`/`createPreactPlugin` from `@tanstack/devtools-utils`.
---
## Path 1: Solid.js Core Panel
### Step 1: Define Event Map and Create EventClient
```ts
// src/event-client.ts
import { EventClient } from '@tanstack/devtools-event-client'
type StoreEvents = {
'state-changed': { storeName: string; state: unknown; timestamp: number }
'action-dispatched': { storeName: string; action: string; payload: unknown }
reset: void
}
class StoreInspectorClient extends EventClient<StoreEvents> {
constructor() {
super({ pluginId: 'store-inspector' })
}
}
export const storeInspector = new StoreInspectorClient()
```
Event names are suffixes only. The `pluginId` is prepended automatically: `'store-inspector:state-changed'`.
### Step 2: Build the Solid.js Panel Component
```tsx
/** @jsxImportSource solid-js */
import { createSignal, onCleanup, For } from 'solid-js'
import {
MainPanel,
Header,
HeaderLogo,
Section,
SectionTitle,
JsonTree,
Button,
Tag,
useTheme,
} from '@tanstack/devtools-ui'
import { storeInspector } from './event-client'
export default function StoreInspectorPanel() {
const { theme } = useTheme()
const [state, setState] = createSignal<Record<string, unknown>>({})
const [actions, setActions] = createSignal<
Array<{ action: string; payload: unknown }>
>([])
const cleanupState = storeInspector.on('state-changed', (e) => {
setState((prev) => ({ ...prev, [e.payload.storeName]: e.payload.state }))
})
const cleanupActions = storeInspector.on('action-dispatched', (e) => {
setActions((prev) => [
...prev,
{ action: e.payload.action, payload: e.payload.payload },
])
})
onCleanup(() => {
cleanupState()
cleanupActions()
})
return (
<MainPanel>
<Header>
<HeaderLogo flavor={{ light: '#1a1a2e', dark: '#e0e0e0' }}>
Store Inspector
</HeaderLogo>
</Header>
<Section>
<SectionTitle>Current State</SectionTitle>
<JsonTree value={state()} copyable defaultExpansionDepth={2} />
</Section>
<Section>
<SectionTitle>
Action Log
<Tag color="purple" label="Actions" count={actions().length} />
</SectionTitle>
<For each={actions()}>
{(a) => (
<div>
<strong>{a.action}</strong>
<JsonTree value={a.payload} copyable defaultExpansionDepth={1} />
</div>
)}
</For>
<Button variant="danger" onClick={() => setActions([])}>
Clear Log
</Button>
</Section>
</MainPanel>
)
}
```
### Step 3: Create Core Class and Framework Adapters
```ts
// src/core.ts
import { constructCoreClass } from '@tanstack/devtools-utils/solid/class'
export const [StoreInspectorCore, NoOpStoreInspectorCore] = constructCoreClass(
() => import('./panel'),
)
```
```tsx
// src/react.tsx
import { createReactPanel } from '@tanstack/devtools-utils/react'
import { StoreInspectorCore } from './core'
export const [StoreInspectorPanel, NoOpStoreInspectorPanel] =
createReactPanel(StoreInspectorCore)
```
```tsx
// src/react-plugin.tsx
import { createReactPlugin } from '@tanstack/devtools-utils/react'
import { StoreInspectorPanel } from './react'
export const [StoreInspectorPlugin, NoOpStoreInspectorPlugin] =
createReactPlugin({
name: 'Store Inspector',
id: 'store-inspector',
defaultOpen: true,
Component: StoreInspectorPanel,
})
```
### Step 4: Register
```tsx
import { TanStackDevtools } from '@tanstack/react-devtools'
import { StoreInspectorPlugin } from 'your-package/react-plugin'
function App() {
return (
<>
<YourApp />
<TanStackDevtools plugins={[StoreInspectorPlugin()]} />
</>
)
}
```
---
## Path 2: Framework-Specific Panel (React Example)
```tsx
import { useState, useEffect } from 'react'
import { EventClient } from '@tanstack/devtools-event-client'
import { createReactPlugin } from '@tanstack/devtools-utils/react'
type MyEvents = {
'data-update': { items: Array<{ id: string; value: number }> }
}
class MyPluginClient extends EventClient<MyEvents> {
constructor() {
super({ pluginId: 'my-plugin' })
}
}
export const myPlugin = new MyPluginClient()
function MyPluginPanel({ theme }: { theme?: 'light' | 'dark' }) {
const [items, setItems] = useState<Array<{ id: string; value: number }>>([])
useEffect(() => {
const cleanup = myPlugin.on('data-update', (e) => {
setItems(e.payload.items)
})
return cleanup
}, [])
return (
<div style={{ color: theme === 'dark' ? '#fff' : '#000' }}>
<h3>My Plugin</h3>
<ul>
{items.map((item) => (
<li key={item.id}>
{item.id}: {item.value}
</li>
))}
</ul>
</div>
)
}
export const [MyPlugin, NoOpMyPlugin] = createReactPlugin({
name: 'My Plugin',
id: 'my-plugin',
defaultOpen: false,
Component: MyPluginPanel,
})
```
---
## Plugin Lifecycle Sequence
1. **Initialization** -- `TanStackDevtoolsCore` receives `plugins` array. Each plugin gets an `id` (explicit or generated).
2. **DOM containers created** -- Core creates `<div id="plugin-container-{id}">` and `<h3 id="plugin-title-container-{id}">` per plugin.
3. **Activation** -- On tab click or `defaultOpen`, `plugin.render(container, theme)` called.
4. **Framework portaling** -- React uses `createPortal`, Solid uses `<Portal>`, Vue uses `<Teleport>`.
5. **Theme change** -- `render` called again with new theme value.
6. **Deactivation/Unmount** -- `destroy(pluginId)` called if provided. Framework adapters handle cleanup.
Active plugin selection persisted in `localStorage` under key `tanstack_devtools_state`.
---
## Common Mistakes
### CRITICAL: Not Cleaning Up Event Listeners
Each `on()` returns a cleanup function. Forgetting it causes memory leaks and duplicate handlers.
Wrong:
```ts
useEffect(() => {
client.on('state', cb)
}, [])
```
Correct:
```ts
useEffect(() => {
const cleanup = client.on('state', cb)
return cleanup
}, [])
```
In Solid, use `onCleanup()`:
```ts
const cleanup = storeInspector.on('state-changed', handler)
onCleanup(cleanup)
```
Source: docs/building-custom-plugins.md
### HIGH: Oversubscribing to Events in Multiple Components
Do not call `on()` in multiple components for the same event. Subscribe once in a shared store/hook.
Wrong:
```ts
function ComponentA() {
useEffect(() => {
const c = client.on('state', cb1)
return c
}, [])
}
function ComponentB() {
useEffect(() => {
const c = client.on('state', cb2)
return c
}, [])
}
```
Correct:
```ts
function useStoreState() {
const [state, setState] = useState(null)
useEffect(() => {
const cleanup = client.on('state', (e) => setState(e.payload))
return cleanup
}, [])
return state
}
```
Source: maintainer interview
### MEDIUM: Hardcoding Repeated Event Payload Fields
When emitting events that share common fields, create a shared base object.
Wrong:
```ts
client.emit('state-changed', { storeName: 'main', version: '1.0', state })
client.emit('action-dispatched', { storeName: 'main', version: '1.0', action })
```
Correct:
```ts
const base = { storeName: 'main', version: '1.0' }
client.emit('state-changed', { ...base, state })
client.emit('action-dispatched', { ...base, action })
```
Source: maintainer interview
### MEDIUM: Ignoring Theme Prop in Panel Component
Panels must adapt styling to theme. Factory-created plugins receive `props.theme`.
Wrong:
```tsx
function MyPanel() {
return <div style={{ color: 'white' }}>Always white text</div>
}
```
Correct:
```tsx
function MyPanel({ theme }: { theme?: 'light' | 'dark' }) {
return (
<div style={{ color: theme === 'dark' ? '#e0e0e0' : '#1a1a1a' }}>
Theme-aware text
</div>
)
}
```
In Solid panels using devtools-ui, use `useTheme()` instead of prop drilling.
Source: docs/plugin-lifecycle.md
### MEDIUM: Not Knowing Max 3 Active Plugins Limit
`MAX_ACTIVE_PLUGINS = 3` (in `packages/devtools/src/utils/constants.ts`). If more than 3 set `defaultOpen: true`, only the first 3 open. Activating a 4th deactivates the earliest. Single-plugin exception: if only 1 plugin is registered, it opens automatically.
Source: packages/devtools/src/utils/get-default-active-plugins.ts
### MEDIUM: Using Raw DOM Manipulation Instead of Framework Portals
Framework adapters handle portaling. Do not manually manipulate DOM.
Wrong:
```ts
render: (el) => {
const div = document.createElement('div')
div.textContent = 'Hello'
el.appendChild(div)
}
```
Correct:
```tsx
import { createReactPlugin } from '@tanstack/devtools-utils/react'
const [Plugin, NoOpPlugin] = createReactPlugin({
name: 'My Plugin',
Component: ({ theme }) => <div>Hello</div>,
})
```
Source: docs/plugin-lifecycle.md
### MEDIUM: Not Keeping Devtools Packages at Latest Versions
All `@tanstack/devtools-*` packages should be on compatible versions. For external plugins, pin to compatible ranges.
Source: maintainer interview
## References
- [devtools-ui components and API](references/panel-api.md)
@@ -0,0 +1,136 @@
# Plugin Panel API Reference
## Plugin Factory Functions
All factories return `[Plugin, NoOpPlugin]` tuples for production tree-shaking.
| Factory | Import Path | Framework |
| -------------------- | -------------------------------------- | ------------------------ |
| `createReactPlugin` | `@tanstack/devtools-utils/react` | React |
| `createSolidPlugin` | `@tanstack/devtools-utils/solid` | Solid.js |
| `createVuePlugin` | `@tanstack/devtools-utils/vue` | Vue 3 |
| `createPreactPlugin` | `@tanstack/devtools-utils/preact` | Preact |
| `createReactPanel` | `@tanstack/devtools-utils/react` | React (wraps Solid core) |
| `createSolidPanel` | `@tanstack/devtools-utils/solid` | Solid (wraps Solid core) |
| `constructCoreClass` | `@tanstack/devtools-utils/solid/class` | Core class construction |
### createReactPlugin / createSolidPlugin / createPreactPlugin
```ts
function createReactPlugin(config: {
name: string
id?: string
defaultOpen?: boolean
Component: (props: { theme?: 'light' | 'dark' }) => JSX.Element
}): readonly [() => PluginConfig, () => PluginConfig]
```
### createVuePlugin
```ts
function createVuePlugin<TComponentProps extends Record<string, any>>(
name: string,
component: DefineComponent<TComponentProps, {}, unknown>,
): readonly [
(props: TComponentProps) => {
name: string
component: DefineComponent
props: TComponentProps
},
(props: TComponentProps) => {
name: string
component: Fragment
props: TComponentProps
},
]
```
Vue uses positional `(name, component)` args, not an options object.
---
## devtools-ui Components
All components are Solid.js. Use in Path 1 (Solid core) panels only.
| Component | Purpose |
| ---------------------- | -------------------------------------------------------------------------------------------- |
| `MainPanel` | Root container with optional padding |
| `Header` | Top header bar |
| `HeaderLogo` | Logo section; accepts `flavor` colors |
| `Section` | Content section wrapper |
| `SectionTitle` | `<h3>` section heading |
| `SectionDescription` | `<p>` description text |
| `SectionIcon` | Icon wrapper in sections |
| `JsonTree` | Expandable JSON tree viewer with copy support |
| `Button` | Variants: primary, secondary, danger, success, info, warning; supports `outline` and `ghost` |
| `Tag` | Colored label tag with optional count badge |
| `Select` | Dropdown select with label and description |
| `Input` | Text input |
| `Checkbox` | Checkbox input |
| `TanStackLogo` | TanStack logo SVG |
| `ThemeContextProvider` | Wraps children with theme context |
| `useTheme` | Returns `{ theme: Accessor<Theme>, setTheme }` -- must be inside ThemeContextProvider |
### JsonTree Props
```ts
function JsonTree<TData>(props: {
value: TData
copyable?: boolean
defaultExpansionDepth?: number // default: 1
collapsePaths?: Array<string>
config?: { dateFormat?: string }
}): JSX.Element
```
---
## EventClient API (Quick Reference)
```ts
class EventClient<TEventMap extends Record<string, any>> {
constructor(config: {
pluginId: string
debug?: boolean // default: false
enabled?: boolean // default: true
reconnectEveryMs?: number // default: 300
})
emit<TEvent extends keyof TEventMap & string>(
eventSuffix: TEvent,
payload: TEventMap[TEvent],
): void
on<TEvent extends keyof TEventMap & string>(
eventSuffix: TEvent,
cb: (event: {
type: TEvent
payload: TEventMap[TEvent]
pluginId?: string
}) => void,
options?: { withEventTarget?: boolean },
): () => void
onAll(cb: (event: { type: string; payload: any }) => void): () => void
onAllPluginEvents(
cb: (event: AllDevtoolsEvents<TEventMap>) => void,
): () => void
getPluginId(): string
}
```
---
## Key Source Files
| File | Purpose |
| ----------------------------------------------------------- | -------------------------------------------------------- |
| `packages/devtools/src/context/devtools-context.tsx` | `TanStackDevtoolsPlugin` interface, plugin ID generation |
| `packages/devtools/src/core.ts` | `TanStackDevtoolsCore` class |
| `packages/devtools/src/utils/constants.ts` | `MAX_ACTIVE_PLUGINS = 3` |
| `packages/devtools/src/utils/get-default-active-plugins.ts` | defaultOpen resolution logic |
| `packages/event-bus-client/src/plugin.ts` | `EventClient` class |
| `packages/devtools-utils/src/solid/class.ts` | `constructCoreClass` |
| `packages/devtools-ui/src/index.ts` | All UI component exports |
| `packages/devtools-ui/src/components/theme.tsx` | `ThemeContextProvider`, `useTheme` |
+459
View File
@@ -0,0 +1,459 @@
---
name: devtools-production
description: >
Handle devtools in production vs development. removeDevtoolsOnBuild,
devDependency vs regular dependency, conditional imports, NoOp plugin
variants for tree-shaking, non-Vite production exclusion patterns.
type: lifecycle
library: '@tanstack/devtools'
library_version: '0.10.12'
requires: devtools-app-setup
sources:
- docs/production.md
- docs/vite-plugin.md
- packages/devtools-vite/src/plugin.ts
- packages/devtools-vite/src/remove-devtools.ts
- packages/devtools/package.json
- packages/devtools/tsup.config.ts
- packages/devtools-utils/src/react/plugin.tsx
- packages/devtools-utils/src/react/panel.tsx
---
# TanStack Devtools Production Handling
> **Prerequisite:** Read the **devtools-app-setup** skill first. The initial setup decisions (framework adapter, Vite plugin, dependency type) directly determine which production strategy applies.
## How Production Stripping Works
TanStack Devtools has two independent mechanisms for keeping devtools out of production bundles. Understanding both is essential because they serve different project types.
### Mechanism 1: Vite Plugin Auto-Stripping (Vite projects)
The `@tanstack/devtools-vite` plugin includes a sub-plugin named `@tanstack/devtools:remove-devtools-on-build`. When `removeDevtoolsOnBuild` is `true` (the default), this plugin runs during `vite build` and any non-`serve` command where the mode is `production`.
It uses Babel to parse every source file, find imports from these packages, and remove them along with any JSX elements they produce:
- `@tanstack/react-devtools`
- `@tanstack/preact-devtools`
- `@tanstack/solid-devtools`
- `@tanstack/devtools`
The stripping is AST-based. It removes the import declaration, then finds and removes any JSX elements whose tag name matches one of the imported identifiers. It also traces plugin references inside the `plugins` prop array and removes their imports if they become unused.
Source: `packages/devtools-vite/src/remove-devtools.ts`
This means for a standard Vite project, the default setup from **devtools-app-setup** already handles production correctly with zero additional configuration:
```tsx
// This import and JSX element are completely removed from the production build
import { TanStackDevtools } from '@tanstack/react-devtools'
function App() {
return (
<>
<YourApp />
<TanStackDevtools
plugins={
[
/* ... */
]
}
/>
</>
)
}
```
### Mechanism 2: Conditional Exports (package.json)
The `@tanstack/devtools` core package uses Node.js conditional exports to serve different bundles based on the environment:
```json
{
"exports": {
"workerd": { "import": "./dist/server.js" },
"browser": {
"development": { "import": "./dist/dev.js" },
"import": "./dist/index.js"
},
"node": { "import": "./dist/server.js" }
}
}
```
Key points:
- `browser` + `development` condition resolves to `dev.js` (dev-only extras).
- `browser` without `development` resolves to `index.js` (production build).
- `node` and `workerd` resolve to `server.js` (server-safe, no DOM).
These are built via `tsup-preset-solid` with `dev_entry: true` and `server_entry: true` in `packages/devtools/tsup.config.ts`.
## The Two Workflows
### Development-Only Workflow (Default, Recommended)
This is the standard path from **devtools-app-setup**. Devtools are present during `vite dev` and stripped automatically on `vite build`.
**Install as dev dependencies:**
```bash
npm install -D @tanstack/react-devtools @tanstack/devtools-vite
```
**Vite config -- default behavior:**
```ts
import { devtools } from '@tanstack/devtools-vite'
import react from '@vitejs/plugin-react'
export default {
plugins: [
devtools(), // removeDevtoolsOnBuild defaults to true
react(),
],
}
```
**Application code -- no guards needed:**
```tsx
import { TanStackDevtools } from '@tanstack/react-devtools'
function App() {
return (
<>
<YourApp />
<TanStackDevtools
plugins={
[
/* ... */
]
}
/>
</>
)
}
```
The Vite plugin handles everything. The import and JSX are removed from the production build. Since the packages are dev dependencies, they are not even available in a production `node_modules` after `npm install --production`.
### Production Workflow (Intentional)
When you deliberately want devtools accessible in a deployed application. This requires three changes from the default setup.
**1. Install as regular dependencies (not `-D`):**
```bash
npm install @tanstack/react-devtools @tanstack/devtools-vite
```
This ensures the packages are available in production `node_modules`.
**2. Disable auto-stripping in the Vite config:**
```ts
import { devtools } from '@tanstack/devtools-vite'
import react from '@vitejs/plugin-react'
export default {
plugins: [
devtools({
removeDevtoolsOnBuild: false,
}),
react(),
],
}
```
**3. Application code remains the same:**
```tsx
import { TanStackDevtools } from '@tanstack/react-devtools'
function App() {
return (
<>
<YourApp />
<TanStackDevtools
plugins={
[
/* ... */
]
}
/>
</>
)
}
```
With `removeDevtoolsOnBuild: false`, the Vite build plugin skips the AST stripping pass entirely, so all devtools code ships to production.
You can combine this with `requireUrlFlag` from the shell config to hide the devtools UI unless a URL parameter is present:
```tsx
<TanStackDevtools
config={{
requireUrlFlag: true,
urlFlag: 'debug', // visit ?debug to show devtools
}}
plugins={
[
/* ... */
]
}
/>
```
## Non-Vite Projects
Without the Vite plugin, there is no automatic stripping. You must manually prevent devtools from entering production bundles using one of these strategies.
### Strategy A: Conditional Dynamic Import
Create a separate file for devtools setup, then conditionally import it:
```tsx
// devtools-setup.tsx
import { TanStackDevtools } from '@tanstack/react-devtools'
export default function Devtools() {
return (
<TanStackDevtools
plugins={
[
// your plugins
]
}
/>
)
}
```
```tsx
// App.tsx
const Devtools =
process.env.NODE_ENV === 'development'
? (await import('./devtools-setup')).default
: () => null
function App() {
return (
<>
<YourApp />
<Devtools />
</>
)
}
```
When `NODE_ENV` is `'production'`, bundlers eliminate the dead `import()` path. The devtools-setup module and all its transitive dependencies are never included in the bundle.
### Strategy B: Bundler-Specific Dead Code Elimination
For bundlers that support define/replace plugins (webpack `DefinePlugin`, esbuild `define`, Rollup `@rollup/plugin-replace`), wrap the import in a condition that the bundler can statically evaluate:
```tsx
// webpack example with DefinePlugin
let DevtoolsComponent: React.ComponentType = () => null
if (__DEV__) {
const { TanStackDevtools } = await import('@tanstack/react-devtools')
DevtoolsComponent = () => (
<TanStackDevtools
plugins={
[
/* ... */
]
}
/>
)
}
function App() {
return (
<>
<YourApp />
<DevtoolsComponent />
</>
)
}
```
The key requirement is that the condition must be statically resolvable by the bundler. `process.env.NODE_ENV === 'development'` works for most bundlers. Framework-specific globals like `__DEV__` also work.
## NoOp Plugin Variants for Tree-Shaking
When building reusable plugin packages with `@tanstack/devtools-utils`, the factory functions return a `[Plugin, NoOpPlugin]` tuple. The `NoOpPlugin` renders an empty fragment and carries no real dependencies. This is the primary mechanism for library authors to make their plugins tree-shakable.
```tsx
import { createReactPlugin } from '@tanstack/devtools-utils/react'
const [QueryPlugin, QueryNoOpPlugin] = createReactPlugin({
name: 'TanStack Query',
Component: ({ theme }) => <QueryDevtoolsPanel theme={theme} />,
})
// The library exports both, and consumers choose:
export { QueryPlugin, QueryNoOpPlugin }
```
Consumer code uses the NoOp variant in production:
```tsx
import { QueryPlugin, QueryNoOpPlugin } from '@tanstack/query-devtools'
const ActivePlugin =
process.env.NODE_ENV === 'development' ? QueryPlugin : QueryNoOpPlugin
function App() {
return <TanStackDevtools plugins={[ActivePlugin()]} />
}
```
The NoOp pattern exists for every framework adapter:
| Framework | Factory | Source |
| ------------- | -------------------- | ----------------------------------------------- |
| React | `createReactPlugin` | `packages/devtools-utils/src/react/plugin.tsx` |
| React (panel) | `createReactPanel` | `packages/devtools-utils/src/react/panel.tsx` |
| Preact | `createPreactPlugin` | `packages/devtools-utils/src/preact/plugin.tsx` |
| Solid | `createSolidPlugin` | `packages/devtools-utils/src/solid/plugin.tsx` |
| Vue | `createVuePlugin` | `packages/devtools-utils/src/vue/plugin.ts` |
All return `readonly [Plugin, NoOpPlugin]`. The `NoOpPlugin` always has the same metadata (`name`, `id`, `defaultOpen`) but its render function produces an empty fragment, so the bundler can tree-shake the real panel component and all its dependencies.
See the **devtools-framework-adapters** skill for the full factory API details.
## Common Mistakes
### HIGH: Keeping devtools in production without disabling stripping
The Vite plugin's `removeDevtoolsOnBuild` defaults to `true`. If you want devtools in production, you must both disable stripping AND install as a regular dependency. Missing either step causes failure.
**Wrong -- devtools stripped despite wanting them in production:**
```ts
// vite.config.ts
export default {
plugins: [
devtools(), // removeDevtoolsOnBuild defaults to true -- code is stripped
react(),
],
}
```
```bash
# package.json has devtools as devDependency
npm install -D @tanstack/react-devtools
```
**Correct -- both changes together:**
```ts
// vite.config.ts
export default {
plugins: [devtools({ removeDevtoolsOnBuild: false }), react()],
}
```
```bash
# regular dependency so it's available in production node_modules
npm install @tanstack/react-devtools
```
Missing `removeDevtoolsOnBuild: false` causes the AST stripping to remove all devtools imports and JSX at build time. Missing the regular dependency means `node_modules` may not contain the package in production environments that prune dev dependencies.
### HIGH: Non-Vite projects not excluding devtools manually
Without the Vite plugin, devtools code is never automatically stripped. If you import `TanStackDevtools` unconditionally, the entire devtools shell and all plugin panels ship to production.
**Wrong -- always imports devtools regardless of environment:**
```tsx
import { TanStackDevtools } from '@tanstack/react-devtools'
function App() {
return (
<>
<YourApp />
<TanStackDevtools
plugins={
[
/* ... */
]
}
/>
</>
)
}
```
**Correct -- conditional import based on NODE_ENV:**
```tsx
const Devtools =
process.env.NODE_ENV === 'development'
? (await import('./devtools-setup')).default
: () => null
function App() {
return (
<>
<YourApp />
<Devtools />
</>
)
}
```
The conditional must be statically evaluable by your bundler so it can eliminate the dead branch. Using a separate file for the devtools setup ensures the entire module subgraph is tree-shaken.
### MEDIUM: Not using NoOp variants in plugin libraries
When building a reusable plugin package, exporting only the `Plugin` function (ignoring the `NoOpPlugin` from the tuple) means consumers have no lightweight alternative for production builds.
**Wrong -- NoOp variant discarded:**
```tsx
const [MyPlugin] = createReactPlugin({
name: 'Store Inspector',
Component: StoreInspectorPanel,
})
export { MyPlugin }
```
**Correct -- both variants exported:**
```tsx
const [MyPlugin, MyNoOpPlugin] = createReactPlugin({
name: 'Store Inspector',
Component: StoreInspectorPanel,
})
export { MyPlugin, MyNoOpPlugin }
```
Consumers then choose the appropriate variant based on their environment. Without the NoOp export, the only way to exclude the plugin is to not import the package at all, which requires the conditional-import pattern at the application level.
## Design Tension
Development convenience pulls toward automatic stripping (dev dependencies, Vite plugin handles everything). Production usage pulls toward explicit inclusion (regular dependencies, disabled stripping, URL flag gating). These two paths are mutually exclusive in their dependency and configuration choices. A project must commit to one path. Attempting to mix them -- for example, keeping devtools as a dev dependency while setting `removeDevtoolsOnBuild: false` -- leads to builds that fail silently when the production environment prunes dev dependencies.
For staging/preview environments where you want devtools but not in the final production deployment, use `requireUrlFlag` with the development-only workflow intact, rather than switching to the production workflow.
## Cross-References
- **devtools-app-setup** -- Initial setup decisions (framework, install command, Vite plugin placement) that this skill builds on.
- **devtools-vite-plugin** -- The `removeDevtoolsOnBuild` option and AST stripping logic live in the Vite plugin. See that skill for all Vite plugin configuration.
- **devtools-framework-adapters** -- The `[Plugin, NoOpPlugin]` tuple pattern and all framework-specific factory APIs.
## Key Source Files
- `packages/devtools-vite/src/plugin.ts` -- Vite plugin entry, `removeDevtoolsOnBuild` option, sub-plugin registration
- `packages/devtools-vite/src/remove-devtools.ts` -- AST-based stripping logic (Babel parse, traverse, codegen)
- `packages/devtools/package.json` -- Conditional exports (`browser.development` -> `dev.js`, `browser` -> `index.js`, `node`/`workerd` -> `server.js`)
- `packages/devtools/tsup.config.ts` -- Build config producing `dev.js`, `index.js`, `server.js` via `tsup-preset-solid`
- `packages/devtools-utils/src/react/plugin.tsx` -- `createReactPlugin` returning `[Plugin, NoOpPlugin]`
- `packages/devtools-utils/src/react/panel.tsx` -- `createReactPanel` returning `[Panel, NoOpPanel]`
@@ -0,0 +1,312 @@
---
name: devtools-vite-plugin
description: >
Configure @tanstack/devtools-vite for source inspection (data-tsd-source,
inspectHotkey, ignore patterns), console piping (client-to-server,
server-to-client, levels), enhanced logging, server event bus (port, host,
HTTPS), production stripping (removeDevtoolsOnBuild), editor integration
(launch-editor, custom editor.open). Must be FIRST plugin in Vite config.
Vite ^6 || ^7 only.
type: core
library: tanstack-devtools
library_version: '0.10.12'
sources:
- 'TanStack/devtools:docs/vite-plugin.md'
- 'TanStack/devtools:docs/source-inspector.md'
- 'TanStack/devtools:packages/devtools-vite/src/plugin.ts'
---
Configure @tanstack/devtools-vite -- the Vite plugin that enhances TanStack Devtools with source inspection, console piping, enhanced logging, a server event bus, production stripping, editor integration, and a plugin marketplace. The plugin returns an array of sub-plugins, all using `enforce: 'pre'`, so it must be the FIRST plugin in the Vite config.
## Installation and Basic Setup
```ts
// vite.config.ts
import { devtools } from '@tanstack/devtools-vite'
export default {
plugins: [
devtools(),
// ... other plugins AFTER devtools
],
}
```
Install as a dev dependency:
```sh
pnpm add -D @tanstack/devtools-vite
```
There is also a `defineDevtoolsConfig` helper for type-safe config objects:
```ts
import { devtools, defineDevtoolsConfig } from '@tanstack/devtools-vite'
const config = defineDevtoolsConfig({
// fully typed options
})
export default {
plugins: [devtools(config)],
}
```
## Exports
From `packages/devtools-vite/src/index.ts`:
- `devtools` -- main plugin factory, returns `Array<Plugin>`
- `defineDevtoolsConfig` -- identity function for type-safe config
- `TanStackDevtoolsViteConfig` -- config type (re-exported)
- `ConsoleLevel` -- `'log' | 'warn' | 'error' | 'info' | 'debug'`
## Architecture: Sub-Plugins
`devtools()` returns an array of Vite plugins. Each has `enforce: 'pre'` and only activates when its conditions are met (dev mode, serve command, etc.).
| Sub-plugin name | What it does | When active |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- |
| `@tanstack/devtools:inject-source` | Babel transform adding `data-tsd-source` attrs to JSX | dev mode + `injectSource.enabled` |
| `@tanstack/devtools:config` | Reserved for future config modifications | serve command only |
| `@tanstack/devtools:custom-server` | Starts ServerEventBus, registers middleware for open-source/console-pipe endpoints | dev mode |
| `@tanstack/devtools:remove-devtools-on-build` | Strips devtools imports/JSX from production bundles | build command or production mode + `removeDevtoolsOnBuild` |
| `@tanstack/devtools:event-client-setup` | Marketplace: listens for install/add-plugin events via devtoolsEventClient | dev mode + serve + not CI |
| `@tanstack/devtools:console-pipe-transform` | Injects runtime console-pipe code into entry files | dev mode + serve + `consolePiping.enabled` |
| `@tanstack/devtools:better-console-logs` | Babel transform prepending source location to `console.log`/`console.error` | dev mode + `enhancedLogs.enabled` |
| `@tanstack/devtools:inject-plugin` | Detects which file imports TanStackDevtools (for marketplace injection) | dev mode + serve |
| `@tanstack/devtools:connection-injection` | Replaces `__TANSTACK_DEVTOOLS_PORT__`, `__TANSTACK_DEVTOOLS_HOST__`, `__TANSTACK_DEVTOOLS_PROTOCOL__` placeholders | dev mode + serve |
## Subsystem Details
### Source Injection
Adds `data-tsd-source="<relative-path>:<line>:<column>"` attributes to every JSX opening element via Babel. This powers the "Go to Source" feature -- hold the inspect hotkey (default: Shift+Alt+Ctrl/Meta), hover over elements, click to open in editor.
**Key behaviors:**
- Skips `<Fragment>` and `<React.Fragment>`
- Skips elements where the component's props parameter is spread (`{...props}`) -- this is because injecting the attribute would be overwritten by the spread
- Skips files matching `injectSource.ignore.files` patterns
- Skips components matching `injectSource.ignore.components` patterns
- Patterns can be strings (matched via picomatch) or RegExp
- Transform filter excludes `node_modules`, `?raw` imports, `/dist/`, `/build/`
**Source files:** `packages/devtools-vite/src/inject-source.ts`, `packages/devtools-vite/src/matcher.ts`
```ts
devtools({
injectSource: {
enabled: true,
ignore: {
files: ['node_modules', /.*\.test\.(js|ts|jsx|tsx)$/],
components: ['InternalComponent', /.*Provider$/],
},
},
})
```
### Console Piping
Bidirectional console piping between client and server. Injects runtime code (IIFE) into entry files that:
**Client side:**
1. Wraps `console[level]` to batch and POST entries to `/__tsd/console-pipe`
2. Opens an EventSource on `/__tsd/console-pipe/sse` to receive server logs
3. Server logs appear in browser console with a purple `[Server]` prefix
4. Client logs appear in terminal with a cyan `[Client]` prefix
**Server side (SSR/Nitro):**
1. Wraps `console[level]` to batch and POST entries to `<viteServerUrl>/__tsd/console-pipe/server`
2. These are then broadcast to all SSE clients
**Entry file detection:** looks for `<html` tag, `StartClient`, `hydrateRoot`, `createRoot`, or `solid-js/web` + `render(` in code.
**Source files:** `packages/devtools-vite/src/virtual-console.ts`, `packages/devtools-vite/src/utils.ts` (middleware handlers)
```ts
devtools({
consolePiping: {
enabled: true,
levels: ['log', 'warn', 'error', 'info', 'debug'],
},
})
```
### Enhanced Logging
Babel transform that prepends source location info to `console.log()` and `console.error()` calls. In the browser, this renders as a clickable "Go to Source" link. On the server, it shows `LOG <path>:<line>:<column>` in chalk colors.
The transform inserts a spread of a conditional expression: `...(typeof window === 'undefined' ? serverLogMessage : browserLogMessage)` as the first argument of the console call.
**Source file:** `packages/devtools-vite/src/enhance-logs.ts`
```ts
devtools({
enhancedLogs: {
enabled: true, // default
},
})
```
### Production Stripping
Removes all devtools code from production builds. The transform:
1. Finds files importing from these packages: `@tanstack/react-devtools`, `@tanstack/preact-devtools`, `@tanstack/solid-devtools`, `@tanstack/vue-devtools`, `@tanstack/devtools`
2. Removes the import declarations
3. Removes the JSX elements that use the imported components
4. Cleans up leftover imports that were only used inside the removed JSX (e.g., plugin panel components)
Active when: `command !== 'serve'` OR `config.mode === 'production'` (handles hosting providers like Cloudflare/Netlify that may not use `build` command but set mode to production).
**Source file:** `packages/devtools-vite/src/remove-devtools.ts`
```ts
devtools({
removeDevtoolsOnBuild: true, // default
})
```
### Server Event Bus
A WebSocket + SSE server for devtools-to-client communication. Managed by `@tanstack/devtools-event-bus/server`.
**Key behaviors:**
- Default port: 4206
- On EADDRINUSE: falls back to OS-assigned port (port 0)
- When Vite uses HTTPS: piggybacks on Vite's httpServer instead of creating a standalone one (shares TLS certificate)
- Uses global variables (`__TANSTACK_DEVTOOLS_SERVER__`, etc.) to survive HMR without restarting
- The actual port is injected into client code via `__TANSTACK_DEVTOOLS_PORT__` placeholder replacement
**Source file:** `packages/event-bus/src/server/server.ts`
```ts
devtools({
eventBusConfig: {
port: 4206, // default
enabled: true, // default; set false for storybook/vitest
debug: false, // default; logs internal bus activity
},
})
```
### Editor Integration
Uses `launch-editor` to open source files in the editor. Default editor is VS Code. The `editor.open` callback receives `(path, lineNumber, columnNumber)` as strings.
The open-source flow: browser requests `/__tsd/open-source?source=<encoded-path:line:col>` --> Vite middleware parses source param --> calls `editor.open`.
Supported editors via launch-editor: VS Code, WebStorm, Sublime Text, Atom, and more. For unsupported editors, provide a custom `editor.open` function.
**Source file:** `packages/devtools-vite/src/editor.ts`
```ts
devtools({
editor: {
name: 'Cursor',
open: async (path, lineNumber, columnNumber) => {
// Custom editor open logic
// path is the absolute file path
// lineNumber and columnNumber are strings or undefined
},
},
})
```
### Plugin Marketplace
When the dev server is running, listens for events via `devtoolsEventClient`:
- `install-devtools` -- runs package manager install, then auto-injects plugin into devtools setup file
- `add-plugin-to-devtools` -- injects plugin import and JSX/function call into the file containing `<TanStackDevtools>`
- `bump-package-version` -- updates a package to a minimum version
- `mounted` -- sends package.json and outdated deps to the UI
Auto-detection of the devtools setup file: the `inject-plugin` sub-plugin scans transforms for files importing from `@tanstack/react-devtools`, `@tanstack/solid-devtools`, `@tanstack/vue-devtools`, etc., and stores the file ID.
**Source files:** `packages/devtools-vite/src/inject-plugin.ts`, `packages/devtools-vite/src/package-manager.ts`
## Common Mistakes
### 1. Not placing devtools() first in Vite plugins (HIGH)
All sub-plugins use `enforce: 'pre'`. They must transform code before framework plugins (React, Vue, Solid, etc.) process it. If devtools is not first, source injection and enhanced logs may silently fail because framework transforms remove the raw JSX before devtools can annotate it.
```ts
// WRONG
export default {
plugins: [
react(),
devtools(), // too late -- react() already transformed JSX
],
}
// CORRECT
export default {
plugins: [devtools(), react()],
}
```
### 2. Using devtools-vite with non-Vite bundlers (HIGH)
`@tanstack/devtools-vite` has a peer dependency on `vite ^6.0.0 || ^7.0.0`. It uses Vite-specific APIs (`configureServer`, `handleHotUpdate`, `transform` with filter objects, `Plugin` type). It will not work with webpack, rspack, esbuild, or other bundlers. For non-Vite setups, use `@tanstack/devtools-event-bus` client directly without the Vite plugin.
### 3. Expecting Vite plugin features in production (MEDIUM)
Source injection, console piping, enhanced logging, the server event bus, and the marketplace only operate during development (`config.mode === 'development'` and `command === 'serve'`). In production builds, the only active sub-plugin is `remove-devtools-on-build` (which strips devtools code). Do not rely on any of these features being available at runtime in production.
### 4. Source injection on spread-props elements (MEDIUM)
The Babel transform in `inject-source.ts` explicitly skips any JSX element that has a `{...props}` spread where `props` is the component's parameter name. This is intentional -- the spread would overwrite the injected `data-tsd-source` attribute. If source inspection doesn't work for a specific component, check if it spreads its props parameter.
```tsx
// data-tsd-source will NOT be injected on <div> here
const MyComponent = (props) => {
return <div {...props}>content</div>
}
```
### 5. Event bus port conflict in multi-project setups (MEDIUM)
The default event bus port is 4206. When running multiple Vite dev servers concurrently (monorepo), the second server will hit EADDRINUSE. The event bus handles this by falling back to an OS-assigned port (port 0), and the actual port is injected via placeholder replacement. However, if you need predictable ports (e.g., for firewall rules), set different ports explicitly:
```ts
// Project A
devtools({ eventBusConfig: { port: 4206 } })
// Project B
devtools({ eventBusConfig: { port: 4207 } })
```
## Internal Middleware Endpoints
These are registered on the Vite dev server (not the event bus server):
| Endpoint | Method | Purpose |
| ------------------------------------------- | ------ | --------------------------------------------------------- |
| `/__tsd/open-source?source=<path:line:col>` | GET | Opens file in editor, returns HTML that closes the window |
| `/__tsd/console-pipe` | POST | Receives client console entries (batched JSON) |
| `/__tsd/console-pipe/server` | POST | Receives server-side console entries |
| `/__tsd/console-pipe/sse` | GET | SSE stream for broadcasting server logs to browser |
## Cross-References
- **devtools-app-setup** -- How to set up `<TanStackDevtools>` in your app (must be done before the Vite plugin provides value)
- **devtools-production** -- Details on production stripping configuration and keeping devtools in production builds
## Key Source Files
- `packages/devtools-vite/src/plugin.ts` -- Main plugin factory with all sub-plugins and config type
- `packages/devtools-vite/src/inject-source.ts` -- Babel transform for data-tsd-source injection
- `packages/devtools-vite/src/enhance-logs.ts` -- Babel transform for enhanced console logs
- `packages/devtools-vite/src/remove-devtools.ts` -- Production stripping transform
- `packages/devtools-vite/src/virtual-console.ts` -- Console pipe runtime code generator
- `packages/devtools-vite/src/editor.ts` -- Editor config type and launch-editor integration
- `packages/devtools-vite/src/inject-plugin.ts` -- Marketplace plugin injection into devtools setup file
- `packages/devtools-vite/src/utils.ts` -- Middleware request handling and helpers
- `packages/devtools-vite/src/matcher.ts` -- Picomatch/RegExp pattern matcher
- `packages/event-bus/src/server/server.ts` -- ServerEventBus implementation (WebSocket + SSE + EADDRINUSE fallback)
@@ -0,0 +1,413 @@
# @tanstack/devtools-vite Options Reference
Complete configuration reference for the `devtools()` Vite plugin. All options are optional -- calling `devtools()` with no arguments uses sensible defaults.
**Source of truth:** `packages/devtools-vite/src/plugin.ts` (type `TanStackDevtoolsViteConfig`)
## Top-Level Config Type
```ts
import type { Plugin } from 'vite'
type ConsoleLevel = 'log' | 'warn' | 'error' | 'info' | 'debug'
type TanStackDevtoolsViteConfig = {
editor?: EditorConfig
eventBusConfig?: ServerEventBusConfig & { enabled?: boolean }
enhancedLogs?: { enabled: boolean }
removeDevtoolsOnBuild?: boolean
logging?: boolean
injectSource?: {
enabled: boolean
ignore?: {
files?: Array<string | RegExp>
components?: Array<string | RegExp>
}
}
consolePiping?: {
enabled?: boolean
levels?: Array<ConsoleLevel>
}
}
// Returns Array<Plugin> (9 sub-plugins)
declare function devtools(args?: TanStackDevtoolsViteConfig): Array<Plugin>
// Identity function for type-safe config objects
declare function defineDevtoolsConfig(
config: TanStackDevtoolsViteConfig,
): TanStackDevtoolsViteConfig
```
---
## `injectSource`
Controls source injection -- the Babel transform that adds `data-tsd-source` attributes to JSX elements for the "Go to Source" feature.
| Field | Type | Default | Description |
| ------------------- | ------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `boolean` | `true` | Whether to inject `data-tsd-source` attributes into JSX elements during development. |
| `ignore` | `object` | `undefined` | Patterns to exclude from injection. |
| `ignore.files` | `Array<string \| RegExp>` | `[]` | File paths to skip. Strings are matched via picomatch glob syntax. RegExp patterns are tested directly. Matched against the file's path relative to `process.cwd()`. |
| `ignore.components` | `Array<string \| RegExp>` | `[]` | Component/element names to skip. Strings are matched via picomatch. RegExp patterns are tested directly. Matched against the JSX element name (e.g., `"div"`, `"MyComponent"`, `"Namespace.Component"`). |
**Built-in exclusions (hardcoded in transform filter, not configurable):**
- `node_modules`
- `?raw` imports
- `/dist/` paths
- `/build/` paths
- `<Fragment>` and `<React.Fragment>` elements
- Elements with `{...propsParam}` spread (where `propsParam` is the function's parameter name)
**Example:**
```ts
devtools({
injectSource: {
enabled: true,
ignore: {
files: ['node_modules', /.*\.test\.(js|ts|jsx|tsx)$/, '**/generated/**'],
components: ['InternalComponent', /.*Provider$/, /^Styled/],
},
},
})
```
---
## `consolePiping`
Controls bidirectional console log piping between client (browser) and server (terminal/SSR runtime).
| Field | Type | Default | Description |
| --------- | --------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `boolean` | `true` | Whether to enable console piping. When enabled, client `console.*` calls are forwarded to the terminal, and server `console.*` calls are forwarded to the browser console. |
| `levels` | `Array<ConsoleLevel>` | `['log', 'warn', 'error', 'info', 'debug']` | Which console methods to intercept and pipe. `ConsoleLevel` is `'log' \| 'warn' \| 'error' \| 'info' \| 'debug'`. |
**Runtime behavior:**
- Client batches entries (max 50, flush after 100ms) and POSTs to `/__tsd/console-pipe`
- Server batches entries (max 20, flush after 50ms) and POSTs to `<viteServerUrl>/__tsd/console-pipe/server`
- Browser subscribes to server logs via `EventSource` at `/__tsd/console-pipe/sse`
- Self-referential log messages (containing `[TSD Console Pipe]` or `[@tanstack/devtools`) are excluded to prevent recursion
- Flushes remaining batch on `beforeunload` (client only)
**Example:**
```ts
// Only pipe errors and warnings
devtools({
consolePiping: {
enabled: true,
levels: ['error', 'warn'],
},
})
// Disable entirely
devtools({
consolePiping: {
enabled: false,
},
})
```
---
## `enhancedLogs`
Controls the Babel transform that prepends source location information to `console.log()` and `console.error()` calls.
| Field | Type | Default | Description |
| --------- | --------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `boolean` | `true` | Whether to enhance console.log and console.error with source location. When enabled, each log call gets a clickable "Go to Source" link in the browser and a file:line:column prefix in the terminal. |
**What gets transformed:**
- Only `console.log(...)` and `console.error(...)` calls (not `warn`, `info`, `debug`)
- Skips `node_modules`, `?raw`, `/dist/`, `/build/`
- Skips files that don't contain the string `console.`
**Browser output format:**
```
%cLOG%c %cGo to Source: http://localhost:5173/__tsd/open-source?source=...%c
-> <original args>
```
**Server output format (chalk):**
```
LOG /src/components/Header.tsx:26:13
-> <original args>
```
**Example:**
```ts
devtools({
enhancedLogs: {
enabled: false, // disable source-annotated logs
},
})
```
---
## `removeDevtoolsOnBuild`
Controls whether devtools code is stripped from production builds.
| Field | Type | Default | Description |
| ----------------------- | --------- | ------- | ----------------------------------------------------------------------------- |
| `removeDevtoolsOnBuild` | `boolean` | `true` | When true, removes all devtools imports and JSX usage from production builds. |
**Packages stripped:**
- `@tanstack/react-devtools`
- `@tanstack/preact-devtools`
- `@tanstack/solid-devtools`
- `@tanstack/vue-devtools`
- `@tanstack/devtools`
**Activation condition:** Active when `command !== 'serve'` OR `config.mode === 'production'`. This dual check supports hosting providers (Cloudflare, Netlify, Heroku) that may not use the `build` command but always set mode to `production`.
**What gets removed:**
1. Import declarations from the listed packages
2. JSX elements using the imported component names
3. Leftover imports that were only referenced inside the removed JSX (e.g., plugin panel components referenced in the `plugins` prop)
**Example:**
```ts
// Keep devtools in production (for staging/QA environments)
devtools({
removeDevtoolsOnBuild: false,
})
```
---
## `logging`
Controls the plugin's own console output.
| Field | Type | Default | Description |
| --------- | --------- | ------- | ----------------------------------------------------------------------------------------------------------- |
| `logging` | `boolean` | `true` | Whether the devtools plugin logs status messages to the terminal (e.g., "Removed devtools code from: ..."). |
**Example:**
```ts
devtools({
logging: false, // suppress devtools plugin output
})
```
---
## `eventBusConfig`
Configuration for the server event bus that handles devtools-to-client communication via WebSocket and SSE.
| Field | Type | Default | Description |
| ------------ | ---------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `enabled` | `boolean` | `true` | Whether to start the server event bus. Set to `false` when running devtools in environments that don't need it (e.g., Storybook, Vitest). This field is specific to the Vite plugin wrapper; it is not part of `ServerEventBusConfig` from `@tanstack/devtools-event-bus/server`. |
| `port` | `number` | `4206` | Preferred port for the event bus server. If the port is in use (EADDRINUSE), the bus falls back to an OS-assigned port (port 0). |
| `host` | `string` | Derived from `server.host` in Vite config, or `'localhost'` | Hostname to bind the event bus server to. |
| `debug` | `boolean` | `false` | When true, logs internal event bus activity (connections, dispatches, etc.) to the console. |
| `httpServer` | `HttpServerLike` | `undefined` | An external HTTP server to attach to instead of creating a standalone one. The Vite plugin automatically sets this when HTTPS is enabled (uses `server.httpServer` from Vite) so WebSocket/SSE connections share the same TLS certificate. You generally do not need to set this manually. |
**`HttpServerLike` interface:**
```ts
interface HttpServerLike {
on: (event: string, listener: (...args: Array<any>) => void) => this
removeListener: (
event: string,
listener: (...args: Array<any>) => void,
) => this
address: () =>
| { port: number; family: string; address: string }
| string
| null
}
```
**`ServerEventBusConfig` type (from `@tanstack/devtools-event-bus/server`):**
```ts
interface ServerEventBusConfig {
port?: number | undefined
host?: string | undefined
debug?: boolean | undefined
httpServer?: HttpServerLike | undefined
}
```
**HTTPS behavior:** When `server.https` is configured in Vite, the plugin passes `server.httpServer` as `httpServer` to the event bus. This causes the bus to piggyback on Vite's server rather than creating a standalone HTTP server, ensuring WebSocket and SSE connections use the same TLS certificate.
**Port fallback:** The `ServerEventBus.start()` method tries the configured port first. On `EADDRINUSE`, it retries with port 0 (OS-assigned). The actual port is stored and injected into client code via `__TANSTACK_DEVTOOLS_PORT__` placeholder.
**Example:**
```ts
devtools({
eventBusConfig: {
port: 4300,
enabled: true,
debug: true, // see all event bus activity
},
})
// Disable for Storybook
devtools({
eventBusConfig: {
enabled: false,
},
})
```
---
## `editor`
Configuration for the "open in editor" functionality used by the source inspector.
| Field | Type | Default | Description |
| ------ | ----------------------------------------------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | `string` | `'VSCode'` | Name of the editor, used for debugging/logging purposes. |
| `open` | `(path: string, lineNumber: string \| undefined, columnNumber?: string) => Promise<void>` | Uses `launch-editor` to open VS Code | Callback function that opens a file in the editor. The `path` is an absolute file path. `lineNumber` and `columnNumber` are strings (not numbers) or undefined. |
**`EditorConfig` type (from `packages/devtools-vite/src/editor.ts`):**
```ts
type EditorConfig = {
name: string
open: (
path: string,
lineNumber: string | undefined,
columnNumber?: string,
) => Promise<void>
}
```
**Default implementation:**
```ts
const DEFAULT_EDITOR_CONFIG: EditorConfig = {
name: 'VSCode',
open: async (path, lineNumber, columnNumber) => {
const launch = (await import('launch-editor')).default
launch(
`${path.replaceAll('$', '\\$')}${lineNumber ? `:${lineNumber}` : ''}${columnNumber ? `:${columnNumber}` : ''}`,
undefined,
(filename, err) => {
console.warn(`Failed to open ${filename} in editor: ${err}`)
},
)
},
}
```
**Supported editors via launch-editor:** VS Code, WebStorm, IntelliJ IDEA, Sublime Text, Atom, Vim, Emacs, and more. Full list: https://github.com/yyx990803/launch-editor#supported-editors
**Example -- custom editor:**
```ts
devtools({
editor: {
name: 'Neovim',
open: async (path, lineNumber, columnNumber) => {
const { execFile } = await import('node:child_process')
const lineArg = lineNumber ? `+${lineNumber}` : ''
execFile('nvim', [lineArg, path].filter(Boolean))
},
},
})
```
---
## Connection Placeholders
These are not user-facing config options but are relevant if you work on `@tanstack/devtools` internals. The `connection-injection` sub-plugin replaces these string literals in `@tanstack/devtools*` and `@tanstack/event-bus` source code during dev:
| Placeholder | Replaced with | Fallback |
| -------------------------------- | ----------------------------------- | ------------- |
| `__TANSTACK_DEVTOOLS_PORT__` | Actual event bus port (number) | `4206` |
| `__TANSTACK_DEVTOOLS_HOST__` | Event bus hostname (JSON string) | `"localhost"` |
| `__TANSTACK_DEVTOOLS_PROTOCOL__` | `"http"` or `"https"` (JSON string) | `"http"` |
---
## Full Configuration Example
```ts
import { devtools } from '@tanstack/devtools-vite'
export default {
plugins: [
devtools({
// Source injection for Go to Source feature
injectSource: {
enabled: true,
ignore: {
files: [/.*\.stories\.(js|ts|jsx|tsx)$/],
components: [/^Styled/, 'InternalWrapper'],
},
},
// Bidirectional console piping
consolePiping: {
enabled: true,
levels: ['log', 'warn', 'error'],
},
// Enhanced console.log/error with source locations
enhancedLogs: {
enabled: true,
},
// Strip devtools from production builds
removeDevtoolsOnBuild: true,
// Plugin console output
logging: true,
// Server event bus
eventBusConfig: {
port: 4206,
enabled: true,
debug: false,
},
// Editor integration (default: VS Code via launch-editor)
// editor: { name: 'VSCode', open: async (path, line, col) => { ... } },
}),
// ... framework plugin (react(), vue(), solid(), etc.)
],
}
```
---
## Defaults Summary
| Option | Default Value |
| ------------------------ | ------------------------------------------- |
| `injectSource.enabled` | `true` |
| `injectSource.ignore` | `undefined` (no ignores) |
| `consolePiping.enabled` | `true` |
| `consolePiping.levels` | `['log', 'warn', 'error', 'info', 'debug']` |
| `enhancedLogs.enabled` | `true` |
| `removeDevtoolsOnBuild` | `true` |
| `logging` | `true` |
| `eventBusConfig.enabled` | `true` |
| `eventBusConfig.port` | `4206` |
| `eventBusConfig.host` | Vite's `server.host` or `'localhost'` |
| `eventBusConfig.debug` | `false` |
| `editor.name` | `'VSCode'` |
| `editor.open` | Uses `launch-editor` |
+10
View File
@@ -0,0 +1,10 @@
---
name: h3
description: Build HTTP servers and APIs with the H3 framework
---
@docs/TOC.md
You can use `npx h3 docs [--page <path>] [...args]` to explore the documentation locally.
For example, `npx h3 docs --page /guide/basics/routing` will open the routing page of the guide section.
If not available, fallback to https://h3.dev/llms.txt
+35
View File
@@ -0,0 +1,35 @@
# H3 Documentation
- [Guide](./guide/index.md)
- [Getting Started](./guide/index.md)
- [Request Lifecycle](./guide/basics/lifecycle.md)
- [Routing](./guide/basics/routing.md)
- [Middleware](./guide/basics/middleware.md)
- [Event Handlers](./guide/basics/handler.md)
- [Sending Response](./guide/basics/response.md)
- [Error Handling](./guide/basics/error.md)
- [Nested Apps](./guide/basics/nested-apps.md)
- [H3](./guide/api/h3.md)
- [H3Event](./guide/api/h3event.md)
- [Plugins](./guide/advanced/plugins.md)
- [WebSockets](./guide/advanced/websocket.md)
- [Nightly Builds](./guide/advanced/nightly.md)
- [Utils](./utils/index.md)
- [Community](./utils/index.md)
- [Request](./utils/request.md)
- [Response](./utils/response.md)
- [Cookie](./utils/cookie.md)
- [Security](./utils/security.md)
- [Proxy](./utils/proxy.md)
- [MCP](./utils/mcp.md)
- [More utils](./utils/more.md)
- [Community](./utils/community.md)
- [Examples](./examples/index.md)
- [Examples](./examples/index.md)
- [Cookies](./examples/handle-cookie.md)
- [Sessions](./examples/handle-session.md)
- [Static Assets](./examples/serve-static-assets.md)
- [Stream Response](./examples/stream-response.md)
- [Validate Data](./examples/validate-data.md)
- [Migration](./migration/index.md)
- [Migration](./migration/index.md)
+311
View File
@@ -0,0 +1,311 @@
[
{
"slug": "guide",
"path": "/guide",
"title": "Guide",
"order": 1,
"icon": "i-ph:book-open-duotone",
"children": [
{
"slug": "",
"path": "/guide",
"title": "Getting Started",
"order": 0,
"icon": "pixel:play"
},
{
"slug": "basics",
"path": "/guide/basics",
"title": "Guide",
"order": 1,
"icon": "ph:book-open-duotone",
"page": false,
"children": [
{
"slug": "lifecycle",
"path": "/guide/basics/lifecycle",
"title": "Request Lifecycle",
"order": 1,
"icon": "icon-park-outline:handle-round"
},
{
"slug": "routing",
"path": "/guide/basics/routing",
"title": "Routing",
"order": 2,
"icon": "solar:routing-bold"
},
{
"slug": "middleware",
"path": "/guide/basics/middleware",
"title": "Middleware",
"order": 3,
"icon": "mdi:middleware-outline"
},
{
"slug": "handler",
"path": "/guide/basics/handler",
"title": "Event Handlers",
"order": 4,
"icon": "mdi:function"
},
{
"slug": "response",
"path": "/guide/basics/response",
"title": "Sending Response",
"order": 5,
"icon": "tabler:json"
},
{
"slug": "error",
"path": "/guide/basics/error",
"title": "Error Handling",
"order": 6,
"icon": "tabler:error-404"
},
{
"slug": "nested-apps",
"path": "/guide/basics/nested-apps",
"title": "Nested Apps",
"order": 7,
"icon": "material-symbols-light:layers-outline"
}
]
},
{
"slug": "api",
"path": "/guide/api",
"title": "API",
"order": 900,
"icon": "material-symbols-light:api-rounded",
"page": false,
"children": [
{
"slug": "h3",
"path": "/guide/api/h3",
"title": "H3",
"order": 1,
"icon": "material-symbols:bolt-rounded"
},
{
"slug": "h3event",
"path": "/guide/api/h3event",
"title": "H3Event",
"order": 2,
"icon": "material-symbols:data-object-rounded"
}
]
},
{
"slug": "advanced",
"path": "/guide/advanced",
"title": "Advanced",
"order": 901,
"icon": "hugeicons:more-01",
"page": false,
"children": [
{
"slug": "plugins",
"path": "/guide/advanced/plugins",
"title": "Plugins",
"order": 1,
"icon": "clarity:plugin-line"
},
{
"slug": "websocket",
"path": "/guide/advanced/websocket",
"title": "WebSockets",
"order": 2,
"icon": "hugeicons:live-streaming-02"
},
{
"slug": "nightly",
"path": "/guide/advanced/nightly",
"title": "Nightly Builds",
"order": 9,
"icon": "game-icons:barn-owl"
}
]
}
]
},
{
"slug": "utils",
"path": "/utils",
"title": "Utils",
"order": 2,
"children": [
{
"slug": "",
"path": "/utils",
"title": "Community",
"order": 0,
"icon": "pixelarticons:github",
"to": "/utils/community"
},
{
"slug": "request",
"path": "/utils/request",
"title": "Request",
"order": 1,
"icon": "material-symbols-light:input"
},
{
"slug": "response",
"path": "/utils/response",
"title": "Response",
"order": 2,
"icon": "material-symbols-light:output"
},
{
"slug": "cookie",
"path": "/utils/cookie",
"title": "Cookie",
"order": 3,
"icon": "material-symbols:cookie-outline"
},
{
"slug": "security",
"path": "/utils/security",
"title": "Security",
"order": 4,
"icon": "wpf:key-security"
},
{
"slug": "proxy",
"path": "/utils/proxy",
"title": "Proxy",
"order": 5,
"icon": "arcticons:super-proxy"
},
{
"slug": "mcp",
"path": "/utils/mcp",
"title": "MCP",
"order": 6,
"icon": "material-symbols:swap-calls"
},
{
"slug": "more",
"path": "/utils/more",
"title": "More utils",
"order": 9,
"icon": "mingcute:plus-line"
},
{
"slug": "community",
"path": "/utils/community",
"title": "Community",
"order": 99,
"icon": "lets-icons:external"
}
]
},
{
"slug": "examples",
"path": "/examples",
"title": "Examples",
"order": 4,
"children": [
{
"slug": "",
"path": "/examples",
"title": "Examples",
"order": 0,
"icon": "ph:code"
},
{
"slug": "handle-cookie",
"path": "/examples/handle-cookie",
"title": "Cookies",
"order": null,
"icon": "ph:arrow-right"
},
{
"slug": "handle-session",
"path": "/examples/handle-session",
"title": "Sessions",
"order": null,
"icon": "ph:arrow-right"
},
{
"slug": "serve-static-assets",
"path": "/examples/serve-static-assets",
"title": "Static Assets",
"order": null,
"icon": "ph:arrow-right"
},
{
"slug": "stream-response",
"path": "/examples/stream-response",
"title": "Stream Response",
"order": null,
"icon": "ph:arrow-right"
},
{
"slug": "validate-data",
"path": "/examples/validate-data",
"title": "Validate Data",
"order": null,
"icon": "ph:arrow-right"
}
]
},
{
"slug": "migration",
"path": "/migration",
"title": "Migration",
"order": 5,
"children": [
{
"slug": "",
"path": "/migration",
"title": "Migration",
"order": 0,
"icon": "icons8:up-round"
}
]
},
{
"slug": "blog",
"path": "/blog",
"title": "Blog",
"order": 99,
"children": [
{
"slug": "v1.8",
"path": "/blog/v1.8",
"title": "H3 1.8 - Towards the Edge of the Web",
"order": 1,
"date": "2023-08-15",
"category": "release",
"authors": [
{
"name": "Pooya Parsa",
"github": "pi0"
}
]
},
{
"slug": "v2-beta",
"path": "/blog/v2-beta",
"title": "H3 v2 beta",
"order": 2,
"date": "2025-06-10",
"category": "release",
"authors": [
{
"name": "Pooya Parsa",
"github": "pi0"
}
]
},
{
"slug": "",
"path": "/blog",
"title": "Blog",
"order": null
}
]
}
]
@@ -0,0 +1,67 @@
# Cookies
> Use cookies to store data on the client.
Handling cookies with H3 is straightforward. There is three utilities to handle cookies:
- `setCookie` to attach a cookie to the response.
- `getCookie` to get a cookie from the request.
- `deleteCookie` to clear a cookie from the response.
## Set a Cookie
To set a cookie, you need to use `setCookie` in an event handler:
```ts
import { setCookie } from "h3";
app.use(async (event) => {
setCookie(event, "name", "value", { maxAge: 60 * 60 * 24 * 7 });
return "";
});
```
In the options, you can configure the [cookie flags](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie):
- `maxAge` to set the expiration date of the cookie in seconds.
- `expires` to set the expiration date of the cookie in a `Date` object.
- `path` to set the path of the cookie.
- `domain` to set the domain of the cookie.
- `secure` to set the `Secure` flag of the cookie.
- `httpOnly` to set the `HttpOnly` flag of the cookie.
- `sameSite` to set the `SameSite` flag of the cookie.
<read-more></read-more>
## Get a Cookie
To get a cookie, you need to use `getCookie` in an event handler.
```ts
import { getCookie } from "h3";
app.use(async (event) => {
const name = getCookie(event, "name");
// do something...
return "";
});
```
This will return the value of the cookie if it exists, or `undefined` otherwise.
## Delete a Cookie
To delete a cookie, you need to use `deleteCookie` in an event handler:
```ts
import { deleteCookie } from "h3";
app.use(async (event) => {
deleteCookie(event, "name");
return "";
});
```
The utility `deleteCookie` is a wrapper around `setCookie` with the value set to `""` and the `maxAge` set to `0`.
This will erase the cookie from the client.
@@ -0,0 +1,130 @@
# Sessions
> Remember your users using a session.
A session is a way to remember users using cookies. It is a very common method for authenticating users or saving data about them, such as their language or preferences on the web.
H3 provides many utilities to handle sessions:
- `useSession` initializes a session and returns a wrapper to control it.
- `getSession` initializes or retrieves the current user session.
- `updateSession` updates the data of the current session.
- `clearSession` clears the current session.
Most of the time, you will use `useSession` to manipulate the session.
## Initialize a Session
To initialize a session, you need to use `useSession` in an [event handler](/guide/handler):
```js
import { useSession } from "h3";
app.use(async (event) => {
const session = await useSession(event, {
password: "80d42cfb-1cd2-462c-8f17-e3237d9027e9",
});
// do something...
});
```
> [!WARNING]
> You must provide a password to encrypt the session.
This will initialize a session and return an header `Set-Cookie` with a cookie named `h3` and an encrypted content.
If the request contains a cookie named `h3` or a header named `x-h3-session`, the session will be initialized with the content of the cookie or the header.
> [!NOTE]
> The header take precedence over the cookie.
## Get Data from a Session
To get data from a session, we will still use `useSession`. Under the hood, it will use `getSession` to get the session.
```js
import { useSession } from "h3";
app.use(async (event) => {
const session = await useSession(event, {
password: "80d42cfb-1cd2-462c-8f17-e3237d9027e9",
});
return session.data;
});
```
Data are stored in the `data` property of the session. If there is no data, it will be an empty object.
## Add Data to a Session
To add data to a session, we will still use `useSession`. Under the hood, it will use `updateSession` to update the session.
```js
import { useSession } from "h3";
app.use(async (event) => {
const session = await useSession(event, {
password: "80d42cfb-1cd2-462c-8f17-e3237d9027e9",
});
const count = (session.data.count || 0) + 1;
await session.update({
count: count,
});
return count === 0 ? "Hello world!" : `Hello world! You have visited this page ${count} times.`;
});
```
What is happening here?
We try to get a session from the request. If there is no session, a new one will be created. Then, we increment the `count` property of the session and we update the session with the new value. Finally, we return a message with the number of times the user visited the page.
Try to visit the page multiple times and you will see the number of times you visited the page.
> [!NOTE]
> If you use a CLI tool like `curl` to test this example, you will not see the number of times you visited the page because the CLI tool does not save cookies. You must get the cookie from the response and send it back to the server.
## Clear a Session
To clear a session, we will still use `useSession`. Under the hood, it will use `clearSession` to clear the session.
```js
import { useSession } from "h3";
app.use("/clear", async (event) => {
const session = await useSession(event, {
password: "80d42cfb-1cd2-462c-8f17-e3237d9027e9",
});
await session.clear();
return "Session cleared";
});
```
H3 will send a header `Set-Cookie` with an empty cookie named `h3` to clear the session.
## Options
When to use `useSession`, you can pass an object with options as the second argument to configure the session:
```js
import { useSession } from "h3";
app.use(async (event) => {
const session = await useSession(event, {
name: "my-session",
password: "80d42cfb-1cd2-462c-8f17-e3237d9027e9",
cookie: {
httpOnly: true,
secure: true,
sameSite: "strict",
},
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return session.data;
});
```
+16
View File
@@ -0,0 +1,16 @@
# Examples
> Common examples for h3.
<read-more>
Check [`examples/` dir](https://github.com/h3js/h3/tree/main/examples) for more examples.
</read-more>
**Examples:**
- [Cookies](/examples/handle-cookie)
- [Session](/examples/handle-session)
- [Static Assets](/examples/serve-static-assets)
- [Streaming Response](/examples/stream-response)
- [Validation](/examples/validate-data)
@@ -0,0 +1,66 @@
# Static Assets
> Serve static assets such as HTML, images, CSS, JavaScript, etc.
H3 can serve static assets such as HTML, images, CSS, JavaScript, etc.
To serve a static directory, you can use the `serveStatic` utility.
```ts
import { H3, serveStatic } from "h3";
const app = new H3();
app.use("/public/**", (event) => {
return serveStatic(event, {
getContents: (id) => {
// TODO
},
getMeta: (id) => {
// TODO
},
});
});
```
This does not serve any files yet. You need to implement the `getContents` and `getMeta` methods.
- `getContents` is used to read the contents of a file. It should return a `Promise` that resolves to the contents of the file or `undefined` if the file does not exist.
- `getMeta` is used to get the metadata of a file. It should return a `Promise` that resolves to the metadata of the file or `undefined` if the file does not exist.
They are separated to allow H3 to respond to `HEAD` requests without reading the contents of the file and to use the `Last-Modified` header.
## Read files
Now, create a `index.html` file in the `public` directory with a simple message and open your browser to http://localhost:3000. You should see the message.
Then, we can create the `getContents` and `getMeta` methods:
```ts
import { stat, readFile } from "node:fs/promises";
import { join } from "node:path";
import { H3, serve, serveStatic } from "h3";
const app = new H3();
app.use("/public/**", (event) => {
return serveStatic(event, {
indexNames: ["/index.html"],
getContents: (id) => readFile(join("public", id)),
getMeta: async (id) => {
const stats = await stat(join("public", id)).catch(() => {});
if (stats?.isFile()) {
return {
size: stats.size,
mtime: stats.mtimeMs,
};
}
},
});
});
serve(app);
```
The `getContents` reads the file and returns its contents, pretty simple. The `getMeta` uses `fs.stat` to get the file metadata. If the file does not exist or is not a file, it returns `undefined`. Otherwise, it returns the file size and the last modification time.
The file size and last modification time are used to create an etag to send a `304 Not Modified` response if the file has not been modified since the last request. This is useful to avoid sending the same file multiple times if it has not changed.
@@ -0,0 +1,76 @@
# Stream Response
> Stream response to the client.
Using stream responses It allows you to send data to the client as soon as you have it. This is useful for large files or long running responses.
## Create a Stream
To stream a response, you first need to create a stream using the [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) API:
```ts
const stream = new ReadableStream();
```
For the example, we will create a start function that will send a random number every 100 milliseconds. After 1000 milliseconds, it will close the stream:
```ts
let interval: NodeJS.Timeout;
const stream = new ReadableStream({
start(controller) {
controller.enqueue("<ul>");
interval = setInterval(() => {
controller.enqueue("<li>" + Math.random() + "</li>");
}, 100);
setTimeout(() => {
clearInterval(interval);
controller.close();
}, 1000);
},
cancel() {
clearInterval(interval);
},
});
```
## Send a Stream
```ts
import { H3 } from "h3";
export const app = new H3();
app.use((event) => {
// Set to response header to tell to the client that we are sending a stream.
event.res.headers.set("Content-Type", "text/html");
event.res.headers.set("Cache-Control", "no-cache");
event.res.headers.set("Transfer-Encoding", "chunked");
let interval: NodeJS.Timeout;
const stream = new ReadableStream({
start(controller) {
controller.enqueue("<ul>");
interval = setInterval(() => {
controller.enqueue("<li>" + Math.random() + "</li>");
}, 100);
setTimeout(() => {
clearInterval(interval);
controller.close();
}, 1000);
},
cancel() {
clearInterval(interval);
},
});
return stream;
});
```
Open your browser to http://localhost:3000 and you should see a list of random numbers appearing every 100 milliseconds.
Magic! 🎉
@@ -0,0 +1,193 @@
# Validate Data
> Ensure that your data are valid and safe before processing them.
When you receive data on your server, you must validate them. By validate, we mean that the shape of the received data must match the expected shape. It's important because you can't trust the data coming from unknown sources, like a user or an external API.
> [!WARNING]
> Do not use type generics as a validation. Providing an interface to a utility like `readBody` is not a validation. You must validate the data before using it.
## Utilities for Validation
H3 provide some utilities to help you to handle data validation. You will be able to validate:
- query with `getValidatedQuery`
- params with `getValidatedRouterParams`.
- body with `readValidatedBody`
H3 doesn't provide any validation library but it does support schemas coming from a **Standard-Schema** compatible one, like: [Zod](https://zod.dev), [Valibot](https://valibot.dev), [ArkType](https://arktype.io/), etc... (for all compatible libraries please check [their official repository](https://github.com/standard-schema/standard-schema)). If you want to use a validation library that is not compatible with Standard-Schema, you can still use it, but you will have to use parsing functions provided by the library itself (refer to the [Safe Parsing](#safe-parsing) section below).
> [!WARNING]
> H3 is runtime agnostic. This means that you can use it in [any runtime](/adapters). But some validation libraries are not compatible with all runtimes.
Let's see how to validate data with [Zod](https://zod.dev) and [Valibot](https://valibot.dev).
### Validate Params
You can use `getValidatedRouterParams` to validate params and get the result, as a replacement of `getRouterParams`:
```js
import { getValidatedRouterParams } from "h3";
import * as z from "zod";
import * as v from "valibot";
// Example with Zod
const contentSchema = z.object({
topic: z.string().min(1),
uuid: z.string().uuid(),
});
// Example with Valibot
const contentSchema = v.object({
topic: v.pipe(v.string(), v.nonEmpty()),
uuid: v.pipe(v.string(), v.uuid()),
});
app.all(
// You must use a router to use params
"/content/:topic/:uuid",
async (event) => {
const params = await getValidatedRouterParams(event, contentSchema);
return `You are looking for content with topic "${params.topic}" and uuid "${params.uuid}".`;
},
);
```
If you send a valid request like `/content/posts/123e4567-e89b-12d3-a456-426614174000` to this event handler, you will get a response like this:
```txt
You are looking for content with topic "posts" and uuid "123e4567-e89b-12d3-a456-426614174000".
```
If you send an invalid request and the validation fails, H3 will throw a `400 Validation Error` error. In the data of the error, you will find the validation errors you can use on your client to display a nice error message to your user.
### Validate Query
You can use `getValidatedQuery` to validate query and get the result, as a replacement of `getQuery`:
```js
import { getValidatedQuery } from "h3";
import * as z from "zod";
import * as v from "valibot";
// Example with Zod
const stringToNumber = z.string().regex(/^\d+$/, "Must be a number string").transform(Number);
const paginationSchema = z.object({
page: stringToNumber.optional().default(1),
size: stringToNumber.optional().default(10),
});
// Example with Valibot
const stringToNumber = v.pipe(
v.string(),
v.regex(/^\d+$/, "Must be a number string"),
v.transform(Number),
);
const paginationSchema = v.object({
page: v.optional(stringToNumber, 1),
size: v.optional(stringToNumber, 10),
});
app.use(async (event) => {
const query = await getValidatedQuery(event, paginationSchema);
return `You are on page ${query.page} with ${query.size} items per page.`;
});
```
As you may have noticed, compared to the `getValidatedRouterParams` example, we can leverage validation libraries to transform the incoming data. In this case, we transform the string representation of a number into a real number, which is useful for things like content pagination.
If you send a valid request like `/?page=2&size=20` to this event handler, you will get a response like this:
```txt
You are on page 2 with 20 items per page.
```
If you send an invalid request and the validation fails, H3 will throw a `400 Validation Error` error. In the data of the error, you will find the validation errors you can use on your client to display a nice error message to your user.
### Validate Body
You can use `readValidatedBody` to validate body and get the result, as a replacement of `readBody`:
```js
import { readValidatedBody } from "h3";
import { z } from "zod";
import * as v from "valibot";
// Example with Zod
const userSchema = z.object({
name: z.string().min(3).max(20),
age: z.number({ coerce: true }).positive().int(),
});
// Example with Valibot
const userSchema = v.object({
name: v.pipe(v.string(), v.minLength(3), v.maxLength(20)),
age: v.pipe(v.number(), v.integer(), v.minValue(0)),
});
app.use(async (event) => {
const body = await readValidatedBody(event, userSchema);
return `Hello ${body.name}! You are ${body.age} years old.`;
});
```
If you send a valid POST request with a JSON body like this:
```json
{
"name": "John",
"age": 42
}
```
You will get a response like this:
```txt
Hello John! You are 42 years old.
```
If you send an invalid request and the validation fails, H3 will throw a `400 Validation Error` error. In the data of the error, you will find the validation errors you can use on your client to display a nice error message to your user.
## Safe Parsing
By default if a schema is directly provided as e second argument for each validation utility (`getValidatedRouterParams`, `getValidatedQuery`, and `readValidatedBody`) it will throw a `400 Validation Error` error if the validation fails, but in some cases you may want to handle the validation errors yourself. For this you should provide the actual safe validation function as the second argument, depending on the validation library you are using.
Going back to the first example with `getValidatedRouterParams`, for Zod it would look like this:
```ts
import { getValidatedRouterParams } from "h3";
import { z } from "zod/v4";
const contentSchema = z.object({
topic: z.string().min(1),
uuid: z.string().uuid(),
});
app.all("/content/:topic/:uuid", async (event) => {
const params = await getValidatedRouterParams(event, contentSchema.safeParse);
if (!params.success) {
// Handle validation errors
return `Validation failed:\n${z.prettifyError(params.error)}`;
}
return `You are looking for content with topic "${params.data.topic}" and uuid "${params.data.uuid}".`;
});
```
And for Valibot, it would look like this:
```ts
import { getValidatedRouterParams } from "h3";
import * as v from "valibot";
const contentSchema = v.object({
topic: v.pipe(v.string(), v.nonEmpty()),
uuid: v.pipe(v.string(), v.uuid()),
});
app.all("/content/:topic/:uuid", async (event) => {
const params = await getValidatedRouterParams(event, v.safeParser(contentSchema));
if (!params.success) {
// Handle validation errors
return `Validation failed:\n${v.summarize(params.issues)}`;
}
return `You are looking for content with topic "${params.output.topic}" and uuid "${params.output.uuid}".`;
});
```
@@ -0,0 +1,13 @@
# Nightly Builds
You can opt-in to early test latest H3 changes using automated nightly release channel.
If you are directly using `h3` as a dependency in your project:
```json
{
"dependencies": {
"h3": "npm:h3-nightly@latest"
}
}
```
@@ -0,0 +1,50 @@
# Plugins
> H3 plugins allow you to extend an H3 app instance with reusable logic.
## Register Plugins
Plugins can be registered either when creating a new [H3 instance](/guide/api/h3) or by using [H3.register](/guide/api/h3#h3register).
```js
import { H3 } from "h3";
import { logger } from "./logger.mjs";
// Using instance config
const app = new H3({
plugins: [logger()],
});
// Or register later
app.register(logger());
// ... rest of the code..
app.get("/**", () => "Hello, World!");
```
> [!NOTE]
> Plugins are always registered immediately. Therefore, the order in which they are used might be important depending on the plugin's functionality.
## Creating Plugins
H3 plugins are simply functions that accept an [H3 instance](/guide/api/h3) as the first argument and immediately apply logic to extend it.
```js
app.register((app) => {
app.use(...)
})
```
For convenience, H3 provides a built-in `definePlugin` utility, which creates a typed factory function with optional plugin-specific options.
```js
import { definePlugin } from "h3";
const logger = definePlugin((h3, _options) => {
if (h3.config.debug) {
h3.use((req) => {
console.log(`[${req.method}] ${req.url}`);
});
}
});
```
@@ -0,0 +1,124 @@
# WebSockets
> H3 has built-in utilities for cross platform WebSocket and Server-Sent Events.
You can add cross platform WebSocket support to H3 servers using [🔌 CrossWS](https://crossws.h3.dev/).
> [!IMPORTANT]
> Built-in support of WebSockets in h3 version is WIP.
## Usage
WebSocket handlers can be defined using the `defineWebSocketHandler()` utility and registered to any route like event handlers.
You need to register CrossWS as a server plugin in the `serve` function and provide a `resolve` function to resolve the correct hooks from the route.
```js
import { H3, serve, defineWebSocketHandler } from "h3";
import { plugin as ws } from "crossws/server";
const app = new H3();
app.get("/_ws", defineWebSocketHandler({ message: console.log }));
serve(app, {
plugins: [ws({ resolve: async (req) => (await app.fetch(req)).crossws })],
});
```
**Full example:**
```js [websocket.mjs]
import { H3, serve, defineWebSocketHandler } from "h3";
import { plugin as ws } from "crossws/server";
export const app = new H3();
const demoURL =
"https://raw.githubusercontent.com/h3js/crossws/refs/heads/main/playground/public/index.html";
app.get("/", () =>
fetch(demoURL).then(
(res) => new Response(res.body, { headers: { "Content-Type": "text/html" } }),
),
);
app.get(
"/_ws",
defineWebSocketHandler({
// upgrade(req) {},
open(peer) {
console.log("[open]", peer);
// Send welcome to the new client
peer.send("Welcome to the server!");
// Join new client to the "chat" channel
peer.subscribe("chat");
// Notify every other connected client
peer.publish("chat", `[system] ${peer} joined!`);
},
message(peer, message) {
console.log("[message]", peer);
if (message.text() === "ping") {
// Reply to the client with a ping response
peer.send("pong");
return;
}
// The server re-broadcasts incoming messages to everyone
peer.publish("chat", `[${peer}] ${message}`);
// Echo the message back to the sender
peer.send(message);
},
close(peer) {
console.log("[close]", peer);
peer.publish("chat", `[system] ${peer} has left the chat!`);
peer.unsubscribe("chat");
},
}),
);
serve(app, {
plugins: [ws({ resolve: async (req) => (await app.fetch(req)).crossws })],
});
```
## Server-Sent Events (SSE)
As an alternative to WebSockets, you can use [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events).
H3 has a built-in API to create server-sent events using `createEventStream(event)` utility.
### Example
```js [server-sent-events.mjs]
import { H3, serve, createEventStream } from "h3";
export const app = new H3();
app.get("/", (event) => {
const eventStream = createEventStream(event);
// Send a message every second
const interval = setInterval(async () => {
await eventStream.push("Hello world");
}, 1000);
// cleanup the interval when the connection is terminated or the writer is closed
eventStream.onClosed(() => {
console.log("Connection closed");
clearInterval(interval);
});
return eventStream.send();
});
serve(app);
```
+145
View File
@@ -0,0 +1,145 @@
# H3
> H3 class is the core of server.
You can create a new H3 app instance using `new H3()`:
```js
import { H3 } from "h3";
const app = new H3({
/* optional config */
});
```
## `H3` Methods
### `H3.request`
A [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)-compatible function allowing to fetch app routes.
- Input can be a relative path, [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL), or [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request).
- Returned value is a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) promise.
```ts
const response = await app.request("/");
console.log(response, await response.text());
```
### `H3.fetch`
Similar to `H3.request` but only accepts one `(req: Request)` argument for cross runtime compatibility.
### `H3.on`
Register route handler for specific HTTP method.
```js
const app = new H3().on("GET", "/", () => "OK");
```
<read-more></read-more>
### `H3.[method]`
Register route handler for specific HTTP method (shortcut for `app.on(method, ...)`).
```js
const app = new H3().get("/", () => "OK");
```
### `H3.all`
Register route handler for all HTTP methods.
```js
const app = new H3().all("/", () => "OK");
```
### `H3.use`
Register a global [middleware](/guide/basics/middleware).
```js
const app = new H3()
.use((event) => {
console.log(`request: ${event.req.url}`);
})
.all("/", () => "OK");
```
<read-more></read-more>
### `H3.register`
Register a H3 plugin to extend app.
<read-more></read-more>
### `H3.handler`
An H3 [event handler](/guide/basics/handler) useful to compose multiple H3 app instances.
**Example:** Nested apps.
```js
import { H3, serve, redirect, withBase } from "h3";
const nestedApp = new H3().get("/test", () => "/test (sub app)");
const app = new H3()
.get("/", (event) => redirect(event, "/api/test"))
.all("/api/**", withBase("/api", nestedApp.handler));
serve(app);
```
### `H3.mount`
Using `.mount` method, you can register a sub-app with prefix.
<read-more></read-more>
## `H3` Options
You can pass global app configuration when initializing an app.
Supported options:
- `debug`: Displays debugging stack traces in HTTP responses (potentially dangerous for production!).
- `silent`: When enabled, console errors for unhandled exceptions will not be displayed.
- `plugins`: (see [plugins](/guide/advanced/plugins) for more information)
> [!IMPORTANT]
Enabling `debug` option, sends important stuff like stack traces in error responses. Only enable during development.
### Global Hooks
When initializing an H3 app, you can register global hooks:
- `onError`
- `onRequest`
- `onResponse`
These hooks are called for every request and can be used to add global logic to your app such as logging, error handling, etc.
```js
const app = new H3({
onRequest: (event) => {
console.log("Request:", event.req.url);
},
onResponse: (response, event) => {
console.log("Response:", event.url.pathname, response.status);
},
onError: (error, event) => {
console.error(error);
},
});
```
> [!IMPORTANT]
> Global hooks only run from main H3 app and **not** sub-apps. Use [middleware](/guide/basics/middleware) for more flexibility.
## `H3` Properties
### `H3.config`
Global H3 instance config.
+113
View File
@@ -0,0 +1,113 @@
# H3Event
> H3Event, carries incoming request, prepared response and context.
With each HTTP request, H3 internally creates an `H3Event` object and passes it though event handlers until sending the response.
<read-more></read-more>
An event is passed through all the lifecycle hooks and composable utils to use it as context.
**Example:**
```js
app.get("/", async (event) => {
// Log HTTP request
console.log(`[${event.req.method}] ${event.req.url}`);
// Parsed URL and query params
const searchParams = event.url.searchParams;
// Try to read request JSON body
const jsonBody = await event.req.json().catch(() => {});
return "OK";
});
```
## `H3Event` Methods
### `H3Event.waitUntil`
Tell the runtime about an ongoing operation that shouldn't close until the promise resolves.
```js [app.mjs]
import { logRequest } from "./tracing.mjs";
app.get("/", (event) => {
request.waitUntil(logRequest(request));
return "OK";
});
```
```js [tracing.mjs]
export async function logRequest(request) {
await fetch("https://telemetry.example.com", {
method: "POST",
body: JSON.stringify({
method: request.method,
url: request.url,
ip: request.ip,
}),
});
}
```
## `H3Event` Properties
### `H3Event.app?`
Access to the H3 [application instance](/guide/api/h3).
### `H3Event.context`
The context is an object that contains arbitrary information about the request.
You can store your custom properties inside `event.context` to share across utils.
**Known context keys:**
- `context.params`: Matched router parameters.
- `middlewareParams`: Matched middleware parameters
- `matchedRoute`: Matched router route object.
- `sessions`: Cached session data.
- `basicAuth`: Basic authentication data.
### `H3Event.req`
Incoming HTTP request info based on native [Web Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) with additional runtime addons (see [srvx docs](https://srvx.h3.dev/guide/handler#extended-request-context)).
```ts
app.get("/", async (event) => {
const url = event.req.url;
const method = event.req.method;
const headers = event.req.headers;
// (note: you can consume body only once with either of this)
const bodyStream = await event.req.body;
const textBody = await event.req.text();
const jsonBody = await event.req.json();
const formDataBody = await event.req.formData();
return "OK";
});
```
### `H3Event.url`
Access to the full parsed request [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL).
### `H3Event.res`
Prepared HTTP response status and headers.
```ts
app.get("/", (event) => {
event.res.status = 200;
event.res.statusText = "OK";
event.res.headers.set("x-test", "works");
return "OK";
});
```
<read-more></read-more>
@@ -0,0 +1,117 @@
# Error Handling
> Send errors by throwing an HTTPError.
H3 captures all possible errors during [request lifecycle](/guide/basics/lifecycle).
## `HTTPError`
You can create and throw HTTP errors using `HTTPError` with different syntaxes.
```js
import { HTTPError } from "h3";
app.get("/error", (event) => {
// Using message and details
throw new HTTPError("Invalid user input", { status: 400 });
// Using HTTPError.status(code)
throw HTTPError.status(400, "Bad Request");
// Using single pbject
throw new HTTPError({
status: 400,
statusText: "Bad Request",
message: "Invalid user input",
data: { field: "email" },
body: { date: new Date().toJSON() },
headers: {},
});
});
```
This will end the request with `400 - Bad Request` status code and the following JSON response:
```json
{
"date": "2025-06-05T04:20:00.0Z",
"status": 400,
"statusText": "Bad Request",
"message": "Invalid user input",
"data": {
"field": "email"
}
}
```
### `HTTPError` Fields
- `status`: HTTP status code in the range 200599.
- `statusText`: HTTP status text to be sent in the response header.
- `message`: Error message to be included in the JSON body.
- `data`: Additional data to be attached under the `data` key in the error JSON body.
- `body`: Additional top-level properties to be attached in the error JSON body.
- `headers`: Additional HTTP headers to be sent in the error response.
- `cause`: The original error object that caused this error, useful for tracing and debugging.
- `unhandled`: Indicates whether the error was thrown for unknown reasons. See [Unhandled Errors](#unhandled-errors).
> [!TIP]
The recommended way to include headers in error responses is to use `new HTTPError({ headers })`:
> ```js
> throw new HTTPError({
> status: 400,
> message: "Invalid input",
> headers: { "x-request-id": requestId },
> });
> ```
> When an error is thrown, any [prepared headers](/guide/basics/response#preparing-response) set via `event.res.headers` are **not** included in the error response. As a last resort for headers that need to be set implicitly before the error is known (e.g., CORS headers), you can use `event.res.errHeaders`. Built-in utilities like `handleCors` automatically set both.
> [!IMPORTANT]
> Error `statusText` should be short (max 512 to 1024 characters) and only include tab, spaces or visible ASCII characters and extended characters (byte value 128255). Prefer `message` in JSON body for extended message.
## Unhandled Errors
Any error that occurs during calling [request lifecycle](/guide/basics/lifecycle) without using `HTTPError` will be processed as an <u>unhandled</u> error.
```js
app.get("/error", (event) => {
// This will cause an unhandled error.
throw new Error("Something went wrong");
});
```
> [!TIP]
> For enhanced security, H3 hides certain fields of unhandled errors (`data`, `body`, `stack` and `message`) in JSON response.
## Catching Errors
Using global [`onError`](/guide/api/h3#global-hooks) hook:
```js
import { H3, onError } from "h3";
// Globally handling errors
const app = new H3({
onError: (error) => {
console.error(error);
},
});
```
Using [`onError` middleware](/guide/basics/middleware) to catch errors.
```js
import { onError } from "h3";
// Handling errors using middleware
app.use(
onError((error, event) => {
console.error(error);
}),
);
```
> [!TIP]
> When using nested apps, global hooks of sub-apps will not be called. Therefore it is better to use `onError` middleware.
@@ -0,0 +1,165 @@
# Event Handlers
> An event handler is a function that receives an H3Event and returns a response.
You can define typed event handlers using `defineHandler`.
```js
import { H3, defineHandler } from "h3";
const app = new H3();
const handler = defineHandler((event) => "Response");
app.get("/", handler);
```
> [!NOTE]
> Using `defineHandler` is optional.
> You can instead, simply use a function that accepts an [`H3Event`](/guide/api/h3event) and returns a response.
The callback function can be sync or async:
```js
defineHandler(async (event) => "Response");
```
## Object Syntax
### middleware
You can optionally register some [middleware](/guide/basics/middleware) to run with event handler to intercept request, response or errors.
```js
import { basicAuth } from "h3";
defineHandler({
middleware: [basicAuth({ password: "test" })],
handler: (event) => "Hi!",
});
```
<read-more></read-more>
<read-more></read-more>
### meta
You can define optional route meta attached to handlers, and access them from any other middleware.
```js
import { H3, defineHandler } from "h3";
const app = new H3();
app.use((event) => {
console.log(event.context.matchedRoute?.meta); // { tag: "admin" }
});
app.get("/admin/**", defineHandler({
meta: { tag: "admin" },
handler: (event) => "Hi!",
})
```
<read-more>
It is also possible to add route meta when registering them to app instance.
</read-more>
## Handler `.fetch`
Event handlers defined with `defineHandler`, can act as a web handler without even using [H3](/guide/api/h3) class.
```js
const handler = defineHandler(async (event) => `Request: ${event.req.url}`);
const response = await handler.fetch("http://localhost/");
console.log(response, await response.text());
```
## Lazy Handlers
You can define lazy event handlers using `defineLazyEventHandler`. This allow you to define some one-time logic that will be executed only once when the first request matching the route is received.
A lazy event handler must return an event handler.
```js
import { defineLazyEventHandler } from "h3";
defineLazyEventHandler(async () => {
await initSomething(); // Will be executed only once
return (event) => {
return "Response";
};
});
```
This is useful to define some one-time logic such as configuration, class initialization, heavy computation, etc.
Another use-case is lazy loading route chunks:
```js [app.mjs]
import { H3, defineLazyEventHandler } from "h3";
const app = new H3();
app.all(
"/route",
defineLazyEventHandler(() => import("./route.mjs").then((mod) => mod.default)),
);
```
```js [route.mjs]
import { defineHandler } from "h3";
export default defineHandler((event) => "Hello!");
```
## Converting to Handler
There are situations that you might want to convert an event handler or utility made for Node.js or another framework to H3.
There are built-in utils to do this.
### From Web Handlers
Request handlers with [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) => [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) signuture can be converted into H3 event handlers using `fromWebHandler` utility or [H3.mount](/guide/api/h3#h3mount).
```js
import { H3, fromWebHandler } from "h3";
export const app = new H3();
const webHandler = (request) => new Response("👋 Hello!");
// Using fromWebHandler utiliy
app.all("/web", fromWebHandler(webHandler));
// Using simple wrapper
app.all("/web", (event) => webHandler(event.req));
// Using app.mount
app.mount("/web", webHandler);
```
### From Node.js Handlers
If you have a legacy request handler with `(req, res) => {}` syntax made for Node.js, you can use `fromNodeHandler` to convert it to an h3 event handler.
> [!IMPORTANT]
> Node.js event handlers can only run within Node.js server runtime!
```js
import { H3, fromNodeHandler } from "h3";
// Force using Node.js compatibility (also works with Bun and Deno)
import { serve } from "h3/node";
export const app = new H3();
const nodeHandler = (req, res) => {
res.end("Node handlers work!");
};
app.get("/web", fromNodeHandler(nodeHandler));
```
@@ -0,0 +1,68 @@
# Request Lifecycle
> H3 dispatches incoming web requests to final web responses.
Below is an overview of what happens in a H3 server from when an HTTP request arrives until a response is generated.
## 1. Incoming Request
When An HTTP request is made by Browser or [fetch()](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), server fetch handler receives a [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object.
```mermaid
%%{init: {'theme':'neutral'}}%%
flowchart LR
A1["<code>fetch(request)</code>"] --> A2["<code>server.fetch(request)</code>"]
click A2 "/guide/api/h3#h3fetch"
```
> [!TIP]
> [💥 Srvx](https://srvx.h3.dev) provides unified `server.fetch` interface and adds [Node.js compatibility](https://srvx.h3.dev/guide/node).
## 2. Accept Request
H3 Initializes an [`H3Event`](/guide/api/h3event) instance from incoming request, calls [`onRequest`](/guide/api/h3#global-hooks) global hook and finally [`H3.handler`](/guide/api/h3#h3handler) with the initialized event.
```mermaid
%%{init: {'theme':'neutral'}}%%
flowchart LR
B1["<code>new H3Event(request)</code>"] --> B2["<code>onRequest(event)</code>"] --> B3["<code>h3.handler(event)</code>"]
click B1 "/guide/api/h3event"
click B2 "/guide/api/h3#global-hooks"
click B3 "/guide/api/h3#apphandler"
```
## 3. Dispatch Request
H3 [matches route](/guide/basics/routing) based on `request.url` and `request.method`, calls global [middleware](/guide/basics/middleware) and finally matched route handler function with event.
```mermaid
%%{init: {'theme':'neutral'}}%%
sequenceDiagram
participant MiddlewareA as Middleware1(event, next)
participant MiddlewareB as Middleware2(event, next)
participant Route as RouteHandler(event)
MiddlewareA->>+MiddlewareB: await next()
MiddlewareB->>+Route: await next()
Route-->>-MiddlewareB: rawBody
MiddlewareB-->>-MiddlewareA: rawBody
```
> [!TIP]
> 🚀 Internally, H3 uses srvx `FastURL` instead of `new URL(req.url).pathname`.
## 4. Send Response
H3 [converts](/guide/basics/response#response-types) returned value and [prepared headers](/guide/basics/response#preparing-response) into a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response), calls [`onResponse`](/guide/api/h3#global-hooks) global hook and finally returns response back to the server fetch handler.
```mermaid
%%{init: {'theme':'neutral'}}%%
flowchart LR
D1["Returned Value => Response"] --> D2["<code>onResponse(response)</code>"] --> D3["Response"]
click D1 "/guide/basics/response"
click D2 "/guide/api/h3#global-hooks"
```
@@ -0,0 +1,97 @@
# Middleware
> Intercept request, response and errors using H3 middleware.
> [!IMPORTANT]
> We recommend using composable utilities whenever possible. Global middleware can complicate application logic, making it less predictable and harder to understand.
Global middleware run on each request before route handler and act as wrappers to intercept request, response and errors.
<read-more></read-more>
You can register global middleware to [app instance](/guide/api/h3) using the [`H3.use`](/guide/api/h3#h3use).
**Example:** Register a global middleware that logs every request.
```js
app.use((event) => {
console.log(event);
});
```
**Example:** Register a global middleware that matches certain requests.
```js
app.use(
"/blog/**",
(event, next) => {
console.log("[alert] POST request on /blog paths!");
},
{
method: "POST",
// match: (event) => event.req.method === "POST",
},
);
```
You can register middleware with `next` argument to intercept return values of next middleware and handler.
```js
app.use(async (event, next) => {
const rawBody = await next();
// [intercept response]
return rawBody;
});
```
Example below, always responds with `Middleware 1`.
```js
app
.use(() => "Middleware 1")
.use(() => "Middleware 2")
.get("/", "Hello");
```
> [!IMPORTANT]
> If middleware returns a value other than `undefined` or the result of `next()`, it immediately intercepts request handling and sends a response.
When adding routes, you can register middleware that only run with them.
```js
import { basicAuth } from "h3";
app.get(
"/secret",
(event) => {
/* ... */
},
{
middleware: [basicAuth({ password: "test" })],
},
);
```
For convenience, H3 provides middleware factory functions `onRequest`, `onResponse`, and `onError`:
```js
import { onRequest, onResponse, onError } from "h3";
app.use(
onRequest((event) => {
console.log(`[${event.req.method}] ${event.url.pathname}`);
}),
);
app.use(
onResponse((response, event) => {
console.log(`[${event.req.method}] ${event.url.pathname} ~>`, response.status);
}),
);
app.use(
onError((error, event) => {
console.log(`[${event.req.method}] ${event.url.pathname} !! ${error.message}`);
}),
);
```
@@ -0,0 +1,57 @@
# Nested Apps
> H3 has a native `mount` method for adding nested sub-apps to the main instance.
Typically, H3 projects consist of several [Event Handlers](/guide/basics/handler) defined in one or multiple files (or even [lazy loaded](/guide/basics/handler#lazy-handlers) for faster startup times).
It is sometimes more convenient to combine multiple `H3` instances or even use another HTTP framework used by a different team and mount it to the main app instance. H3 provides a native [`.mount`](/guide/api/h3#h3mount) method to facilitate this.
## Nested H3 Apps
H3 natively allows mounting sub-apps. When mounted, sub-app routes and middleware are **merged** with the base url prefix into the main app instance.
```js
import { H3, serve } from "h3";
const nestedApp = new H3()
.use((event) => {
event.res.headers.set("x-api", "1");
})
.get("/**:slug", (event) => ({
pathname: event.url.pathname,
slug: event.context.params?.slug,
}));
const app = new H3().mount("/api", nestedApp);
```
In the example above, when fetching the `/api/test` URL, `pathname` will be `/api/test` (the real path), and `slug` will be `/test` (wildcard param).
> [!NOTE]
> Global config and hooks won't be inherited from the nested app. Consider always setting them from the main app.
## Nested Web Standard Apps
Mount a `.fetch` compatible server instance like [Hono](https://hono.dev/) or [Elysia](https://elysiajs.com/) under the base URL.
> [!NOTE]
> Base prefix will be removed from `request.url` passed to the mounted app.
```js
import { H3 } from "h3";
import { Hono } from "hono";
import { Elysia } from "elysia";
const app = new H3()
.mount(
"/elysia",
new Elysia().get("/test", () => "Hello Elysia!"),
)
.mount(
"/hono",
new Hono().get("/test", (c) => c.text("Hello Hono!")),
);
```
> [!TIP]
> Similarly, you can mount an H3 app in [Hono](https://hono.dev/docs/api/hono#mount) or [Elysia](https://elysiajs.com/patterns/mount#mount-1).
@@ -0,0 +1,162 @@
# Sending Response
> H3 automatically converts any returned value into a web response.
Values returned from [Event Handlers](/guide/basics/handler) are automatically converted to a web [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) by H3.
**Example:** Simple event handler function.
```js
const handler = defineHandler((event) => ({ hello: "world" }));
```
H3 smartly converts handler into:
```js
const handler = (event) =>
new Response(JSON.stringify({ hello: "world" }), {
headers: {
"content-type": "application/json;charset=UTF-8",
},
});
```
> [!TIP]
> 🚀 H3 uses srvx `FastResponse` internally to optimize performances in Node.js runtime.
If the returned value from event handler is a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) or from an [async function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function), H3 will wait for it to resolve before sending the response.
If an error is thrown, H3 automatically handles it with error handler.
<read-more></read-more>
## Preparing Response
Before returning a response in main handler, you can prepare response headers and status using [`event.res`](/guide/api/h3event#eventres).
```js
defineHandler((event) => {
event.res.status = 200;
event.res.statusText = "OK";
event.res.headers.set("Content-Type", "text/html");
return "<h1>Hello, World</h1>";
});
```
> [!NOTE]
> If a full [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response) value is returned, prepared status is discarded and headers will be merged/overriden. For performance reasons, it is best to only set headers only from final Response in this case.
> [!NOTE]
> If an Error happens, prepared status and headers will be discarded. The recommended way to include headers in error responses is via `new HTTPError({ headers })`. As a last resort for headers that need to be set implicitly before the error is known (e.g., CORS), you can use `event.res.errHeaders` — these will be merged into error responses automatically.
## Response Types
H3 smartly converts JavaScript values into web [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response).
### JSON Serializable Value
Returning a [JSON](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON) serializable value (**object**, **array**, **number** or **boolean**), it will be stringified using [JSON.stringify()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) and sent with default `application/json` content-type.
**Example:**
```ts
app.get("/", (event) => ({ hello: "world" }));
```
> [!TIP]
> Returned objects with `.toJSON()` property can customize serialization behavior. Check [MDN docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) for more info.
### String
Returning a string value, sends it as plain text body.
> [!NOTE]
> If not setting `content-type` header, it can default to `text/plain;charset=UTF-8`.
**Example:** Send HTML response.
```ts
app.get("/", (event) => {
event.res.headers.set("Content-Type", "text/html;charset=UTF-8");
return "<h1>hello world</h1>";
});
```
You can also use `html` utility as shortcut.
```js
import { html } from "h3";
app.get("/", () => html("<h1>hello world</h1>"));
```
### `Response`
Returning a web [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response), sends-it as final reponse.
**Example:**
```ts
app.get("/", (event) => new Response("Hello, world!", { headers: { "x-powered-by": "H3" } }));
```
> [!IMPORTANT]
> When sending a `Response`, any [prepared headers](#preparing-response) that set before, will be merged as default headers. `event.res.{status,statusText}` will be ignored. For performance reasons, it is best to only set headers only from final `Response`.
### `ReadableStream` or `Readable`
Returning a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) or Node.js [`Readable`](https://nodejs.org/api/stream.html#readable-streams) sends it as stream.
### `ArrayBuffer` or `Uint8Array` or `Buffer`
Send binary [ArrayBuffer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer), [Uint8Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array) or node [Buffer](https://nodejs.org/api/buffer.html#buffer).
`content-length` header will be automatically set.
### `Blob`
Send a [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) as stream.
`Content-type` and `Content-Length` headers will be automatically set.
### `File`
Send a [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) as stream.
`Content-type`, `Content-Length` and `Content-Disposition` headers will be automatically set.
## Special Types
Some less commonly possible values for response types.
### `null` or `undefined`
Sends a response with empty body.
> [!TIP]
> If there is no `return` statement in event handler, it is same as `return undefined`.
### `Error`
Retuning an [`Error`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) instance will send it.
> [!IMPORTANT]
> It is better to `throw` errors instead of returning them. This allows proper propagation from any nested utility.
<read-more></read-more>
### `BigInt`
Value will be sent as stringified version of BigInt number.
> [!NOTE]
> Returning a JSON object, does not allows BigInt serialization. You need to implement `.toJSON`. Check [MDN docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) for more info.
### `Symbol` or `Function`
**Returning Symbol or Function has undetermined behavior.** Currently, H3 sends a string-like representation of unknown Symbols and Functions but this behavior might be changed to throw an error in the future versions.
There are some internal known Symbols H3 internally uses:
- `Symbol.for("h3.notFound")`: Indicate no route is found to throw a 404 error.
- `Symbol.for("h3.handled")`: Indicate request is somehow handled and H3 should not continue (Node.js specific).
@@ -0,0 +1,92 @@
# Routing
> Each request is matched to one (most specific) route handler.
## Adding Routes
You can register route [handlers](/guide/basics/handler) to [H3 instance](/guide/api/h3) using [`H3.on`](/guide/api/h3#h3on), [`H3.[method]`](/guide/api/h3#h3method), or [`H3.all`](/guide/api/h3#h3all).
> [!TIP]
> Router is powered by [🌳 Rou3](https://github.com/h3js/rou3), an ultra-fast and tiny route matcher engine.
**Example:** Register a route to match requests to the `/hello` endpoint with HTTP **GET** method.
- Using [`H3.[method]`](/guide/api/h3#h3method)
```js
app.get("/hello", () => "Hello world!");
```
- Using [`H3.on`](/guide/api/h3#h3on)
```js
app.on("GET", "/hello", () => "Hello world!");
```
You can register multiple event handlers for the same route with different methods:
```js
app
.get("/hello", () => "GET Hello world!")
.post("/hello", () => "POST Hello world!")
.all("/hello", () => "Any other method!");
```
You can also use [`H3.all`](/guide/api/h3#h3all) method to register a route accepting any HTTP method:
```js
app.all("/hello", (event) => `This is a ${event.req.method} request!`);
```
## Dynamic Routes
You can define dynamic route parameters using `:` prefix:
```js
// [GET] /hello/Bob => "Hello, Bob!"
app.get("/hello/:name", (event) => {
return `Hello, ${event.context.params.name}!`;
});
```
Instead of named parameters, you can use `*` for unnamed **optional** parameters:
```js
app.get("/hello/*", (event) => `Hello!`);
```
## Wildcard Routes
Adding `/hello/:name` route will match `/hello/world` or `/hello/123`. But it will not match `/hello/foo/bar`.
When you need to match multiple levels of sub routes, you can use `**` prefix:
```js
app.get("/hello/**", (event) => `Hello ${event.context.params._}!`);
```
This will match `/hello`, `/hello/world`, `/hello/123`, `/hello/world/123`, etc.
> [!NOTE]
> Param `_` will store the full wildcard content as a single string.
## Route Meta
You can define optional route meta when registering them, accessible from any middleware.
```js
import { H3 } from "h3";
const app = new H3();
app.use((event) => {
console.log(event.context.matchedRoute?.meta); // { auth: true }
});
app.get("/", (event) => "Hi!", { meta: { auth: true } });
```
<read-more>
It is also possible to add route meta when defining them using `defineHandler` object syntax.
</read-more>
+117
View File
@@ -0,0 +1,117 @@
# Getting Started
> Get started with H3.
> [!IMPORTANT]
> You are currently reading H3 v2 docs. See [v1.h3.dev](https://v1.h3.dev/) for legacy docs.
## Overview
⚡ H3 (short for H(TTP), pronounced as /eɪtʃθriː/, like h-3) is a lightweight, fast, and composable server framework for modern JavaScript runtimes. It is based on web standard primitives such as [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request), [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response), [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL), and [Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers). You can integrate H3 with any compatible runtime or [mount](/guide/api/h3#h3mount) other web-compatible handlers to H3 with almost no added latency.
H3 is designed to be extendable and composable. Instead of providing one big core, you start with a lightweight [H3 instance](/guide/api/h3) and then import built-in, tree-shakable [utilities](/utils) or bring your own for more functionality.
Composable utilities has several advantages:
- The server only includes used code and runs them exactly where is needed.
- Application size can scale better. Usage of utilities is explicit and clean, with less global impact.
- H3 is minimally opinionated and won't limit your choices.
All utilities, share an [H3Event](/guide/api/h3event) context.
<read-more></read-more>
## Quick Start
> [!TIP]
> You try H3 online [on ⚡️ Stackblitz ](https://stackblitz.com/github/h3js/h3/tree/main/playground?file=server.mjs).
Install `h3` as a dependency:
<pm-install></pm-install>
Create a new file for server entry:
```ts [server.mjs]
import { H3, serve } from "h3";
const app = new H3().get("/", (event) => "⚡️ Tadaa!");
serve(app, { port: 3000 });
```
Then, run the server using your favorite runtime:
<code-group>
```bash [node]
node --watch ./server.mjs
```
```bash [deno]
deno run -A --watch ./server.mjs
```
```bash [bun]
bun run --watch server.mjs
```
</code-group>
And tadaa! We have a web server running locally.
### What Happened?
Okay, let's now break down our hello world example.
We first created an [H3](/guide/api/h3) app instance using `new H3()`:
```ts
const app = new H3();
```
[H3](/guide/api/h3) is a tiny class capable of [matching routes](/guide/basics/routing), [generating responses](/guide/basics/response) and calling [middleware](/guide/basics/middleware) and [global hooks](/guide/api/h3#global-hooks).
Then we add a route for handling HTTP GET requests to `/` path.
```ts
app.get("/", (event) => {
return { message: "⚡️ Tadaa!" };
});
```
<read-more></read-more>
We simply returned an object. H3 automatically [converts](/guide/basics/response#response-types) values into web responses.
<read-more></read-more>
Finally, we use `serve` method to start the server listener. Using `serve` method you can easily start an H3 server in various runtimes.
```js
serve(app, { port: 3000 });
```
> [!TIP]
> The `serve` method is powered by [💥 srvx](https://srvx.h3.dev/), a runtime-agnostic universal server listener based on web standards that works seamlessly with [Deno](https://deno.com/), [Node.js](https://nodejs.org/) and [Bun](https://bun.sh/).
We also have [`app.fetch`](/guide/api/h3#h3fetch) which can be directly used to run H3 apps in any web-compatible runtime or even directly called for testing purposes.
<read-more></read-more>
```js
import { H3, serve } from "h3";
const app = new H3().get("/", () => "⚡️ Tadaa!");
// Test without listening
const response = await app.request("/");
console.log(await response.text());
```
You can directly import `h3` library from CDN alternatively. This method can be used for Bun, Deno and other runtimes such as Cloudflare Workers.
```js
import { H3 } from "https://esm.sh/h3";
const app = new H3().get("/", () => "⚡️ Tadaa!");
export const fetch = app.fetch;
```
+200
View File
@@ -0,0 +1,200 @@
# Migration guide for v1 to v2
H3 version 2 includes some behavior and API changes that you need to consider applying when migrating.
> [!NOTE]
> Currently H3 v2 in beta stage. You can try with [nightly channel](/guide/advanced/nightly).
> [!NOTE]
> This is an undergoing migration guide and might be updated.
> [!TIP]
> H3 has a brand new documentation rewrite. Head to the new [Guide](/guide) section to learn more!
## Latest Node.js and ESM-only
> [!TIP]
> H3 v2 requires Node.js >= 20.11 (latest LTS recommended) .
If your application is currently using CommonJS modules (`require` and `module.exports`), You can still use `require("h3")` thanks to `require(esm)` supported in latest Node.js versions.
You can alternatively use other compatible runtimes [Bun](https://bun.sh/) or [Deno](https://deno.com/).
## Web Standards
> [!TIP]
> H3 v2 is rewritten based on web standard primitives ([`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL), [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers), [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request), and [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)).
When using Node.js, H3 uses a compatibility layer ([💥 srvx](https://srvx.h3.dev/guide/node)) and in other runtimes uses native web compatibility APIs.
Access to the native `event.node.{req,res}` is only available when running server in Node.js runtime.
`event.web` is renamed to `event.req` (instance of web [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request)).
## Response Handling
> [!TIP]
> You should always explicitly **return** the response body or **throw** an error.
If you were previously using methods below, you can replace them with `return` statements returning a text, JSON, stream, or web `Response` (h3 smartly detects and handles each):
- `send(event, value)`: Migrate to `return <value>`.
- `sendError(event, <error>)`: Migrate to `throw createError(<error>)`.
- `sendStream(event, <stream>)`: Migrate to `return <stream>`.
- `sendWebResponse(event, <response>)`: Migrate to `return <response>`.
Other send utils that are renamed and need explicit `return`:
- `sendNoContent(event)` / `return null`: Migrate to `return noContent()`.
- `sendIterable(event, <value>)`: Migrate to `return iterable(<value>)`.
- `sendProxy(event, target)`: Migrate to `return proxy(event, target)`.
- `handleCors(event)`: Check return value and early `return` if handled(not `false`).
- `serveStatic(event, content)`: Make sure to add `return` before.
- `sendRedirect(event, location, code)`: Migrate to `return redirect(location, code)`.
<read-more></read-more>
## H3 and Router
> [!TIP]
> Router function is now integrated into the H3 core.
> Instead of `createApp()` and `createRouter()` you can use [`new H3()`](/guide/api/h3).
Any handler can return a response. If middleware don't return a response, next handlers will be tried and finally make a 404 if neither responses. Router handlers can return or not return any response, in this case, H3 will send a simple 200 with empty content.
<read-more></read-more>
H3 migrated to a brand new route-matching engine ([🌳 rou3](https://rou3.h3.dev/)). You might experience slight (but more intuitive) behavior changes for matching patterns.
**Other changes from v1:**
- Middleware added with `app.use("/path", handler)` only matches `/path` (not `/path/foo/bar`). For matching all subpaths like before, it should be updated to `app.use("/path/**", handler)`.
- The `event.path` received in each handler will have a full path without omitting the prefixes. use `withBase(base, handler)` utility to make prefixed app. (example: `withBase("/api", app.handler)`).
- **`router.add(path, method: Method | Method[]` signature is changed to `router.add(method: Method, path)`**
- `router.use(path, handler)` is deprecated. Use `router.all(path, handler)` instead.
- `app.use(() => handler, { lazy: true })` is no supported anymore. Instead you can use `app.use(defineLazyEventHandler(() => handler), { lazy: true })`.
- `app.use(["/path1", "/path2"], ...)` and `app.use("/path", [handler1, handler2])` are not supported anymore. Instead, use multiple `app.use()` calls.
- `app.resolve(path)` removed.
<read-more></read-more>
<read-more></read-more>
## Request Body
> [!TIP]
> Most of request body utilities can now be replaced with native `event.req.*` methods which is based on web [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Response) interface.
`readBody(event)` utility will use [`JSON.parse`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse) or [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) for parsing requests with `application/x-www-form-urlencoded` content-type.
- For text: Use [event.req.text()](https://developer.mozilla.org/en-US/docs/Web/API/Request/text).
- For json: Use [event.req.json()](https://developer.mozilla.org/en-US/docs/Web/API/Request/json).
- For formData: Use [event.req.formData()](https://developer.mozilla.org/en-US/docs/Web/API/Request/formData).
- For stream: Use [event.req.body](https://developer.mozilla.org/en-US/docs/Web/API/Request/body).
**Behavior changes:**
- Body utils won't throw an error if the incoming request has no body (or is a `GET` method for example) but instead, return empty values.
- Native `request.json` and `readBody` does not use [unjs/destr](https://destr.unjs.io) anymore. You should always filter and sanitize data coming from user to avoid [prototype-poisoning](https://medium.com/intrinsic-blog/javascript-prototype-poisoning-vulnerabilities-in-the-wild-7bc15347c96).
## Cookie and Headers
> [!TIP]
H3 now natively uses standard web [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) for all utils.
Header values are always a plain `string` now (no `null` or `undefined` or `number` or `string[]`).
For the [`Set-Cookie`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) header, you can use [`headers.getSetCookie`](https://developer.mozilla.org/en-US/docs/Web/API/Headers/getSetCookie) that always returns a string array.
## Other Deprecations
H3 v2 deprecated some legacy and aliased utilities.
### App and router utils
- `createApp` / `createRouter`: Migrate to `new H3()`.
### Error utils
- `createError`/`H3Error`: Migrate to `HTTPError`
- `isError`: Migrate to `HTTPError.isError`
### Handler utils
- `eventHandler`/`defineEventHandler`: Migrate to `defineHandler` (you can also directly use a function!).
- `lazyEventHandler`: Migrate to `defineLazyEventHandler`.
- `isEventHandler`: (removed) Any function can be an event handler.
- `useBase`: Migrate to `withBase`.
- `defineRequestMiddleware` and `defineResponseMiddleware` removed.
### Request utils
- `getHeader` / `getRequestHeader`: Migrate to `event.req.headers.get(name)`.
- `getHeaders` / `getRequestHeaders`: Migrate to `Object.fromEntries(event.req.headers.entries())`.
- `getRequestPath`: Migrate to `event.url.pathname`.
- `getMethod`: Migrate to `event.req.method`.
> [!NOTE]
The following `H3Event` properties are deprecated in v2 and might be removed in a future version:
> - `event.path` → use `event.url.pathname + event.url.search`
> - `event.method` → use `event.req.method`
> - `event.headers` → use `event.req.headers`
> - `event.node` → use `event.runtime.node`
### Response utils
- `getResponseHeader` / `getResponseHeaders`: Migrate to `event.res.headers.get(name)`
- `setHeader` / `setResponseHeader` / `setHeaders` / `setResponseHeaders`: Migrate to `event.res.headers.set(name, value)`.
- `appendHeader` / `appendResponseHeader` / `appendResponseHeaders`: Migrate to `event.res.headers.append(name, value)`.
- `removeResponseHeader` / `clearResponseHeaders`: Migrate to `event.res.headers.delete(name)`
- `appendHeaders`: Migrate to `appendResponseHeaders`.
- `defaultContentType`: Migrate to `event.res.headers.set("content-type", type)`
- `getResponseStatus` / `getResponseStatusText` / `setResponseStatus`: Use `event.res.status` and `event.res.statusText`.
### Node.js utils
- `defineNodeListener`: Migrate to `defineNodeHandler`.
- `fromNodeMiddleware`: Migrate to `fromNodeHandler`.
- `toNodeListener`: Migrate to `toNodeHandler`.
- `createEvent`: (removed): Use Node.js adapter (`toNodeHandler(app)`).
- `fromNodeRequest`: (removed): Use Node.js adapter (`toNodeHandler(app)`).
- `promisifyNodeListener` (removed).
- `callNodeListener`: (removed).
### Web Utils
- `fromPlainHandler`: (removed) Migrate to Web API.
- `toPlainHandler`: (removed) Migrate to Web API.
- `fromPlainRequest` (removed) Migrate to Web API or use `mockEvent` util for testing.
- `callWithPlainRequest` (removed) Migrate to Web API.
- `fromWebRequest`: (removed) Migrate to Web API.
- `callWithWebRequest`: (removed).
### Body Utils
- `readRawBody`: Migrate to `event.req.text()` or `event.req.arrayBuffer()`.
- `getBodyStream` / `getRequestWebStream`: Migrate to `event.req.body`.
- `readFormData` / `readMultipartFormData` / `readFormDataBody`: Migrate to `event.req.formData()`.
### Other Utils
- `isStream`: Migrate to `instanceof ReadableStream`.
- `isWebResponse`: Migrate to `instanceof Response`.
- `splitCookiesString`: Use `splitSetCookieString` from [cookie-es](https://github.com/unjs/cookie-es).
- `MIMES`: (removed).
### Type Exports
> [!NOTE]
There might be more type changes.
- `App`: Migrate to `H3`.
- `AppOptions`: Migrate to `H3Config`.
- `_RequestMiddleware`: Migrate to `RequestMiddleware`.
- `_ResponseMiddleware`: Migrate to `ResponseMiddleware`.
- `NodeListener`: Migrate to `NodeHandler`.
- `TypedHeaders`: Migrate to `RequestHeaders` and `ResponseHeaders`.
- `HTTPHeaderName`: Migrate to `RequestHeaderName` and `ResponseHeaderName`.
- `H3Headers`: Migrate to native `Headers`.
- `H3Response`: Migrate to native `Response`.
- `MultiPartData`: Migrate to native `FormData`.
- `RouteNode`: Migrate to `RouterEntry`.
`CreateRouterOptions`: Migrate to `RouterOptions`.
Removed type exports: `WebEventContext`, `NodeEventContext`, `NodePromisifiedHandler`, `AppUse`, `Stack`, `InputLayer`, `InputStack`, `Layer`, `Matcher`, `PlainHandler`, `PlainRequest`, `PlainResponse`, `WebHandler`.
+42
View File
@@ -0,0 +1,42 @@
# Community
> H3 utils from community.
You can use external H3 event utilities made by the community.
This section is placeholder for any new H3 version 2 compatible community library.
> [!TIP]
> 💛 PR is more than welcome to list yours.
## `apitally`
[Apitally](https://apitally.io/h3) is a simple API monitoring, analytics, and request logging tool with a plugin for H3. See setup guide [here](https://docs.apitally.io/frameworks/h3).
<read-more></read-more>
## `H3ravel Framework`
[H3ravel Framework](https://h3ravel.toneflix.net) is a modern TypeScript runtime-agnostic web framework built on top of H3, designed to bring the elegance and developer experience of Laravel PHP to the JavaScript ecosystem. See the getting started guide [here](https://h3ravel.toneflix.net/guide/get-started).
<read-more></read-more>
## `Intlify`
[Intlify](https://intlify.dev/) is a project that aims to improve Developer Experience in software internationalization. That project provides server-side frameworks, middleware, and utilities. About those, see the [here](https://github.com/intlify/srvmid)
<read-more></read-more>
## `Clear Router`
Laravel-style routing system for H3 and Express.js. Clean route definitions, middleware support, and controller bindings with full TypeScript support.
<read-more></read-more>
## `unjwt`
`unjwt` is a collection of low-level JWT utilities (JWS, JWE, JWK) built on the Web Crypto API, with zero runtime dependencies. It includes a dedicated H3 v2 adapter for header and cookie-based session management with support for encrypted (JWE) and signed (JWS) tokens.
<read-more></read-more>
+33
View File
@@ -0,0 +1,33 @@
# Cookie
> H3 cookie utilities.
### `deleteChunkedCookie(event, name, serializeOptions?)`
Remove a set of chunked cookies by name.
### `deleteCookie(event, name, serializeOptions?)`
Remove a cookie by name.
### `getChunkedCookie(event, name)`
Get a chunked cookie value by name. Will join chunks together.
### `getCookie(event, name)`
Get a cookie value by name.
### `getValidatedCookies(event, validate, options?: { onError?: OnValidateError })`
### `parseCookies(event)`
Parse the request to get HTTP Cookie header string and returning an object of all cookie name-value pairs.
### `setChunkedCookie(event, name, value, options?)`
Set a cookie value by name. Chunked cookies will be created as needed.
### `setCookie(event, name, value, options?)`
Set a cookie value by name.
+46
View File
@@ -0,0 +1,46 @@
# H3 Utils
H3 is a composable framework. Instead of providing a big core, you start with a lightweight [H3](/guide/api/h3) instance and for every functionality, there is either a built-in utility or you can make yours.
<card-group>
<card>
Utilities for incoming request.
</card>
<card>
Utilities for preparing and sending response.
</card>
<card>
Cookie utilities.
</card>
<card>
Security utilities.
</card>
<card>
Proxy utilities.
</card>
<card>
MCP related utilities.
</card>
<card>
More Utilities.
</card>
<card>
Community made utilities.
</card>
</card-group>
+71
View File
@@ -0,0 +1,71 @@
# MCP
> H3 MCP related utils.
### `defineJsonRpcHandler()`
Creates an H3 event handler that implements the JSON-RPC 2.0 specification.
**Example:**
```ts
app.post(
"/rpc",
defineJsonRpcHandler({
methods: {
echo: ({ params }, event) => {
return `Received \`${params}\` on path \`${event.url.pathname}\``;
},
sum: ({ params }, event) => {
return params.a + params.b;
},
},
}),
);
```
### `defineJsonRpcWebSocketHandler()`
Creates an H3 event handler that implements JSON-RPC 2.0 over WebSocket.
This is an opt-in feature that allows JSON-RPC communication over WebSocket connections for bi-directional messaging. Each incoming WebSocket text message is processed as a JSON-RPC request, and responses are sent back to the peer.
**Example:**
```ts
app.get(
"/rpc/ws",
defineJsonRpcWebSocketHandler({
methods: {
echo: ({ params }) => {
return `Received: ${Array.isArray(params) ? params[0] : params?.message}`;
},
sum: ({ params }) => {
return params.a + params.b;
},
},
}),
);
```
**Example:**
```ts
// With additional WebSocket hooks
app.get(
"/rpc/ws",
defineJsonRpcWebSocketHandler({
methods: {
greet: ({ params }) => `Hello, ${params.name}!`,
},
hooks: {
open(peer) {
console.log(`Peer connected: ${peer.id}`);
},
close(peer, details) {
console.log(`Peer disconnected: ${peer.id}`, details);
},
},
}),
);
```
+78
View File
@@ -0,0 +1,78 @@
# More utils
> More H3 utilities.
## Base
### `withBase(base, input)`
Returns a new event handler that removes the base url of the event before calling the original handler.
**Example:**
```ts
const api = new H3()
.get("/", () => "Hello API!");
const app = new H3();
.use("/api/**", withBase("/api", api.handler));
```
## Event
### `getEventContext(event)`
Gets the context of the event, if it does not exists, initializes a new context on `req.context`.
### `isEvent(input)`
Checks if the input is an H3Event object.
### `isHTTPEvent(input)`
Checks if the input is an object with `{ req: Request }` signature.
### `mockEvent(_request, options?)`
## Middleware
### `bodyLimit(limit)`
Define a middleware that checks whether request body size is within specified limit.
If body size exceeds the limit, throws a `413` Request Entity Too Large response error. If you need custom handling for this case, use `assertBodySize` instead.
### `onError(hook)`
Define a middleware that runs when an error occurs.
You can return a new Response from the handler to gracefully handle the error.
### `onRequest(hook)`
Define a middleware that runs on each request.
### `onResponse(hook)`
Define a middleware that runs after Response is generated.
You can return a new Response from the handler to replace the original response.
## WebSocket
### `defineWebSocket(hooks)`
Define WebSocket hooks.
### `defineWebSocketHandler()`
Define WebSocket event handler.
## Adapters
### `defineNodeHandler(handler)`
### `defineNodeMiddleware(handler)`
### `fromNodeHandler(handler)`
### `fromWebHandler(handler)`
+31
View File
@@ -0,0 +1,31 @@
# Proxy
> H3 proxy utilities.
### `fetchWithEvent(event, url, init?)`
Make a fetch request with the event's context and headers.
If the `url` starts with `/`, the request is dispatched internally via `event.app.fetch()` (sub-request) and never leaves the process.
**Security:** Never pass unsanitized user input as the `url`. Callers are responsible for validating and restricting the URL.
### `getProxyRequestHeaders(event)`
Get the request headers object without headers known to cause issues when proxying.
### `proxy(event, target, opts)`
Make a proxy request to a target URL and send the response back to the client.
If the `target` starts with `/`, the request is dispatched internally via `event.app.fetch()` (sub-request) and never leaves the process. This bypasses any external security layer (reverse proxy auth, IP allowlisting, mTLS).
**Security:** Never pass unsanitized user input as the `target`. Callers are responsible for validating and restricting the target URL (e.g. allowlisting hosts, blocking internal paths, enforcing protocol).
### `proxyRequest(event, target, opts)`
Proxy the incoming request to a target URL.
If the `target` starts with `/`, the request is handled internally by the app router via `event.app.fetch()` instead of making an external HTTP request.
**Security:** Never pass unsanitized user input as the `target`. Callers are responsible for validating and restricting the target URL (e.g. allowlisting hosts, blocking internal paths, enforcing protocol). Consider using `bodyLimit()` middleware to prevent large request bodies from consuming excessive resources when proxying untrusted input.
+355
View File
@@ -0,0 +1,355 @@
# Request
> H3 request utilities.
## Body
### `assertBodySize(event, limit)`
Asserts that request body size is within the specified limit.
If body size exceeds the limit, throws a `413` Request Entity Too Large response error.
**Example:**
```ts
app.get("/", async (event) => {
await assertBodySize(event, 10 * 1024 * 1024); // 10MB
const data = await event.req.formData();
});
```
### `readBody(event)`
Reads request body and tries to parse using JSON.parse or URLSearchParams.
**Example:**
```ts
app.get("/", async (event) => {
const body = await readBody(event);
});
```
### `readValidatedBody(event, validate)`
Tries to read the request body via `readBody`, then uses the provided validation schema or function and either throws a validation error or returns the result.
You can use a simple function to validate the body or use a Standard-Schema compatible library like `zod` to define a schema.
**Example:**
```ts
function validateBody(body: any) {
return typeof body === "object" && body !== null;
}
app.post("/", async (event) => {
const body = await readValidatedBody(event, validateBody);
});
```
**Example:**
```ts
import { z } from "zod";
const objectSchema = z.object({
name: z.string().min(3).max(20),
age: z.number({ coerce: true }).positive().int(),
});
app.post("/", async (event) => {
const body = await readValidatedBody(event, objectSchema);
});
```
**Example:**
```ts
import * as v from "valibot";
app.post("/", async (event) => {
const body = await readValidatedBody(
event,
v.object({
name: v.pipe(v.string(), v.minLength(3), v.maxLength(20)),
age: v.pipe(v.number(), v.integer(), v.minValue(1)),
}),
{
onError: ({ issues }) => ({
statusText: "Custom validation error",
message: v.summarize(issues),
}),
},
);
});
```
## Cache
### `handleCacheHeaders(event, opts)`
Check request caching headers (`If-Modified-Since`) and add caching headers (Last-Modified, Cache-Control) Note: `public` cache control will be added by default
## More Request Utils
### `assertMethod(event, expected, allowHead?)`
Asserts that the incoming request method is of the expected type using `isMethod`.
If the method is not allowed, it will throw a 405 error and include an `Allow` response header listing the permitted methods, as required by RFC 9110.
If `allowHead` is `true`, it will allow `HEAD` requests to pass if the expected method is `GET`.
**Example:**
```ts
app.get("/", (event) => {
assertMethod(event, "GET");
// Handle GET request, otherwise throw 405 error
});
```
### `getQuery(event)`
Get parsed query string object from the request URL.
**Example:**
```ts
app.get("/", (event) => {
const query = getQuery(event); // { key: "value", key2: ["value1", "value2"] }
});
```
### `getRequestHost(event, opts: { xForwardedHost? })`
Get the request hostname.
If `xForwardedHost` is `true`, it will use the `x-forwarded-host` header if it exists.
If no host header is found, it will return an empty string.
**Example:**
```ts
app.get("/", (event) => {
const host = getRequestHost(event); // "example.com"
});
```
### `getRequestIP(event)`
Try to get the client IP address from the incoming request.
If `xForwardedFor` is `true`, it will use the `x-forwarded-for` header if it exists.
If IP cannot be determined, it will default to `undefined`.
**Example:**
```ts
app.get("/", (event) => {
const ip = getRequestIP(event); // "192.0.2.0"
});
```
### `getRequestProtocol(event, opts: { xForwardedProto? })`
Get the request protocol.
If `x-forwarded-proto` header is set to "https", it will return "https". You can disable this behavior by setting `xForwardedProto` to `false`.
If protocol cannot be determined, it will default to "http".
**Example:**
```ts
app.get("/", (event) => {
const protocol = getRequestProtocol(event); // "https"
});
```
### `getRequestURL(event, opts: { xForwardedHost?, xForwardedProto? })`
Generated the full incoming request URL.
If `xForwardedHost` is `true`, it will use the `x-forwarded-host` header if it exists.
If `xForwardedProto` is `false`, it will not use the `x-forwarded-proto` header.
**Example:**
```ts
app.get("/", (event) => {
const url = getRequestURL(event); // "https://example.com/path"
});
```
### `getRouterParam(event, name, opts: { decode? })`
Get a matched route param by name.
If `decode` option is `true`, it will decode the matched route param using `decodeURI`.
**Example:**
```ts
app.get("/", (event) => {
const param = getRouterParam(event, "key");
});
```
### `getRouterParams(event, opts: { decode? })`
Get matched route params.
If `decode` option is `true`, it will decode the matched route params using `decodeURIComponent`.
**Example:**
```ts
app.get("/", (event) => {
const params = getRouterParams(event); // { key: "value" }
});
```
### `getValidatedQuery(event, validate)`
Get the query param from the request URL validated with validate function.
You can use a simple function to validate the query object or use a Standard-Schema compatible library like `zod` to define a schema.
**Example:**
```ts
app.get("/", async (event) => {
const query = await getValidatedQuery(event, (data) => {
return "key" in data && typeof data.key === "string";
});
});
```
**Example:**
```ts
import { z } from "zod";
app.get("/", async (event) => {
const query = await getValidatedQuery(
event,
z.object({
key: z.string(),
}),
);
});
```
**Example:**
```ts
import * as v from "valibot";
app.get("/", async (event) => {
const params = await getValidatedQuery(
event,
v.object({
key: v.string(),
}),
{
onError: ({ issues }) => ({
statusText: "Custom validation error",
message: v.summarize(issues),
}),
},
);
});
```
### `getValidatedRouterParams(event, validate)`
Get matched route params and validate with validate function.
If `decode` option is `true`, it will decode the matched route params using `decodeURI`.
You can use a simple function to validate the params object or use a Standard-Schema compatible library like `zod` to define a schema.
**Example:**
```ts
app.get("/:key", async (event) => {
const params = await getValidatedRouterParams(event, (data) => {
return "key" in data && typeof data.key === "string";
});
});
```
**Example:**
```ts
import { z } from "zod";
app.get("/:key", async (event) => {
const params = await getValidatedRouterParams(
event,
z.object({
key: z.string(),
}),
);
});
```
**Example:**
```ts
import * as v from "valibot";
app.get("/:key", async (event) => {
const params = await getValidatedRouterParams(
event,
v.object({
key: v.pipe(v.string(), v.picklist(["route-1", "route-2", "route-3"])),
}),
{
decode: true,
onError: ({ issues }) => ({
statusText: "Custom validation error",
message: v.summarize(issues),
}),
},
);
});
```
### `isMethod(event, expected, allowHead?)`
Checks if the incoming request method is of the expected type.
If `allowHead` is `true`, it will allow `HEAD` requests to pass if the expected method is `GET`.
**Example:**
```ts
app.get("/", (event) => {
if (isMethod(event, "GET")) {
// Handle GET request
} else if (isMethod(event, ["POST", "PUT"])) {
// Handle POST or PUT request
}
});
```
### `requestWithBaseURL(req, base)`
Create a lightweight request proxy with the base path stripped from the URL pathname.
### `requestWithURL(req, url)`
Create a lightweight request proxy that overrides only the URL.
Avoids cloning the original request (no `new Request()` allocation).
### `toRequest(input, options?)`
Convert input into a web [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request).
If input is a relative URL, it will be normalized into a full path based on headers.
If input is already a Request and no options are provided, it will be returned as-is.
### `getRequestFingerprint(event, opts)`
Get a unique fingerprint for the incoming request.
+144
View File
@@ -0,0 +1,144 @@
# Response
> H3 response utilities.
## Event Stream
### `createEventStream(event, opts?)`
Initialize an EventStream instance for creating [server sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)
**Example:**
```ts
import { createEventStream, sendEventStream } from "h3";
app.get("/sse", (event) => {
const eventStream = createEventStream(event);
// Send a message every second
const interval = setInterval(async () => {
await eventStream.push("Hello world");
}, 1000);
// cleanup the interval and close the stream when the connection is terminated
eventStream.onClosed(async () => {
console.log("closing SSE...");
clearInterval(interval);
await eventStream.close();
});
return eventStream.send();
});
```
## Sanitize
### `sanitizeStatusCode(statusCode?, defaultStatusCode)`
Make sure the status code is a valid HTTP status code.
### `sanitizeStatusMessage(statusMessage)`
Make sure the status message is safe to use in a response.
Allowed characters: horizontal tabs, spaces or visible ascii characters: [https://www.rfc-editor.org/rfc/rfc7230#section-3.1.2](https://www.rfc-editor.org/rfc/rfc7230#section-3.1.2)
## Serve Static
### `serveStatic(event, options)`
Dynamically serve static assets based on the request path.
## More Response Utils
### `html(first)`
### `iterable(iterable)`
Iterate a source of chunks and send back each chunk in order. Supports mixing async work together with emitting chunks.
Each chunk must be a string or a buffer.
For generator (yielding) functions, the returned value is treated the same as yielded values.
**Example:**
```ts
return iterable(async function* work() {
// Open document body
yield "<!DOCTYPE html>\n<html><body><h1>Executing...</h1><ol>\n";
// Do work ...
for (let i = 0; i < 1000; i++) {
await delay(1000);
// Report progress
yield `<li>Completed job #`;
yield i;
yield `</li>\n`;
}
// Close out the report
return `</ol></body></html>`;
});
async function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
```
### `noContent(status)`
Respond with an empty payload.
**Example:**
```ts
app.get("/", () => noContent());
```
### `redirect(location, status, statusText?)`
Send a redirect response to the client.
It adds the `location` header to the response and sets the status code to 302 by default.
In the body, it sends a simple HTML page with a meta refresh tag to redirect the client in case the headers are ignored.
**Example:**
```ts
app.get("/", () => {
return redirect("https://example.com");
});
```
**Example:**
```ts
app.get("/", () => {
return redirect("https://example.com", 301); // Permanent redirect
});
```
### `redirectBack(event)`
Redirect the client back to the previous page using the `referer` header.
If the `referer` header is missing or is a different origin, it falls back to the provided URL (default `"/"`).
By default, only the **pathname** of the referer is used (query string and hash are stripped) to prevent spoofed referers from carrying unintended parameters. Set `allowQuery: true` to preserve the query string.
**Security:** The `fallback` value MUST be a trusted, hardcoded path — never use user input. Passing user-controlled values (e.g., query params) as `fallback` creates an open redirect vulnerability.
**Example:**
```ts
app.post("/submit", (event) => {
// process form...
return redirectBack(event, { fallback: "/form" });
});
```
### `writeEarlyHints(event, hints)`
Write `HTTP/1.1 103 Early Hints` to the client.
In runtimes that don't support early hints natively, this function falls back to setting response headers which can be used by CDN.
+109
View File
@@ -0,0 +1,109 @@
# Security
> H3 security utilities.
## Authentication
### `basicAuth(opts)`
Create a basic authentication middleware.
**Example:**
```ts
import { H3, serve, basicAuth } from "h3";
const auth = basicAuth({ password: "test" });
app.get("/", (event) => `Hello ${event.context.basicAuth?.username}!`, [auth]);
serve(app, { port: 3000 });
```
### `requireBasicAuth(event, opts)`
Apply basic authentication for current request.
**Example:**
```ts
import { defineHandler, requireBasicAuth } from "h3";
export default defineHandler(async (event) => {
await requireBasicAuth(event, { password: "test" });
return `Hello, ${event.context.basicAuth.username}!`;
});
```
## Session
### `clearSession(event, config)`
Clear the session data for the current request.
### `getSession(event, config)`
Get the session for the current request.
### `sealSession(event, config)`
Encrypt and sign the session data for the current request.
### `unsealSession(_event, config, sealed)`
Decrypt and verify the session data for the current request.
### `updateSession(event, config, update?)`
Update the session data for the current request.
### `useSession(event, config)`
Create a session manager for the current request.
## Fingerprint
### `getRequestFingerprint(event, opts)`
Get a unique fingerprint for the incoming request.
## CORS
### `appendCorsHeaders(event, options)`
Append CORS headers to the response.
### `appendCorsPreflightHeaders(event, options)`
Append CORS preflight headers to the response.
### `handleCors(event, options)`
Handle CORS for the incoming request.
If the incoming request is a CORS preflight request, it will append the CORS preflight headers and send a 204 response.
If return value is not `false`, the request is handled and no further action is needed.
**Example:**
```ts
const app = new H3();
app.all("/", async (event) => {
const corsRes = handleCors(event, {
origin: "*",
preflight: {
statusCode: 204,
},
methods: "*",
});
if (corsRes !== false) {
return corsRes;
}
// Your code here
});
```
### `isCorsOriginAllowed(origin, options)`
Check if the origin is allowed.
### `isPreflightRequest(event)`
Check if the incoming request is a CORS preflight request.
+10
View File
@@ -0,0 +1,10 @@
---
name: nitro
description: Build and deploy universal JavaScript servers with Nitro
---
@docs/TOC.md
You can use `npx nitro docs [--page <path>] [...args]` to explore the documentation locally.
For example, `npx nitro docs --page /docs/routing` will open the routing page of the guide section.
If not available, fallback to https://nitro.build/llms.txt
+83
View File
@@ -0,0 +1,83 @@
# Nitro Documentation
- [Docs](./docs/index.md)
- [Introduction](./docs/index.md)
- [Quick Start](./docs/quick-start.md)
- [Renderer](./docs/renderer.md)
- [Routing](./docs/routing.md)
- [Server Entry](./docs/server-entry.md)
- [Cache](./docs/cache.md)
- [KV Storage](./docs/storage.md)
- [Assets](./docs/assets.md)
- [Configuration](./docs/configuration.md)
- [Database](./docs/database.md)
- [Lifecycle](./docs/lifecycle.md)
- [Plugins](./docs/plugins.md)
- [Tasks](./docs/tasks.md)
- [Migration Guide](./docs/migration.md)
- [Nightly Channel](./docs/nightly.md)
- [Deploy](./deploy/index.md)
- [Deploy](./deploy/index.md)
- [Node.js](./deploy/runtimes/node.md)
- [Bun](./deploy/runtimes/bun.md)
- [Deno](./deploy/runtimes/deno.md)
- [Alwaysdata](./deploy/providers/alwaysdata.md)
- [AWS Lambda](./deploy/providers/aws.md)
- [AWS Amplify](./deploy/providers/aws-amplify.md)
- [Azure](./deploy/providers/azure.md)
- [Cleavr](./deploy/providers/cleavr.md)
- [Cloudflare](./deploy/providers/cloudflare.md)
- [Deno Deploy](./deploy/providers/deno-deploy.md)
- [DigitalOcean](./deploy/providers/digitalocean.md)
- [Firebase](./deploy/providers/firebase.md)
- [Flightcontrol](./deploy/providers/flightcontrol.md)
- [Genezio](./deploy/providers/genezio.md)
- [GitHub Pages](./deploy/providers/github-pages.md)
- [GitLab Pages](./deploy/providers/gitlab-pages.md)
- [Heroku](./deploy/providers/heroku.md)
- [IIS](./deploy/providers/iis.md)
- [Koyeb](./deploy/providers/koyeb.md)
- [Netlify](./deploy/providers/netlify.md)
- [Platform.sh](./deploy/providers/platform-sh.md)
- [Render.com](./deploy/providers/render.md)
- [StormKit](./deploy/providers/stormkit.md)
- [Vercel](./deploy/providers/vercel.md)
- [Zeabur](./deploy/providers/zeabur.md)
- [Zephyr Cloud](./deploy/providers/zephyr.md)
- [Zerops](./deploy/providers/zerops.md)
- [Config](./config/index.md)
- [Config](./config/index.md)
- [Examples](./examples/index.md)
- [Examples](./examples/index.md)
- [API Routes](./examples/api-routes.md)
- [Auto Imports](./examples/auto-imports.md)
- [Cached Handler](./examples/cached-handler.md)
- [Custom Error Handler](./examples/custom-error-handler.md)
- [Database](./examples/database.md)
- [Elysia](./examples/elysia.md)
- [Express](./examples/express.md)
- [Fastify](./examples/fastify.md)
- [Hello World](./examples/hello-world.md)
- [Hono](./examples/hono.md)
- [Import Alias](./examples/import-alias.md)
- [Middleware](./examples/middleware.md)
- [Mono JSX](./examples/mono-jsx.md)
- [Nano JSX](./examples/nano-jsx.md)
- [Plugins](./examples/plugins.md)
- [Custom Renderer](./examples/renderer.md)
- [Runtime Config](./examples/runtime-config.md)
- [Server Fetch](./examples/server-fetch.md)
- [Shiki](./examples/shiki.md)
- [Virtual Routes](./examples/virtual-routes.md)
- [Vite Nitro Plugin](./examples/vite-nitro-plugin.md)
- [Vite RSC](./examples/vite-rsc.md)
- [Vite SSR HTML](./examples/vite-ssr-html.md)
- [SSR with Preact](./examples/vite-ssr-preact.md)
- [SSR with React](./examples/vite-ssr-react.md)
- [SSR with SolidJS](./examples/vite-ssr-solid.md)
- [SSR with TanStack Router](./examples/vite-ssr-tsr-react.md)
- [SSR with TanStack Start](./examples/vite-ssr-tss-react.md)
- [SSR with Vue Router](./examples/vite-ssr-vue-router.md)
- [Vite + tRPC](./examples/vite-trpc.md)
- [WebSocket](./examples/websocket.md)
- [Index](./index.md)
+679
View File
@@ -0,0 +1,679 @@
[
{
"slug": "docs",
"path": "/docs",
"title": "Docs",
"order": 1,
"icon": "i-lucide-book-open",
"children": [
{
"slug": "",
"path": "/docs",
"title": "Introduction",
"order": 1,
"icon": "i-lucide-compass"
},
{
"slug": "quick-start",
"path": "/docs/quick-start",
"title": "Quick Start",
"order": 2,
"icon": "i-lucide-zap"
},
{
"slug": "renderer",
"path": "/docs/renderer",
"title": "Renderer",
"order": 4,
"icon": "ri:layout-masonry-line"
},
{
"slug": "routing",
"path": "/docs/routing",
"title": "Routing",
"order": 5,
"icon": "ri:direction-line"
},
{
"slug": "server-entry",
"path": "/docs/server-entry",
"title": "Server Entry",
"order": 6,
"icon": "ri:server-line"
},
{
"slug": "cache",
"path": "/docs/cache",
"title": "Cache",
"order": 7,
"icon": "ri:speed-line"
},
{
"slug": "storage",
"path": "/docs/storage",
"title": "KV Storage",
"order": 8,
"icon": "carbon:datastore"
},
{
"slug": "assets",
"path": "/docs/assets",
"title": "Assets",
"order": 50,
"icon": "ri:image-2-line"
},
{
"slug": "configuration",
"path": "/docs/configuration",
"title": "Configuration",
"order": 50,
"icon": "ri:settings-3-line"
},
{
"slug": "database",
"path": "/docs/database",
"title": "Database",
"order": 50,
"icon": "ri:database-2-line"
},
{
"slug": "lifecycle",
"path": "/docs/lifecycle",
"title": "Lifecycle",
"order": 50,
"icon": "i-lucide-layers"
},
{
"slug": "plugins",
"path": "/docs/plugins",
"title": "Plugins",
"order": 50,
"icon": "ri:plug-line"
},
{
"slug": "tasks",
"path": "/docs/tasks",
"title": "Tasks",
"order": 50,
"icon": "codicon:run-all"
},
{
"slug": "migration",
"path": "/docs/migration",
"title": "Migration Guide",
"order": 99,
"icon": "ri:arrow-right-up-line"
},
{
"slug": "nightly",
"path": "/docs/nightly",
"title": "Nightly Channel",
"order": 99,
"icon": "ri:moon-fill"
}
]
},
{
"slug": "deploy",
"path": "/deploy",
"title": "Deploy",
"order": 2,
"children": [
{
"slug": "",
"path": "/deploy",
"title": "Deploy",
"order": 0,
"icon": "ri:upload-cloud-2-line"
},
{
"slug": "runtimes",
"path": "/deploy/runtimes",
"title": "Runtimes",
"order": 10,
"page": false,
"children": [
{
"slug": "node",
"path": "/deploy/runtimes/node",
"title": "Node.js",
"order": 1,
"icon": "akar-icons:node-fill"
},
{
"slug": "bun",
"path": "/deploy/runtimes/bun",
"title": "Bun",
"order": null,
"icon": "simple-icons:bun"
},
{
"slug": "deno",
"path": "/deploy/runtimes/deno",
"title": "Deno",
"order": null,
"icon": "simple-icons:deno"
}
]
},
{
"slug": "providers",
"path": "/deploy/providers",
"title": "Providers",
"order": 20,
"page": false,
"children": [
{
"slug": "alwaysdata",
"path": "/deploy/providers/alwaysdata",
"title": "Alwaysdata",
"order": null
},
{
"slug": "aws",
"path": "/deploy/providers/aws",
"title": "AWS Lambda",
"order": null
},
{
"slug": "aws-amplify",
"path": "/deploy/providers/aws-amplify",
"title": "AWS Amplify",
"order": null
},
{
"slug": "azure",
"path": "/deploy/providers/azure",
"title": "Azure",
"order": null
},
{
"slug": "cleavr",
"path": "/deploy/providers/cleavr",
"title": "Cleavr",
"order": null
},
{
"slug": "cloudflare",
"path": "/deploy/providers/cloudflare",
"title": "Cloudflare",
"order": null
},
{
"slug": "deno-deploy",
"path": "/deploy/providers/deno-deploy",
"title": "Deno Deploy",
"order": null
},
{
"slug": "digitalocean",
"path": "/deploy/providers/digitalocean",
"title": "DigitalOcean",
"order": null
},
{
"slug": "firebase",
"path": "/deploy/providers/firebase",
"title": "Firebase",
"order": null
},
{
"slug": "flightcontrol",
"path": "/deploy/providers/flightcontrol",
"title": "Flightcontrol",
"order": null
},
{
"slug": "genezio",
"path": "/deploy/providers/genezio",
"title": "Genezio",
"order": null
},
{
"slug": "github-pages",
"path": "/deploy/providers/github-pages",
"title": "GitHub Pages",
"order": null
},
{
"slug": "gitlab-pages",
"path": "/deploy/providers/gitlab-pages",
"title": "GitLab Pages",
"order": null
},
{
"slug": "heroku",
"path": "/deploy/providers/heroku",
"title": "Heroku",
"order": null
},
{
"slug": "iis",
"path": "/deploy/providers/iis",
"title": "IIS",
"order": null
},
{
"slug": "koyeb",
"path": "/deploy/providers/koyeb",
"title": "Koyeb",
"order": null
},
{
"slug": "netlify",
"path": "/deploy/providers/netlify",
"title": "Netlify",
"order": null
},
{
"slug": "platform-sh",
"path": "/deploy/providers/platform-sh",
"title": "Platform.sh",
"order": null
},
{
"slug": "render",
"path": "/deploy/providers/render",
"title": "Render.com",
"order": null
},
{
"slug": "stormkit",
"path": "/deploy/providers/stormkit",
"title": "StormKit",
"order": null
},
{
"slug": "vercel",
"path": "/deploy/providers/vercel",
"title": "Vercel",
"order": null
},
{
"slug": "zeabur",
"path": "/deploy/providers/zeabur",
"title": "Zeabur",
"order": null
},
{
"slug": "zephyr",
"path": "/deploy/providers/zephyr",
"title": "Zephyr Cloud",
"order": null
},
{
"slug": "zerops",
"path": "/deploy/providers/zerops",
"title": "Zerops",
"order": null
}
]
}
]
},
{
"slug": "config",
"path": "/config",
"title": "Config",
"order": 3,
"children": [
{
"slug": "",
"path": "/config",
"title": "Config",
"order": 0,
"icon": "ri:settings-3-line"
}
]
},
{
"slug": "examples",
"path": "/examples",
"title": "Examples",
"order": 4,
"children": [
{
"slug": "",
"path": "/examples",
"title": "Examples",
"order": 0,
"icon": "i-lucide-folder-code"
},
{
"slug": "api-routes",
"path": "/examples/api-routes",
"title": "API Routes",
"order": null,
"icon": "i-lucide-route",
"category": "features"
},
{
"slug": "auto-imports",
"path": "/examples/auto-imports",
"title": "Auto Imports",
"order": null,
"icon": "i-lucide-import",
"category": "config"
},
{
"slug": "cached-handler",
"path": "/examples/cached-handler",
"title": "Cached Handler",
"order": null,
"icon": "i-lucide-clock",
"category": "features"
},
{
"slug": "custom-error-handler",
"path": "/examples/custom-error-handler",
"title": "Custom Error Handler",
"order": null,
"icon": "i-lucide-alert-circle",
"category": "features"
},
{
"slug": "database",
"path": "/examples/database",
"title": "Database",
"order": null,
"icon": "i-lucide-database",
"category": "features"
},
{
"slug": "elysia",
"path": "/examples/elysia",
"title": "Elysia",
"order": null,
"icon": "i-skill-icons-elysia-dark",
"category": "backend frameworks"
},
{
"slug": "express",
"path": "/examples/express",
"title": "Express",
"order": null,
"icon": "i-simple-icons-express",
"category": "backend frameworks"
},
{
"slug": "fastify",
"path": "/examples/fastify",
"title": "Fastify",
"order": null,
"icon": "i-simple-icons-fastify",
"category": "backend frameworks"
},
{
"slug": "hello-world",
"path": "/examples/hello-world",
"title": "Hello World",
"order": null,
"icon": "i-lucide-sparkles",
"category": "features"
},
{
"slug": "hono",
"path": "/examples/hono",
"title": "Hono",
"order": null,
"icon": "i-logos-hono",
"category": "backend frameworks"
},
{
"slug": "import-alias",
"path": "/examples/import-alias",
"title": "Import Alias",
"order": null,
"icon": "i-lucide-at-sign",
"category": "config"
},
{
"slug": "middleware",
"path": "/examples/middleware",
"title": "Middleware",
"order": null,
"icon": "i-lucide-layers",
"category": "features"
},
{
"slug": "mono-jsx",
"path": "/examples/mono-jsx",
"title": "Mono JSX",
"order": null,
"icon": "i-lucide-brackets",
"category": "server side rendering"
},
{
"slug": "nano-jsx",
"path": "/examples/nano-jsx",
"title": "Nano JSX",
"order": null,
"icon": "i-lucide-brackets",
"category": "server side rendering"
},
{
"slug": "plugins",
"path": "/examples/plugins",
"title": "Plugins",
"order": null,
"icon": "i-lucide-plug",
"category": "features"
},
{
"slug": "renderer",
"path": "/examples/renderer",
"title": "Custom Renderer",
"order": null,
"icon": "i-lucide-code",
"category": "server side rendering"
},
{
"slug": "runtime-config",
"path": "/examples/runtime-config",
"title": "Runtime Config",
"order": null,
"icon": "i-lucide-settings",
"category": "config"
},
{
"slug": "server-fetch",
"path": "/examples/server-fetch",
"title": "Server Fetch",
"order": null,
"icon": "i-lucide-arrow-right-left",
"category": "features"
},
{
"slug": "shiki",
"path": "/examples/shiki",
"title": "Shiki",
"order": null,
"icon": "i-lucide-highlighter",
"category": "integrations"
},
{
"slug": "virtual-routes",
"path": "/examples/virtual-routes",
"title": "Virtual Routes",
"order": null,
"icon": "i-lucide-box",
"category": "features"
},
{
"slug": "vite-nitro-plugin",
"path": "/examples/vite-nitro-plugin",
"title": "Vite Nitro Plugin",
"order": null,
"icon": "i-logos-vitejs",
"category": "vite"
},
{
"slug": "vite-rsc",
"path": "/examples/vite-rsc",
"title": "Vite RSC",
"order": null,
"icon": "i-logos-react",
"category": "vite"
},
{
"slug": "vite-ssr-html",
"path": "/examples/vite-ssr-html",
"title": "Vite SSR HTML",
"order": null,
"icon": "i-logos-html-5",
"category": "server side rendering"
},
{
"slug": "vite-ssr-preact",
"path": "/examples/vite-ssr-preact",
"title": "SSR with Preact",
"order": null,
"icon": "i-logos-preact",
"category": "server side rendering"
},
{
"slug": "vite-ssr-react",
"path": "/examples/vite-ssr-react",
"title": "SSR with React",
"order": null,
"icon": "i-logos-react",
"category": "server side rendering"
},
{
"slug": "vite-ssr-solid",
"path": "/examples/vite-ssr-solid",
"title": "SSR with SolidJS",
"order": null,
"icon": "i-logos-solidjs-icon",
"category": "server side rendering"
},
{
"slug": "vite-ssr-tsr-react",
"path": "/examples/vite-ssr-tsr-react",
"title": "SSR with TanStack Router",
"order": null,
"icon": "i-simple-icons-tanstack",
"category": "server side rendering"
},
{
"slug": "vite-ssr-tss-react",
"path": "/examples/vite-ssr-tss-react",
"title": "SSR with TanStack Start",
"order": null,
"icon": "i-simple-icons-tanstack",
"category": "server side rendering"
},
{
"slug": "vite-ssr-vue-router",
"path": "/examples/vite-ssr-vue-router",
"title": "SSR with Vue Router",
"order": null,
"icon": "i-logos-vue",
"category": "server side rendering"
},
{
"slug": "vite-trpc",
"path": "/examples/vite-trpc",
"title": "Vite + tRPC",
"order": null,
"icon": "i-simple-icons-trpc",
"category": "vite"
},
{
"slug": "websocket",
"path": "/examples/websocket",
"title": "WebSocket",
"order": null,
"icon": "i-lucide-radio",
"category": "features"
}
]
},
{
"slug": "",
"path": "/",
"title": "Index",
"order": null,
"seo": {
"title": "Build Full-Stack Servers",
"description": "Nitro extends your Vite application with a production-ready server, compatible with any runtime. Add server routes to your application and deploy many hosting platform with a zero-config experience."
},
"orientation": "horizontal",
"filename": "nitro.config.ts",
"features": [
{
"title": "Fast",
"description": "Enjoy the fast Vite 8 (rolldown powered) development experience with HMR on the server and optimized for production.",
"icon": "i-lucide-zap",
"color": "text-amber-500",
"bgColor": "bg-amber-500/10",
"borderColor": "group-hover:border-amber-500/30"
},
{
"title": "Agnostic",
"description": "Deploy the same codebase to any deployment provider with zero config and locked-in.",
"icon": "i-lucide-globe",
"color": "text-sky-500",
"bgColor": "bg-sky-500/10",
"borderColor": "group-hover:border-sky-500/30"
},
{
"title": "Minimal",
"description": "Nitro adds no overhead to runtime. Build your servers with any modern tool you like.",
"icon": "i-lucide-feather",
"color": "text-emerald-500",
"bgColor": "bg-emerald-500/10",
"borderColor": "group-hover:border-emerald-500/30"
}
],
"metrics": [
{
"label": "Bare metal perf",
"value": "~Native",
"unit": "RPS",
"description": "Using compile router, and fast paths for request handling.",
"icon": "i-lucide-gauge",
"color": "text-emerald-500",
"bgColor": "bg-emerald-500/10",
"barWidth": "95%",
"barColor": "bg-emerald-500"
},
{
"label": "Minimum install Size",
"value": "Tiny",
"unit": "deps",
"description": "Minimal dependencies. No bloated node_modules.",
"icon": "i-lucide-package",
"color": "text-sky-500",
"bgColor": "bg-sky-500/10",
"barWidth": "15%",
"barColor": "bg-sky-500"
},
{
"label": "Small and portable output",
"value": " 10",
"unit": "kB",
"description": "Standard server builds produce ultra-small output bundles.",
"icon": "i-lucide-file-output",
"color": "text-violet-500",
"bgColor": "bg-violet-500/10",
"barWidth": "10%",
"barColor": "bg-violet-500"
},
{
"label": "FAST builds",
"value": " 1",
"unit": "sec",
"description": "Cold production builds complete in seconds, not minutes.",
"icon": "i-lucide-timer",
"color": "text-amber-500",
"bgColor": "bg-amber-500/10",
"barWidth": "12%",
"barColor": "bg-amber-500"
}
],
"headline": "Assets",
"link": "/docs/assets",
"link-label": "Assets docs"
}
]
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
# Deploy
> Learn more about Nitro deploy providers.
Nitro can generate different output formats suitable for different hosting providers from the same code base.
Using built-in presets, you can easily configure Nitro to adjust its output format with almost no additional code or configuration!
## Default output
The default production output preset is [Node.js server](/deploy/runtimes/node).
When running Nitro in development mode, Nitro will always use a special preset called `nitro-dev` using Node.js with ESM in an isolated Worker environment with behavior as close as possible to the production environment.
## Zero-Config Providers
When deploying to production using CI/CD, Nitro tries to automatically detect the provider environment and set the right one without any additional configuration required. Currently, the providers below can be auto-detected with zero config.
- [aws amplify](/deploy/providers/aws-amplify)
- [azure](/deploy/providers/azure)
- [cloudflare](/deploy/providers/cloudflare)
- [firebase app hosting](/deploy/providers/firebase#firebase-app-hosting)
- [netlify](/deploy/providers/netlify)
- [stormkit](/deploy/providers/stormkit)
- [vercel](/deploy/providers/vercel)
- [zeabur](/deploy/providers/zeabur)
<warning>
For Turborepo users, zero config detection will be interferenced by its Strict Environment Mode. You may need to allowing the variables explictly or use its Loose Environment Mode (with `--env-mode=loose` flag).
</warning>
Other built-in providers are available with an explicit preset, including [zephyr](/deploy/providers/zephyr).
## Changing the deployment preset
If you need to build Nitro against a specific provider, you can target it by defining an environment variable named `NITRO_PRESET` or `SERVER_PRESET`, or by updating your Nitro [configuration](/docs/configuration) or using `--preset` argument.
Using the environment variable approach is recommended for deployments depending on CI/CD.
**Example:** Defining a `NITRO_PRESET` environment variable
```bash
nitro build --preset cloudflare_pages
```
**Example:** Updating the `nitro.config.ts` file
```ts
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
preset: 'cloudflare_pages'
})
```
## Compatibility date
Deployment providers regularly update their runtime behavior. Nitro presets are updated to support these new features.
To prevent breaking existing deployments, Nitro uses compatibility dates. These dates let you lock in behavior at the project creation time. You can also opt in to future updates when ready.
When you create a new project, the `compatibilityDate` is set to the current date. This setting is saved in your project's configuration.
You should update the compatibility date periodically. Always test your deployment thoroughly after updating. Below is a list of key dates and their effects.
@@ -0,0 +1,50 @@
# Alwaysdata
> Deploy Nitro apps to alwaysdata.
**Preset:** `alwaysdata`
<read-more></read-more>
## Set up application
### Pre-requisites
1. [Register a new profile](https://www.alwaysdata.com/en/register/) on alwaysdata platform if you don't have one.
2. Get a free 100Mb plan to host your app.
> [!NOTE]
> Keep in mind your *account name* will be used to provide you a default URL in the form of `account_name.alwaysdata.net`, so choose it wisely. You can also link your existing domains to your account later or register as many accounts under your profile as you need.
### Local deployment
1. Build your project locally with `npm run build -- preset alwaysdata`
2. [Upload your app](https://help.alwaysdata.com/en/remote-access/) to your account in its own directory (e.g. `$HOME/www/my-app`). You can use any protocol you prefer (SSH/FTP/WebDAV…) to do so.
3. On your admin panel, [create a new site](https://admin.alwaysdata.com/site/add/) for your app with the following features:
- *Addresses*: `[account_name].alwaysdata.net`
- *Type*: Node.js
- *Command*: `node .output/server/index.mjs`
- *Working directory*: `www/my-app` (adapt it to your deployment path)
- *Environment*:
```ini
NITRO_PRESET=alwaysdata
```
- *Node.js version*: `Default version` is fine; pick no less than `20.0.0` (you can also [set your Node.js version globally](https://help.alwaysdata.com/en/languages/nodejs/configuration/#supported-versions))
- *Hot restart*: `SIGHUP`
<read-more></read-more>
- Your app is now live at `http(s)://[account_name].alwaysdata.net`.
@@ -0,0 +1,81 @@
# AWS Amplify
> Deploy Nitro apps to AWS Amplify Hosting.
**Preset:** `aws_amplify`
<read-more></read-more>
## Deploy to AWS Amplify Hosting
<tip>
Integration with this provider is possible with [zero configuration](/deploy/#zero-config-providers).
</tip>
1. Login to the [AWS Amplify Hosting Console](https://console.aws.amazon.com/amplify/)
2. Click on "Get Started" > Amplify Hosting (Host your web app)
3. Select and authorize access to your Git repository provider and select the main branch
4. Choose a name for your app, make sure build settings are auto-detected and optionally set requirement environment variables under the advanced section
5. Optionally, select Enable SSR logging to enable server-side logging to your Amazon CloudWatch account
6. Confirm configuration and click on "Save and Deploy"
## Advanced Configuration
You can configure advanced options of this preset using `awsAmplify` option.
```ts [nitro.config.ts]
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
awsAmplify: {
// catchAllStaticFallback: true,
// imageOptimization: { path: "/_image", cacheControl: "public, max-age=3600, immutable" },
// imageSettings: { ... },
// runtime: "nodejs18.x", // default: "nodejs18.x" | "nodejs16.x" | "nodejs20.x"
}
})
```
### `amplify.yml`
You might need a custom `amplify.yml` file for advanced configuration. Here are two template examples:
<code-group>
```yml [amplify.yml]
version: 1
frontend:
phases:
preBuild:
commands:
- nvm use 18 && node --version
- corepack enable && npx --yes nypm install
build:
commands:
- pnpm build
artifacts:
baseDirectory: .amplify-hosting
files:
- "**/*"
```
```yml [amplify.yml (monorepo)]
version: 1
applications:
- frontend:
phases:
preBuild:
commands:
- nvm use 18 && node --version
- corepack enable && npx --yes nypm install
build:
commands:
- pnpm --filter website1 build
artifacts:
baseDirectory: apps/website1/.amplify-hosting
files:
- '**/*'
buildPath: /
appRoot: apps/website1
```
</code-group>
@@ -0,0 +1,47 @@
# AWS Lambda
> Deploy Nitro apps to AWS Lambda.
**Preset:** `aws_lambda`
<read-more></read-more>
Nitro provides a built-in preset to generate output format compatible with [AWS Lambda](https://aws.amazon.com/lambda/).
The output entrypoint in `.output/server/index.mjs` is compatible with [AWS Lambda format](https://docs.aws.amazon.com/lex/latest/dg/lambda-input-response-format.html).
It can be used programmatically or as part of a deployment.
```ts
import { handler } from './.output/server'
// Use programmatically
const { statusCode, headers, body } = handler({ rawPath: '/' })
```
## Inlining chunks
Nitro output, by default uses dynamic chunks for lazy loading code only when needed. However this sometimes can not be ideal for performance. (See discussions in [nitrojs/nitro#650](https://github.com/nitrojs/nitro/pull/650)). You can enabling chunk inlining behavior using [`inlineDynamicImports`](/config#inlinedynamicimports) config.
```ts [nitro.config.ts]
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
inlineDynamicImports: true
});
```
## Response streaming
<read-more></read-more>
In order to enable response streaming, enable `awsLambda.streaming` flag:
```ts [nitro.config.ts]
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
awsLambda: {
streaming: true
}
});
```
@@ -0,0 +1,72 @@
# Azure
> Deploy Nitro apps to Azure Static Web apps or functions.
## Azure static web apps
**Preset:** `azure-swa`
<read-more></read-more>
<note>
Integration with this provider is possible with [zero configuration](/deploy/#zero-config-providers).
</note>
[Azure Static Web Apps](https://azure.microsoft.com/en-us/products/app-service/static) are designed to be deployed continuously in a [GitHub Actions workflow](https://docs.microsoft.com/en-us/azure/static-web-apps/github-actions-workflow). By default, Nitro will detect this deployment environment and enable the `azure` preset.
### Local preview
Install [Azure Functions Core Tools](https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local) if you want to test locally.
You can invoke a development environment to preview before deploying.
```bash
NITRO_PRESET=azure npx nypm@latest build
npx @azure/static-web-apps-cli start .output/public --api-location .output/server
```
### Configuration
Azure Static Web Apps are [configured](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration) using the `staticwebapp.config.json` file.
Nitro automatically generates this configuration file whenever the application is built with the `azure` preset.
Nitro will automatically add the following properties based on the following criteria:
| Property | Criteria | Default |
| --- | --- | --- |
| **[platform.apiRuntime](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#platform)** | Will automatically set to `node:16` or `node:14` depending on your package configuration. | `node:16` |
| **[navigationFallback.rewrite](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#fallback-routes)** | Is always `/api/server` | `/api/server` |
| **[routes](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#routes)** | All prerendered routes are added. Additionally, if you do not have an `index.html` file an empty one is created for you for compatibility purposes and also requests to `/index.html` are redirected to the root directory which is handled by `/api/server`. | `[]` |
### Custom configuration
You can alter the Nitro generated configuration using `azure.config` option.
Custom routes will be added and matched first. In the case of a conflict (determined if an object has the same route property), custom routes will override generated ones.
### Deploy from CI/CD via GitHub actions
When you link your GitHub repository to Azure Static Web Apps, a workflow file is added to the repository.
When you are asked to select your framework, select custom and provide the following information:
| Input | Value |
| --- | --- |
| **app_location** | '/' |
| **api_location** | '.output/server' |
| **output_location** | '.output/public' |
If you miss this step, you can always find the build configuration section in your workflow and update the build configuration:
```yaml [.github/workflows/azure-static-web-apps-<RANDOM_NAME>.yml]
###### Repository/Build Configurations ######
app_location: '/'
api_location: '.output/server'
output_location: '.output/public'
###### End of Repository/Build Configurations ######
```
That's it! Now Azure Static Web Apps will automatically deploy your Nitro-powered application on push.
If you are using runtimeConfig, you will likely want to configure the corresponding [environment variables on Azure](https://docs.microsoft.com/en-us/azure/static-web-apps/application-settings).
@@ -0,0 +1,33 @@
# Cleavr
> Deploy Nitro apps to Cleavr.
**Preset:** `cleavr`
<read-more></read-more>
<note>
Integration with this provider is possible with [zero configuration](/deploy/#zero-config-providers).
</note>
## Set up your web app
In your project, set Nitro preset to `cleavr`.
```js
export default {
nitro: {
preset: 'cleavr'
}
}
```
Push changes to your code repository.
**In your Cleavr panel:**
1. Provision a new server
2. Add a website, selecting **Nuxt 3** as the app type
3. In web app > settings > Code Repo, point to your project's code repository
You're now all set to deploy your project!
@@ -0,0 +1,375 @@
# Cloudflare
> Deploy Nitro apps to Cloudflare.
## Cloudflare Workers
**Preset:** `cloudflare_module`
<read-more></read-more>
<note>
Integration with this provider is possible with [zero configuration](/deploy#zero-config-providers) supporting [workers builds (beta)](https://developers.cloudflare.com/workers/ci-cd/builds/).
</note>
<important>
To use Workers with Static Assets, you need a Nitro compatibility date set to `2024-09-19` or later.
</important>
The following shows an example `nitro.config.ts` file for deploying a Nitro app to Cloudflare Workers.
```ts [nitro.config.ts]
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
compatibilityDate: "2024-09-19",
preset: "cloudflare_module",
cloudflare: {
deployConfig: true,
nodeCompat: true
}
})
```
By setting `deployConfig: true`, Nitro will automatically generate a `wrangler.json` for you with the correct configuration.
If you need to add [Cloudflare Workers configuration](https://developers.cloudflare.com/workers/wrangler/configuration/), such as [bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/), you can either:
- Set these in your Nitro config under the `cloudflare: { wrangler : {} }`. This has the same type as `wrangler.json`.
- Provide your own `wrangler.json`. Nitro will merge your config with the appropriate settings, including pointing to the build output.
### Local Preview
You can use [Wrangler](https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler) to preview your app locally:
<pm-run></pm-run>
<pm-x></pm-x>
### Manual Deploy
After having built your application you can manually deploy it with Wrangler.
First make sure to be logged into your Cloudflare account:
<pm-x></pm-x>
Then you can deploy the application with:
<pm-x></pm-x>
### Runtime Hooks
You can use [runtime hooks](/docs/plugins#nitro-runtime-hooks) below in order to extend [Worker handlers](https://developers.cloudflare.com/workers/runtime-apis/handlers/).
<read-more></read-more>
- [`cloudflare:scheduled`](https://developers.cloudflare.com/workers/runtime-apis/handlers/scheduled/)
- [`cloudflare:email`](https://developers.cloudflare.com/email-routing/email-workers/runtime-api/)
- [`cloudflare:queue`](https://developers.cloudflare.com/queues/configuration/javascript-apis/#consumer)
- [`cloudflare:tail`](https://developers.cloudflare.com/workers/runtime-apis/handlers/tail/)
- `cloudflare:trace`
### Additional Exports
You can add a `exports.cloudflare.ts` file to your project root to export additional handlers or properties to the Cloudflare Worker entrypoint.
```ts [exports.cloudflare.ts]
export class MyWorkflow extends WorkflowEntrypoint {
async run(event: WorkflowEvent, step: WorkflowStep) {
// ...
}
}
```
Nitro will automatically detect this file and include its exports in the final build.
<warning>
The `exports.cloudflare.ts` file must not have a default export.
</warning>
You can also customize the entrypoint file location using the `cloudflare.exports` option in your `nitro.config.ts`:
```ts [nitro.config.ts]
export default defineConfig({
cloudflare: {
exports: "custom-exports-entry.ts"
}
})
```
### Scheduled Tasks (Cron Triggers)
When using [Nitro tasks](/docs/tasks) with `scheduledTasks`, Nitro automatically generates [Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/) in the wrangler config at build time.
```ts [nitro.config.ts]
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
preset: "cloudflare_module",
experimental: {
tasks: true,
},
scheduledTasks: {
"* * * * *": ["cms:update"],
"0 15 1 * *": ["db:cleanup"],
},
cloudflare: {
deployConfig: true,
},
})
```
No manual Wrangler configuration is needed - Nitro handles it for you.
## Cloudflare Pages
**Preset:** `cloudflare_pages`
<read-more></read-more>
<note>
Integration with this provider is possible with [zero configuration](/deploy#zero-config-providers).
</note>
<warning>
Cloudflare [Workers Module](#cloudflare-workers) is the new recommended preset for deployments. Please consider using the pages only if you need specific features.
</warning>
The following shows an example `nitro.config.ts` file for deploying a Nitro app to Cloudflare Pages.
```ts [nitro.config.ts]
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
preset: "cloudflare_pages",
cloudflare: {
deployConfig: true,
nodeCompat:true
}
})
```
Nitro automatically generates a `_routes.json` file that controls which routes get served from files and which are served from the Worker script. The auto-generated routes file can be overridden with the config option `cloudflare.pages.routes` ([read more](https://developers.cloudflare.com/pages/platform/functions/routing/#functions-invocation-routes)).
### Local Preview
You can use [Wrangler](https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler) to preview your app locally:
<pm-run></pm-run>
<pm-x></pm-x>
### Manual Deploy
After having built your application you can manually deploy it with Wrangler, in order to do so first make sure to be
logged into your Cloudflare account:
<pm-x></pm-x>
Then you can deploy the application with:
<pm-x></pm-x>
## Deploy within CI/CD using GitHub Actions
Regardless on whether you're using Cloudflare Pages or Cloudflare Workers, you can use the [Wrangler GitHub actions](https://github.com/marketplace/actions/deploy-to-cloudflare-workers-with-wrangler) to deploy your application.
<note>
**Note:** Remember to [instruct Nitro to use the correct preset](/deploy#changing-the-deployment-preset) (note that this is necessary for all presets including the `cloudflare_pages` one).
</note>
## Environment Variables
Nitro allows you to universally access environment variables using `process.env` or `import.meta.env` or the runtime config.
<note>
Make sure to only access environment variables **within the event lifecycle** and not in global contexts since Cloudflare only makes them available during the request lifecycle and not before.
</note>
**Example:** If you have set the `SECRET` and `NITRO_HELLO_THERE` environment variables set you can access them in the following way:
```ts
import { defineHandler } from "nitro";
import { useRuntimeConfig } from "nitro/runtime-config";
console.log(process.env.SECRET) // note that this is in the global scope! so it doesn't actually work and the variable is undefined!
export default defineHandler((event) => {
// note that all the below are valid ways of accessing the above mentioned variables
useRuntimeConfig().helloThere
useRuntimeConfig().secret
process.env.NITRO_HELLO_THERE
import.meta.env.SECRET
});
```
### Specify Variables in Development Mode
For development, you can use a `.env` or `.env.local` file to specify environment variables:
```ini
NITRO_HELLO_THERE="captain"
SECRET="top-secret"
```
<note>
**Note:** Make sure you add `.env` and `.env.local` to the `.gitignore` file so that you don't commit it as it can contain sensitive information.
</note>
### Specify Variables for local previews
After build, when you try out your project locally with `wrangler dev` or `wrangler pages dev`, in order to have access to environment variables you will need to specify the in a `.dev.vars` file in the root of your project (as presented in the [Pages](https://developers.cloudflare.com/pages/functions/bindings/#interact-with-your-environment-variables-locally) and [Workers](https://developers.cloudflare.com/workers/configuration/environment-variables/#interact-with-environment-variables-locally) documentation).
If you are using a `.env` or `.env.local` file while developing, your `.dev.vars` should be identical to it.
<note>
**Note:** Make sure you add `.dev.vars` to the `.gitignore` file so that you don't commit it as it can contain sensitive information.
</note>
### Specify Variables for Production
For production, use the Cloudflare dashboard or the [`wrangler secret`](https://developers.cloudflare.com/workers/wrangler/commands/#secret) command to set environment variables and secrets.
### Specify Variables using `wrangler.toml`/`wrangler.json`
You can specify a custom `wrangler.toml`/`wrangler.json` file and define vars inside.
<warning>
Note that this isn't recommend for sensitive data like secrets.
</warning>
**Example:**
<code-group>
```ini [wrangler.toml]
# Shared
[vars]
NITRO_HELLO_THERE="general"
SECRET="secret"
# Override values for `--env production` usage
[env.production.vars]
NITRO_HELLO_THERE="captain"
SECRET="top-secret"
```
```json [wrangler.json]
{
"vars": {
"NITRO_HELLO_THERE": "general",
"SECRET": "secret"
},
"env": {
"production": {
"vars": {
"NITRO_HELLO_THERE": "captain",
"SECRET": "top-secret"
}
}
}
}
```
</code-group>
## Direct access to Cloudflare bindings
Bindings are what allows you to interact with resources from the Cloudflare platform, examples of such resources are key-value data storages ([KVs](https://developers.cloudflare.com/kv/)) and serverless SQL databases ([D1s](https://developers.cloudflare.com/d1/)).
<read-more>
For more details on Bindings and how to use them please refer to the Cloudflare [Pages](https://developers.cloudflare.com/pages/functions/bindings/) and [Workers](https://developers.cloudflare.com/workers/configuration/bindings/#bindings) documentation.
</read-more>
> [!TIP]
> Nitro provides high level API to interact with primitives such as [KV Storage](/docs/storage) and [Database](/docs/database) and you are highly recommended to prefer using them instead of directly depending on low-level APIs for usage stability.
<read-more></read-more>
<read-more></read-more>
In runtime, you can access bindings from the request event via `event.req.runtime.cloudflare.env`. This is for example how you can access a D1 binding:
<warning>
**Nitro v3 Breaking Change:** The `event.context.cloudflare.env` pattern from Nitro v2 no longer works in production. Use `event.req.runtime.cloudflare.env` instead. The old pattern may still appear to work in local dev (via the Wrangler proxy plugin) but will be `undefined` in production deployments.
</warning>
```ts
import { defineHandler } from "nitro";
defineHandler(async (event) => {
// Nitro v3: access Cloudflare bindings via event.req.runtime.cloudflare.env
const { env } = event.req.runtime.cloudflare
const stmt = await env.MY_D1.prepare('SELECT id FROM table')
const { results } = await stmt.all()
})
```
### Access to the bindings in local dev
To access bindings in dev mode, we first define them. You can do this in a `wrangler.jsonc`/`wrangler.json`/`wrangler.toml` file
For example, to define a variable and a KV namespace in `wrangler.toml`:
<code-group>
```ini [wrangler.toml]
[vars]
MY_VARIABLE="my-value"
[[kv_namespaces]]
binding = "MY_KV"
id = "xxx"
```
```json [wrangler.json]
{
"vars": {
"MY_VARIABLE": "my-value",
},
"kv_namespaces": [
{
"binding": "MY_KV",
"id": "xxx"
}
]
}
```
</code-group>
Next we install the required `wrangler` package (if not already installed):
<pm-install></pm-install>
From this moment, when running
<pm-run></pm-run>
you will be able to access the `MY_VARIABLE` and `MY_KV` from the request event just as illustrated above.
#### Wrangler environments
If you have multiple Wrangler environments, you can specify which Wrangler environment to use during Cloudflare dev emulation:
```ts [nitro.config.ts]
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
preset: 'cloudflare_module',
cloudflare: {
dev: {
environment: 'preview'
}
}
})
```
@@ -0,0 +1,66 @@
# Deno Deploy
> Deploy Nitro apps to [Deno Deploy](https://deno.com/deploy).
**Preset:** `deno_deploy`
<read-more></read-more>
## Deploy with the CLI
You can use [deployctl](https://deno.com/deploy/docs/deployctl) to deploy your app.
Login to [Deno Deploy](https://dash.deno.com/account#access-tokens) to obtain a `DENO_DEPLOY_TOKEN` access token, and set it as an environment variable.
```bash
# Build with the deno_deploy NITRO preset
NITRO_PRESET=deno_deploy npm run build
# Make sure to run the deployctl command from the output directory
cd .output
deployctl deploy --project=my-project server/index.ts
```
## Deploy within CI/CD using GitHub actions
You just need to include the deployctl GitHub Action as a step in your workflow.
You do not need to set up any secrets for this to work. You do need to link your GitHub repository to your Deno Deploy project and choose the "GitHub Actions" deployment mode. You can do this in your project settings on [Deno Deploy](https://dash.deno.com).
Create the following workflow file in your `.github/workflows` directory:
```yaml [.github/workflows/deno_deploy.yml]
name: deno-deploy
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
deploy:
steps:
- uses: actions/checkout@v5
- run: corepack enable
- uses: actions/setup-node@v6
with:
node-version: 18
cache: pnpm
- run: pnpm install
- run: pnpm build
env:
NITRO_PRESET: deno_deploy
- name: Deploy to Deno Deploy
uses: denoland/deployctl@v1
with:
project: my-project
entrypoint: server/index.ts
root: .output
```
## Deno runtime
<read-more></read-more>
@@ -0,0 +1,46 @@
# DigitalOcean
> Deploy Nitro apps to DigitalOcean.
**Preset:** `digital_ocean`
<read-more></read-more>
## Set up application
1. Create a new Digital Ocean app following the [guide](https://docs.digitalocean.com/products/app-platform/how-to/create-apps/).
2. Next, you'll need to configure environment variables. In your app settings, ensure the following app-level environment variables are set:
```bash
NITRO_PRESET=digital_ocean
```
[More information](https://docs.digitalocean.com/products/app-platform/how-to/use-environment-variables/).
3. You will need to ensure you set an `engines.node` field in your app's `package.json` to ensure Digital Ocean uses a supported version of Node.js:
```json
{
"engines": {
"node": "20.x"
}
}
```
[See more information](https://docs.digitalocean.com/products/app-platform/languages-frameworks/nodejs/#node-version).
4. You'll also need to add a run command so Digital Ocean knows what command to run after a build. You can do so by adding a start script to your `package.json`:
```json
{
"scripts": {
"start": "node .output/server/index.mjs"
}
}
```
5. Finally, you'll need to add this start script to your Digital Ocean app's run command. Go to `Components > Settings > Commands`, click "Edit", then add `npm run start`
Your app should be live at a Digital Ocean generated URL and you can now follow [the rest of the Digital Ocean deployment guide](https://docs.digitalocean.com/products/app-platform/how-to/manage-deployments/).
@@ -0,0 +1,32 @@
# Firebase
> Deploy Nitro apps to Firebase.
<note>
You will need to be on the [**Blaze plan**](https://firebase.google.com/pricing) (Pay as you go) to get started.
</note>
## Firebase app hosting
Preset: `firebase_app_hosting`
<read-more></read-more>
<tip>
You can integrate with this provider using [zero configuration](/deploy/#zero-config-providers).
</tip>
### Project setup
1. Go to the Firebase [console](https://console.firebase.google.com/) and set up a new project.
2. Select **Build > App Hosting** from the sidebar. - You may need to upgrade your billing plan at this step.
- Click **Get Started**. - Choose a region.
- Import a GitHub repository (youll need to link your GitHub account).
- Configure deployment settings (project root directory and branch), and enable automatic rollouts.
- Choose a unique ID for your backend.
- Click Finish & Deploy to create your first rollout.
When you deploy with Firebase App Hosting, the App Hosting preset will be run automatically at build time.
@@ -0,0 +1,67 @@
# Flightcontrol
> Deploy Nitro apps to AWS via Flightcontrol.
**Preset:** `flightcontrol`
<read-more></read-more>
## Set Up your flightcontrol account
On a high level, the steps you will need to follow to deploy a project for the first time are:
1. Create an account at [Flightcontrol](https://app.flightcontrol.dev/signup?ref=nitro)
2. Create an account at [AWS](https://portal.aws.amazon.com/billing/signup) (if you don't already have one)
3. Link your AWS account to the Flightcontrol
4. Authorize the Flightcontrol GitHub App to access your chosen repositories, public or private.
5. Create a Flightcontrol project with configuration via the Dashboard or with configuration via `flightcontrol.json`.
### Create a project with configuration via the dashboard
1. Create a Flightcontrol project from the Dashboard. Select a repository for the source.
2. Select the `GUI` config type.
3. Select the Nuxt preset. This preset will also work for any Nitro-based applications.
4. Select your preferred AWS server size.
5. Submit the new project form.
### Create a project with configuration via `flightcontrol.json`
1. Create a Flightcontrol project from your dashboard. Select a repository for the source.
2. Select the `flightcontrol.json` config type.
3. Add a new file at the root of your repository called `flightcontrol.json`. Here is an example configuration that creates an AWS fargate service for your app:
```json [flightcontrol.json]
{
"$schema": "https://app.flightcontrol.dev/schema.json",
"environments": [
{
"id": "production",
"name": "Production",
"region": "us-west-2",
"source": {
"branch": "main"
},
"services": [
{
"id": "nitro",
"buildType": "nixpacks",
"name": "My Nitro site",
"type": "fargate",
"domain": "www.yourdomain.com",
"outputDirectory": ".output",
"startCommand": "node .output/server/index.mjs",
"cpu": 0.25,
"memory": 0.5
}
]
}
]
}
```
4. Submit the new project form.
<read-more>
Learn more about Flightcontrol's [configuration](https://www.flightcontrol.dev/docs?ref=nitro).
</read-more>
@@ -0,0 +1,67 @@
# Genezio
> Deploy Nitro apps to Genezio.
**Preset:** `genezio`
<read-more></read-more>
> [!IMPORTANT]
> 🚧 This preset is currently experimental.
## 1. Project Setup
Create `genezio.yaml` file:
```yaml
# The name of the project.
name: nitro-app
# The version of the Genezio YAML configuration to parse.
yamlVersion: 2
backend:
# The root directory of the backend.
path: .output/
# Information about the backend's programming language.
language:
# The name of the programming language.
name: js
# The package manager used by the backend.
packageManager: npm
# Information about the backend's functions.
functions:
# The name (label) of the function.
- name: nitroServer
# The path to the function's code.
path: server/
# The name of the function handler
handler: handler
# The entry point for the function.
entry: index.mjs
```
<read-more>
To further customize the file to your needs, you can consult the
[official documentation](https://genezio.com/docs/project-structure/genezio-configuration-file/).
</read-more>
## 2. Deploy your project
Build with the genezio nitro preset:
```bash
NITRO_PRESET=genezio npm run build
```
Deploy with [`genezio`](https://npmjs.com/package/genezio) cli:
<pm-x></pm-x>
<read-more>
To set environment viarables, please check out [Genezio - Environment Variables](https://genezio.com/docs/project-structure/backend-environment-variables).
</read-more>
## 3. Monitor your project
You can monitor and manage your application through the [Genezio App Dashboard](https://app.genez.io/dashboard). The dashboard URL, also provided after deployment, allows you to access comprehensive views of your project's status and logs.
@@ -0,0 +1,68 @@
# GitHub Pages
> Deploy Nitro apps to GitHub Pages.
**Preset:** `github_pages`
<read-more></read-more>
## Setup
Follow the steps to [create a GitHub Pages site](https://docs.github.com/en/pages/getting-started-with-github-pages/creating-a-github-pages-site).
## Deployment
Here is an example GitHub Actions workflow to deploy your site to GitHub Pages using the `github_pages` preset:
```yaml [.github/workflows/deploy.yml]
# https://github.com/actions/deploy-pages#usage
name: Deploy to GitHub Pages
on:
workflow_dispatch:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: corepack enable
- uses: actions/setup-node@v6
with:
node-version: "18"
- run: npx nypm install
- run: npm run build
env:
NITRO_PRESET: github_pages
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
with:
path: ./.output/public
# Deployment job
deploy:
# Add a dependency to the build job
needs: build
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
permissions:
pages: write # to deploy to Pages
id-token: write # to verify the deployment originates from an appropriate source
# Deploy to the github_pages environment
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
# Specify runner + deployment step
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1
```
@@ -0,0 +1,37 @@
# GitLab Pages
> Deploy Nitro apps to GitLab Pages.
**Preset:** `gitlab_pages`
<read-more></read-more>
## Setup
Follow the steps to [create a GitLab Pages site](https://docs.gitlab.com/ee/user/project/pages/#getting-started).
## Deployment
1. Here is an example GitLab Pages workflow to deploy your site to GitLab Pages:
```yaml [.gitlab-ci.yml]
image: node:lts
before_script:
- npx nypm install
pages:
cache:
paths:
- node_modules/
variables:
NITRO_PRESET: gitlab_pages
script:
- npm run build
artifacts:
paths:
- .output/public
publish: .output/public
rules:
# This ensures that only pushes to the default branch
# will trigger a pages deploy
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
```
@@ -0,0 +1,84 @@
# Heroku
> Deploy Nitro apps to Heroku.
**Preset:** `heroku`
<read-more></read-more>
## Using the heroku CLI
1. Create a new Heroku app.
```bash
heroku create myapp
```
2. Configure Heroku to use the nodejs buildpack.
```bash
heroku buildpacks:set heroku/nodejs
```
3. Configure your app.
```bash
heroku config:set NITRO_PRESET=heroku
```
4. Ensure you have `start` and `build` commands in your `package.json` file.
```json5
"scripts": {
"build": "nitro build", // or `nuxt build` if using nuxt
"start": "node .output/server/index.mjs"
}
```
## With nginx
1. Add the heroku Nginx buildpack [here](https://github.com/heroku/heroku-buildpack-nginx.git)
2. Change to the 'node' preset in your `nitro.config`
```json5
"nitro": {
"preset":"node",
}
```
3. From the **Existing app** section of buildpack doc, 2 key steps are required to get things running
Step 1: Listen on a socket at 'tmp/nginx.socket'
Step 2: Create a file '/tmp/app-initialized' when your app is ready to accept connections
4. Create custom app runner, eg: apprunner.mjs at the root of the project (or any other preferred location), in this file, create a server, using the listener generated by the node preset, then listen on the socket as detailed in the buildpack doc
```ts
import { createServer } from 'node:http'
import { listener } from './.output/server/index.mjs'
const server = createServer(listener)
server.listen('/tmp/nginx.socket') //following the buildpack doc
```
5. To create the 'tmp/app-initialized' file, use a nitro plugin, create file 'initServer.ts' at the root of the project (or any other preferred location)
```ts
import fs from "fs"
export default definePlugin((nitroApp) => {
if((process.env.NODE_ENV || 'development') != 'development') {
fs.openSync('/tmp/app-initialized', 'w')
}
})
```
6. Finally, create file 'Procfile' at the root of the project, with the Procfile, we tell heroku to start nginx and use the custom apprunner.mjs to start the server
web: bin/start-nginx node apprunner.mjs
7. Bonus: create file 'config/nginx.conf.erb' to customize your nginx config. With the node preset, by default, static files handlers will not be generated, you can use nginx to server static files, just add the right location rule to the server block(s), or, force the node preset to generate handlers for the static files by setting serveStatic to true.
@@ -0,0 +1,38 @@
# IIS
> Deploy Nitro apps to IIS.
## Using [IISnode](https://github.com/Azure/iisnode)
**Preset:** `iis_node`
1. Install the latest LTS version of [Node.js](https://nodejs.org/en/) on your Windows Server.
2. Install [IISnode](https://github.com/azure/iisnode/releases)
3. Install [IIS `URLRewrite` Module](https://www.iis.net/downloads/microsoft/url-rewrite).
4. In IIS, add `.mjs` as a new mime type and set its content type to `application/javascript`.
5. Deploy the contents of your `.output` folder to your website in IIS.
## Using IIS handler
**Preset:** `iis_handler`
You can use IIS http handler directly.
1. Install the latest LTS version of [Node.js](https://nodejs.org/en/) on your Windows Server.
2. Install [IIS `HttpPlatformHandler` Module](https://www.iis.net/downloads/microsoft/httpplatformhandler)
3. Copy your `.output` directory into the Windows Server, and create a website on IIS pointing to that exact directory.
## IIS config options
```ts [nitro.config.ts]
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
// IIS options default
iis: {
// merges in a pre-existing web.config file to the nitro default file
mergeConfig: true,
// overrides the default nitro web.config file all together
overrideConfig: false,
},
});
```
@@ -0,0 +1,109 @@
# Koyeb
> Deploy Nitro apps to Koyeb.
**Preset:** `koyeb`
<read-more></read-more>
## Using the control panel
1. In the [Koyeb control panel](https://app.koyeb.com/), click **Create App**.
2. Choose **GitHub** as your deployment method.
3. Choose the GitHub **repository** and **branch** containing your application code.
4. Name your Service.
5. If you did not add a `start` command to your `package.json` file, under the **Build and deployment settings**, toggle the override switch associated with the run command field. In the **Run command** field, enter:
```bash
node .output/server/index.mjs`
```
6. In the **Advanced** section, click **Add Variable** and add a `NITRO_PRESET` variable set to `koyeb`.
7. Name the App.
8. Click the **Deploy** button.
## Using the Koyeb CLI
1. Follow the instructions targeting your operating system to [install the Koyeb CLI client](https://www.koyeb.com/docs/cli/installation) with an installer. Alternatively, visit the [releases page on GitHub](https://github.com/koyeb/koyeb-cli/releases) to directly download required files.
2. Create a Koyeb API access token by visiting the [API settings for your organization](https://app.koyeb.com/settings/api) in the Koyeb control panel.
3. Log into your account with the Koyeb CLI by typing:
```bash
koyeb login
```
Paste your API credentials when prompted.
4. Deploy your Nitro application from a GitHub repository with the following command. Be sure to substitute your own values for `<APPLICATION_NAME>`, `<YOUR_GITHUB_USERNAME>`, and `<YOUR_REPOSITORY_NAME>`:
```bash
koyeb app init <APPLICATION_NAME> \
--git github.com/<YOUR_GITHUB_USERNAME>/<YOUR_REPOSITORY_NAME> \
--git-branch main \
--git-run-command "node .output/server/index.mjs" \
--ports 3000:http \
--routes /:3000 \
--env PORT=3000 \
--env NITRO_PRESET=koyeb
```
## Using a docker container
1. Create a `.dockerignore` file in the root of your project and add the following lines:
```
Dockerfile
.dockerignore
node_modules
npm-debug.log
.nitro
.output
.git
dist
README.md
```
2. Add a `Dockerfile` to the root of your project:
```
FROM node:18-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build && npm cache clean --force
FROM base AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nitro
COPY --from=builder /app .
USER nitro
EXPOSE 3000
ENV PORT 3000
CMD ["npm", "run", "start"]
```
The Dockerfile above provides the minimum requirements to run the Nitro application. You can easily extend it depending on your needs.
You will then need to push your Docker image to a registry. You can use [Docker Hub](https://hub.docker.com/) or [GitHub Container Registry](https://docs.github.com/en/packages/guides/about-github-container-registry) for example.
In the Koyeb control panel, use the image and the tag field to specify the image you want to deploy.
You can also use the [Koyeb CLI](https://www.koyeb.com/docs/build-and-deploy/cli/installation)
Refer to the Koyeb [Docker documentation](https://www.koyeb.com/docs/build-and-deploy/prebuilt-docker-images) for more information.
@@ -0,0 +1,47 @@
# Netlify
> Deploy Nitro apps to Netlify functions or edge.
**Preset:** `netlify`
<read-more></read-more>
<note>
Integration with this provider is possible with [zero configuration](/deploy/#zero-config-providers).
</note>
Normally, the deployment to Netlify does not require any configuration.
Nitro will auto-detect that you are in a [Netlify](https://www.netlify.com) build environment and build the correct version of your server.
For new sites, Netlify will detect that you are using Nitro and set the publish directory to `dist` and build command to `npm run build`.
If you are upgrading an existing site you should check these and update them if needed.
If you want to add custom redirects, you can do so with [`routeRules`](/config#routerules) or by adding a [`_redirects`](https://docs.netlify.com/routing/redirects/#syntax-for-the-redirects-file) file to your `public` directory.
For deployment, just push to your git repository [as you would normally do for Netlify](https://docs.netlify.com/configure-builds/get-started/).
<note>
Make sure the publish directory is set to `dist` when creating a new project.
</note>
## Netlify edge functions
**Preset:** `netlify_edge`
Netlify Edge Functions use Deno and the powerful V8 JavaScript runtime to let you run globally distributed functions for the fastest possible response times.
<read-more></read-more>
Nitro output can directly run the server at the edge. Closer to your users.
<note>
Make sure the publish directory is set to `dist` when creating a new project.
</note>
## Custom deploy configuration
You can provide additional deploy configuration using the `netlify` key inside `nitro.config`. It will be merged with built-in auto-generated config. Currently the only supported value is `images.remote_images`, for [configuring Netlify Image CDN](https://docs.netlify.com/image-cdn/create-integration/).
@@ -0,0 +1,37 @@
# Platform.sh
> Deploy Nitro apps to platform.sh
**Preset:** `platform_sh`
<read-more></read-more>
## Setup
First, create a new project on platform.sh and link it to the repository you want to auto-deploy with.
Then in repository create `.platform.app.yaml` file:
```yaml [.platform.app.yaml]
name: nitro-app
type: 'nodejs:20'
disk: 128
web:
commands:
start: "node .output/server/index.mjs"
build:
flavor: none
hooks:
build: |
corepack enable
npx nypm install
NITRO_PRESET=platform_sh npm run build
mounts:
'.data':
source: local
source_path: .data
```
<read-more></read-more>
<read-more></read-more>
@@ -0,0 +1,37 @@
# Render.com
> Deploy Nitro apps to Render.com.
**Preset:** `render_com`
<read-more></read-more>
## Set up application
1. [Create a new Web Service](https://dashboard.render.com/select-repo?type=web) and select the repository that contains your code.
2. Ensure the 'Node' environment is selected.
3. Update the start command to `node .output/server/index.mjs`
4. Click 'Advanced' and add an environment variable with `NITRO_PRESET` set to `render_com`. You may also need to add a `NODE_VERSION` environment variable set to `20` for the build to succeed ([docs](https://render.com/docs/node-version)).
5. Click 'Create Web Service'.
## Infrastructure as Code (IaC)
1. Create a file called `render.yaml` with following content at the root of your repository.
This file followed by [Infrastructure as Code](https://render.com/docs/infrastructure-as-code) on Render
```yaml
services:
- type: web
name: <PROJECTNAME>
env: node
branch: main
startCommand: node .output/server/index.mjs
buildCommand: npx nypm install && npm run build
envVars:
- key: NITRO_PRESET
value: render_com
```
1. [Create a new Blueprint Instance](https://dashboard.render.com/select-repo?type=blueprint) and select the repository containing your `render.yaml` file.
You should be good to go!
@@ -0,0 +1,24 @@
# StormKit
> Deploy Nitro apps to StormKit.
**Preset:** `stormkit`
<read-more></read-more>
<note>
Integration with [Stormkit](https://www.stormkit.io/) is possible with [zero configuration](/deploy#zero-config-providers).
</note>
## Setup
Follow the steps to [create a new app](https://app.stormkit.io/apps/new) on Stormkit.
![Create a new app on Stormkit](/images/stormkit-new-app.png)
## Deployment
By default, Stormkit will deploy your apps automatically when you push changes to your main branch. But to trigger a manual deploy (for example, you might do this for the very first deployment), you may click `Deploy now`.
![Trigger a manual deploy with Deploy Now](/images/stormkit-deploy.png)
@@ -0,0 +1,179 @@
# Vercel
> Deploy Nitro apps to Vercel.
**Preset:** `vercel`
<read-more></read-more>
<note>
Integration with this provider is possible with [zero configuration](/deploy/#zero-config-providers).
</note>
## Getting started
Deploying to Vercel comes with the following features:
- [Preview deployments](https://vercel.com/docs/deployments/environments)
- [Fluid compute](https://vercel.com/docs/fluid-compute)
- [Observability](https://vercel.com/docs/observability)
- [Vercel Firewall](https://vercel.com/docs/vercel-firewall)
And much more. Learn more in [the Vercel documentation](https://vercel.com/docs).
### Deploy with Git
Vercel supports Nitro with zero-configuration. [Deploy Nitro to Vercel now](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fvercel%2Ftree%2Fmain%2Fexamples%2Fnitro).
## API routes
Nitro `/api` directory isn't compatible with Vercel. Instead, you should use:
- `routes/api/` for standalone usage
## Bun runtime
<read-more></read-more>
You can use [Bun](https://bun.com) instead of Node.js by specifying the runtime using the `vercel.functions` key inside `nitro.config`:
```ts [nitro.config.ts]
export default defineNitroConfig({
vercel: {
functions: {
runtime: "bun1.x"
}
}
})
```
Alternatively, Nitro also detects Bun automatically if you specify a `bunVersion` property in your `vercel.json`:
```json [vercel.json]
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"bunVersion": "1.x"
}
```
## Proxy route rules
Nitro automatically optimizes `proxy` route rules on Vercel by generating [CDN-level rewrites](https://vercel.com/docs/rewrites) at build time. This means matching requests are proxied at the edge without invoking a serverless function, reducing latency and cost.
```ts [nitro.config.ts]
export default defineNitroConfig({
routeRules: {
// Proxied at CDN level — no function invocation
"/api/**": {
proxy: "https://api.example.com/**",
},
},
});
```
### When CDN rewrites apply
A proxy rule is offloaded to a Vercel CDN rewrite when **all** of the following are true:
- The target is an **external URL** (starts with `http://` or `https://`).
- No advanced `ProxyOptions` are set on the rule.
### Fallback to runtime proxy
When the proxy rule uses any of the following `ProxyOptions`, Nitro keeps it as a runtime proxy handled by the serverless function:
- `headers` — custom headers on the outgoing request to the upstream
- `forwardHeaders` / `filterHeaders` — header filtering
- `fetchOptions` — custom fetch options
- `cookieDomainRewrite` / `cookiePathRewrite` — cookie manipulation
- `onResponse` — response callback
<note>
Response headers defined on the route rule via the `headers` option are still applied to CDN-level rewrites. Only request-level `ProxyOptions.headers` (sent to the upstream) require a runtime proxy.
</note>
## Scheduled tasks (Cron Jobs)
<read-more></read-more>
Nitro automatically converts your [`scheduledTasks`](/docs/tasks#scheduled-tasks) configuration into [Vercel Cron Jobs](https://vercel.com/docs/cron-jobs) at build time. Define your schedules in your Nitro config and deploy - no manual `vercel.json` cron configuration required.
```ts [nitro.config.ts]
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
experimental: {
tasks: true
},
scheduledTasks: {
// Run `cms:update` every hour
'0 * * * *': ['cms:update'],
// Run `db:cleanup` every day at midnight
'0 0 * * *': ['db:cleanup']
}
})
```
### Secure cron job endpoints
<read-more></read-more>
To prevent unauthorized access to the cron handler, set a `CRON_SECRET` environment variable in your Vercel project settings. When `CRON_SECRET` is set, Nitro validates the `Authorization` header on every cron invocation.
## Custom build output configuration
You can provide additional [build output configuration](https://vercel.com/docs/build-output-api/v3) using `vercel.config` key inside `nitro.config`. It will be merged with built-in auto-generated config.
## On-Demand incremental static regeneration (ISR)
On-demand revalidation allows you to purge the cache for an ISR route whenever you want, foregoing the time interval required with background revalidation.
To revalidate a page on demand:
1. Create an Environment Variable which will store a revalidation secret
- You can use the command `openssl rand -base64 32` or [Generate a Secret](https://generate-secret.vercel.app/32) to generate a random value.
- Update your configuration:
```ts [nitro.config.ts]
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
vercel: {
config: {
bypassToken: process.env.VERCEL_BYPASS_TOKEN
}
}
})
```
- To trigger "On-Demand Incremental Static Regeneration (ISR)" and revalidate a path to a Prerender Function, make a GET or HEAD request to that path with a header of x-prerender-revalidate: `bypassToken`. When that Prerender Function endpoint is accessed with this header set, the cache will be revalidated. The next request to that function should return a fresh response.
### Fine-grained ISR config via route rules
By default, query params affect cache keys but are not passed to the route handler unless specified.
You can pass an options object to `isr` route rule to configure caching behavior.
- `expiration`: Expiration time (in seconds) before the cached asset will be re-generated by invoking the Serverless Function. Setting the value to `false` (or `isr: true` route rule) means it will never expire.
- `group`: Group number of the asset. Prerender assets with the same group number will all be re-validated at the same time.
- `allowQuery`: List of query string parameter names that will be cached independently. - If an empty array, query values are not considered for caching.
- If `undefined` each unique query value is cached independently.
- For wildcard `/**` route rules, `url` is always added
- `passQuery`: When `true`, the query string will be present on the `request` argument passed to the invoked function. The `allowQuery` filter still applies.
- `exposeErrBody`: When `true`, expose the response body regardless of status code including error status codes. (default `false`
```ts
export default defineNitroConfig({
routeRules: {
"/products/**": {
isr: {
allowQuery: ["q"],
passQuery: true,
exposeErrBody: true
},
},
},
});
```
@@ -0,0 +1,19 @@
# Zeabur
> Deploy Nitro apps to [Zeabur](https://zeabur.com).
**Preset:** `zeabur`
<read-more></read-more>
<note>
Integration with this provider is possible with [zero configuration](/deploy/#zero-config-providers).
</note>
## Deploy using git
1. Push your code to your git repository (Currently only GitHub supported).
2. [Import your project](https://zeabur.com/docs/get-started) into Zeabur.
3. Zeabur will detect that you are using Nitro and will enable the correct settings for your deployment.
4. Your application is deployed!
@@ -0,0 +1,99 @@
# Zephyr Cloud
> Deploy Nitro apps to [Zephyr Cloud](https://zephyr-cloud.io).
**Preset:** `zephyr`
<read-more></read-more>
Zephyr support is built into Nitro through the `zephyr` preset.
For most Zephyr-specific topics such as BYOC, cloud integrations, environments, and CI/CD authentication, refer to the [Zephyr Cloud docs](https://docs.zephyr-cloud.io).
<note>
Zephyr is a little different from most Nitro deployment providers. Instead of targeting a single hosting vendor directly, Zephyr acts as a deployment control plane on top of either Zephyr-managed infrastructure or your own cloud integrations.
</note>
## BYOC model
Zephyr supports a BYOC (Bring Your Own Cloud) model. In Zephyr's architecture, the control plane stays managed by Zephyr, while the data plane (workers and storage) runs in your cloud accounts.
This lets you keep Zephyr's deployment workflow while using any supported Zephyr cloud integration. See the [Zephyr BYOC docs](https://docs.zephyr-cloud.io/features/byoc) for the current list of supported providers.
## Deploy with Nitro CLI
Use Nitro's deploy command to build and upload your app to Zephyr in one step:
```bash
npx nitro deploy --preset zephyr
```
Nitro will upload the generated output using `zephyr-agent`. If `zephyr-agent` is missing, Nitro will prompt to install it locally and will install it automatically in CI.
## Deploy during build
Zephyr is a little different here from most Nitro providers: we recommend enabling deployment during `nitro build` and treating build as the primary deployment step.
If your CI pipeline already runs `nitro build`, enable deployment during the build step:
```ts [nitro.config.ts]
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
preset: "zephyr",
zephyr: {
deployOnBuild: true,
},
});
```
Then your normal build command is enough:
<pm-run></pm-run>
After the build finishes, Nitro uploads the generated output to Zephyr, deploys it to the edge, and prints the deployment URL:
```txt
◐ Building [Nitro] (preset: zephyr, compatibility: YYYY-MM-DD)
...
ZEPHYR Uploaded local snapshot in 110ms
ZEPHYR Deployed to Zephyr's edge in 700ms.
ZEPHYR
ZEPHYR https://my-app.zephyrcloud.app
```
## CI authentication
Zephyr requires an API token for non-interactive deployments. The example below uses the simpler personal-token style setup with `ZE_SECRET_TOKEN` together with `zephyr.deployOnBuild`.
```yaml [.github/workflows/deploy.yml]
name: Deploy with Zephyr
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
env:
ZE_SECRET_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
```
For more advanced CI/CD setups, Zephyr also documents organization-level server-token authentication using `ZE_SERVER_TOKEN`. See the [Zephyr CI/CD server token docs](https://docs.zephyr-cloud.io/features/ci-cd-server-token).
## Options
### `zephyr.deployOnBuild`
Deploy to Zephyr during `nitro build` when using the `zephyr` preset.
- Default: `false`

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