Skill: encryptshawn/directus-io (version 1.0.0)

--- name: directus_io description: Use this skill for anything involving Directus — the open-source headless CMS and backend-as-a-service. Triggers include setting up Directus (Docker, Cloud, self-hosted), using the Directus JavaScript/TypeScript SDK, integrating Directus with frontend frameworks (especially Astro, but also Next.js, Nuxt, SvelteKit), building Directus Flows and automations, generating content with AI via Directus Automate, building custom extensions (hooks, endpoints, interfaces, layouts, modules, operations), data modeling and collections, access control and permissions, file management, real-time subscriptions, REST and GraphQL API usage, troubleshooting Directus errors, and any mention of 'Directus', 'directus.io', '@directus/sdk', 'Directus Cloud', 'Directus Flows', 'Directus Automate', or 'Data Studio'. Also trigger when users mention headless CMS integration with Astro/TypeScript and Directus is a likely fit, or when they ask about CMS-powered content pipelines, dynamic page generation from a CMS, or automating content workflows with a headless backend. --- # Directus Skill Directus is an open-source headless CMS and backend-as-a-service that wraps any SQL database with instant REST and GraphQL APIs, plus a no-code Data Studio for content management. It's built with Node.js and Vue.js, supports PostgreSQL, MySQL, SQLite, MariaDB, MS-SQL, OracleDB, and CockroachDB, and is fully extensible. ## How to Use This Skill This skill is organized into focused reference files. Read the relevant reference before answering: | Topic | Reference File | When to Read | |---|---|---| | **SDK & API Basics** | `references/sdk-and-api.md` | Any question about the JS/TS SDK, REST API, GraphQL, authentication, CRUD operations, filtering, or real-time subscriptions | | **Astro Integration** | `references/astro-integration.md` | Integrating Directus with Astro — fetching data, dynamic routes, SSG/SSR, live preview, authentication, visual editing | | **TypeScript Patterns** | `references/typescript-patterns.md` | Schema typing, type generation, advanced generics, type-safe SDK usage | | **Flows & Automation** | `references/flows-and-automation.md` | Directus Flows, triggers, operations, scheduled tasks, AI content generation, webhook integrations | | **Extensions** | `references/extensions.md` | Custom endpoints, hooks, interfaces, layouts, displays, modules, operations, panels, themes | | **Data Modeling & Admin** | `references/data-modeling.md` | Collections, fields, relations (M2O, O2M, M2M, M2A), singletons, permissions, roles, file management, environment config | | **Troubleshooting** | `references/troubleshooting.md` | Common errors, debugging, CORS issues, Docker problems, migration headaches, performance tips | **Always read the relevant reference file before generating code or instructions.** For complex questions spanning multiple topics, read multiple references. ## Quick-Reference Essentials These are the most common patterns you'll need. For anything beyond these basics, consult the reference files. ### Install the SDK ```bash npm install @directus/sdk ``` ### Create a Client (TypeScript) ```typescript import { createDirectus, rest, staticToken } from '@directus/sdk'; // Define your schema for type safety interface MySchema { posts: Post[]; categories: Category[]; global: GlobalSettings; // singleton (not an array) } interface Post { id: number; title: string; content: string; status: string; category: number | Category; } interface Category { id: number; name: string; } interface GlobalSettings { site_title: string; description: string; } const client = createDirectus<MySchema>('https://your-directus-url.com') .with(staticToken('your-token')) .with(rest()); ``` ### Read Items ```typescript import { readItems, readItem, readSingleton } from '@directus/sdk'; // Get all posts const posts = await client.request(readItems('posts')); // Get one post with relational data const post = await client.request(readItem('posts', 1, { fields: ['*', { category: ['name'] }], })); // Get singleton const global = await client.request(readSingleton('global')); ``` ### Astro Quick Setup ```typescript // src/lib/directus.ts import { createDirectus, rest } from '@directus/sdk'; const directus = createDirectus(import.meta.env.DIRECTUS_URL).with(rest()); export default directus; ``` ### Docker Quick Start ```yaml # docker-compose.yml services: directus: image: directus/directus:latest ports: - 8055:8055 volumes: - ./database:/directus/database - ./uploads:/directus/uploads - ./extensions:/directus/extensions environment: [REDACTED_SECRET]' ADMIN_EMAIL: '[REDACTED_SECRET]' ADMIN_PASSWORD: 'your-password' DB_CLIENT: 'sqlite3' DB_FILENAME: '/directus/database/data.db' WEBSOCKETS_ENABLED: 'true' ``` ## Key Concepts to Remember - **Composable Client**: The SDK client starts empty — you add features with `.with(rest())`, `.with(authentication())`, `.with(realtime())`, `.with(staticToken())`. - **Schema Typing**: Always define a TypeScript schema interface for type-safe SDK usage. Regular collections are arrays, singletons are singular types. - **Access Policies**: New collections are private by default. You must configure public read access or use authentication tokens. - **Directus Assets**: Images/files are served at `{DIRECTUS_URL}/assets/{file-id}` with optional transformation query params like `?width=500&format=webp`. - **Flows vs Extensions**: Flows are no-code automations configured in the Data Studio. Extensions are code-based additions (hooks, endpoints, etc.) that extend Directus itself. - **Environment Variables**: Directus is heavily configured via env vars — `DB_CLIENT`, `SECRET`, `CORS_ENABLED`, `AUTH_PROVIDERS`, etc.

path: references/data-modeling.md
content: # Directus Data Modeling & Administration

## Table of Contents
1. [Collections & Fields](#collections--fields)
2. [Relations](#relations)
3. [Permissions & Access Control](#permissions--access-control)
4. [File Management](#file-management)
5. [Environment Configuration](#environment-configuration)
6. [Installation & Deployment](#installation--deployment)
7. [Schema Migration](#schema-migration)

---

## Collections & Fields

### Creating Collections
Collections map to database tables. Create them in the Data Studio under **Settings → Data Model** or via API.

**Common field types:**
| Type | Description | Use Case |
|---|---|---|
| `string` | Short text (varchar) | Titles, names, slugs |
| `text` | Long text | Descriptions, excerpts |
| `integer` | Whole number | IDs, counts |
| `float` / `decimal` | Decimal number | Prices, percentages |
| `boolean` | True/false | Toggles, flags |
| `datetime` | Date and time | Publish dates, timestamps |
| `date` | Date only | Birthdays, event dates |
| `time` | Time only | Schedules |
| `json` | JSON object | Structured data, settings |
| `uuid` | UUID string | Primary keys |
| `hash` | Hashed string | Passwords |
| `csv` | Comma-separated values | Simple lists |

### Interface Types
Interfaces control how fields appear in the editor:
- `input` — standard text input
- `input-rich-text-html` — WYSIWYG editor (stores HTML)
- `input-rich-text-md` — Markdown editor
- `input-code` — Code editor with syntax highlighting
- `select-dropdown` — Dropdown select
- `select-multiple-checkbox` — Checkboxes
- `boolean` — Toggle switch
- `datetime` — Date/time picker
- `file-image` — Image upload
- `file` — File upload
- `tags` — Tag input
- `slider` — Range slider
- `map` — Map coordinate picker
- `translations` — Multilingual content interface

### System Fields
Directus can automatically manage these optional fields:
- `status` — workflow status (draft, published, archived)
- `sort` — manual sort order
- `date_created` — auto-set on creation
- `date_updated` — auto-set on update
- `user_created` — auto-set to creating user
- `user_updated` — auto-set to updating user

### Singleton Collections
A singleton has exactly one item — used for global settings, homepage content, etc. Enable in collection settings: **Singleton → Treat as a single object**.

---

## Relations

### Many-to-One (M2O)
The most common relation. A foreign key field on the "many" side points to the "one" side.

**Example:** Each `post` belongs to one `category`.
- Field `category` on `posts` collection → references `categories.id`
- In the editor, shows as a dropdown or drawer selector

### One-to-Many (O2M)
The reverse of M2O. Displayed on the "one" side as a list of related items.

**Example:** A `category` has many `posts`.
- Configure as an O2M alias field on `categories` → references `posts.category`
- No new database column — it reads the existing M2O relation in reverse

### Many-to-Many (M2M)
Requires a junction collection with two foreign keys.

**Example:** `posts` and `tags` linked via `posts_tags`.
- Junction collection `posts_tags`:
  - `posts_id` → M2O to `posts`
  - `tags_id` → M2O to `tags`
- Directus auto-creates the junction if you set up M2M in the Data Model UI

### Many-to-Any (M2A)
A polymorphic relation where items can reference rows from multiple collections. Used for page builders and block-based content.

**Example:** A `page` has `blocks` that can be heroes, text sections, galleries, etc.
- Junction collection `pages_blocks`:
  - `pages_id` → M2O to `pages`
  - `collection` → string (which collection the item comes from)
  - `item` → string (the ID of the related item)
  - `sort` → integer (ordering)

### Translations
A special M2M pattern for multilingual content. Directus provides a dedicated translations interface that creates:
- A `languages` collection
- A junction collection (e.g., `posts_translations`) with fields for each translated field plus a `languages_code` foreign key

---

## Permissions & Access Control

### Concepts
- **Roles** — groups of users (e.g., Admin, Editor, Viewer)
- **Policies** — sets of permissions attached to a role
- **Permissions** — CRUD access per collection, optionally with field-level and item-level rules
- **Access Policies** — found in Settings → Access Policies

### Default State
New collections are completely private. No role (including Public) has any access until you grant it.

### Public Role
The Public role controls what unauthenticated users can access. For a public-facing site:
1. Go to **Settings → Access Policies → Public**
2. For each collection you want publicly readable, enable **Read** access
3. Optionally restrict which fields are visible

### Field-Level Permissions
Control which fields a role can read or write:
```
Role: Editor
Collection: posts
Create: ✅ (title, content, excerpt, status=draft only)
Read: ✅ (all fields)
Update: ✅ (title, content, excerpt — but NOT status)
Delete: ❌
```

### Item-Level Permissions (Custom Rules)
Restrict access to specific items based on field values:
```
Role: Author
Collection: posts
Read: Custom → user_created equals $CURRENT_USER
Update: Custom → user_created equals $CURRENT_USER
```

### Available Variables in Permission Rules
- `$CURRENT_USER` — the authenticated user's ID
- `$CURRENT_ROLE` — the authenticated user's role UUID
- `$NOW` — current timestamp

### Admin Role
Users with the admin role bypass all permission checks. There is always at least one admin user.

---

## File Management

### Storage Adapters
Directus supports multiple storage backends:

```env
# Local filesystem (default)
STORAGE_LOCATIONS="local"
STORAGE_LOCAL_ROOT="./uploads"

# Amazon S3
STORAGE_LOCATIONS="s3"
STORAGE_S3_KEY="your-access-key"
STORAGE_S3_SECRET="your-secret-key"
STORAGE_S3_BUCKET="your-bucket"
STORAGE_S3_REGION="us-east-1"

# Google Cloud Storage
STORAGE_LOCATIONS="gcs"
STORAGE_GCS_KEY_FILENAME="./service-account.json"
STORAGE_GCS_BUCKET="your-bucket"

# Cloudflare R2
STORAGE_LOCATIONS="s3"
STORAGE_S3_KEY="your-r2-key"
STORAGE_S3_SECRET="your-r2-secret"
STORAGE_S3_BUCKET="your-bucket"
STORAGE_S3_ENDPOINT="https://{account-id}.r2.cloudflarestorage.com"
STORAGE_S3_REGION="auto"
```

### Image Transformations
Directus automatically serves transformed images via query parameters:
```
GET /assets/{file-id}?width=300&height=200&fit=cover&format=webp&quality=80
```

Transformation presets can be configured to limit allowed transformations:
```env
ASSETS_TRANSFORM="presets"
ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION="4096"
```

---

## Environment Configuration

### Essential Variables

```env
# Security (REQUIRED)
[REDACTED_SECRET]"

# Database
DB_CLIENT="pg"                    # pg, mysql, sqlite3, mssql, oracledb, cockroachdb
DB_HOST="localhost"
DB_PORT="5432"
DB_DATABASE="directus"
DB_USER="directus"
DB_PASSWORD="password"

# For SQLite
DB_CLIENT="sqlite3"
DB_FILENAME="./database.db"

# Admin User (first run only)
ADMIN_EMAIL="[REDACTED_SECRET]"
ADMIN_PASSWORD="secure-password"
ADMIN_TOKEN="optional-static-token"

# Server
HOST="0.0.0.0"
PORT="8055"
PUBLIC_URL="https://your-directus-domain.com"

# CORS
CORS_ENABLED="true"
CORS_ORIGIN="https://your-frontend.com"
CORS_METHODS="GET,POST,PATCH,DELETE"

# WebSockets
WEBSOCKETS_ENABLED="true"

# Email
EMAIL_TRANSPORT="smtp"
EMAIL_SMTP_HOST="smtp.example.com"
EMAIL_SMTP_PORT="587"
EMAIL_SMTP_USER="user"
EMAIL_SMTP_PASSWORD="password"
EMAIL_FROM="[REDACTED_SECRET]"

# Cache
CACHE_ENABLED="true"
CACHE_STORE="redis"
CACHE_REDIS="redis://localhost:6379"
CACHE_TTL="5m"

# Rate Limiting
RATE_LIMITER_ENABLED="true"
RATE_LIMITER_STORE="redis"
RATE_LIMITER_POINTS="50"
RATE_LIMITER_DURATION="1"

# Extensions
EXTENSIONS_PATH="./extensions"
EXTENSIONS_AUTO_RELOAD="false"

# Logging
LOG_LEVEL="info"                  # fatal, error, warn, info, debug, trace
LOG_STYLE="pretty"                # pretty, raw
```

---

## Installation & Deployment

### Docker (Recommended)
```yaml
# docker-compose.yml
services:
  database:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: directus
      POSTGRES_PASSWORD: directus
      POSTGRES_DB: directus

  cache:
    image: redis:7

  directus:
    image: directus/directus:latest
    ports:
      - 8055:8055
    volumes:
      - ./uploads:/directus/uploads
      - ./extensions:/directus/extensions
    depends_on:
      - database
      - cache
    environment:
      [REDACTED_SECRET]'
      ADMIN_EMAIL: '[REDACTED_SECRET]'
      ADMIN_PASSWORD: 'your-password'

      DB_CLIENT: 'pg'
      DB_HOST: 'database'
      DB_PORT: '5432'
      DB_DATABASE: 'directus'
      DB_USER: 'directus'
      DB_PASSWORD: 'directus'

      CACHE_ENABLED: 'true'
      CACHE_STORE: 'redis'
      CACHE_REDIS: 'redis://cache:6379'

      WEBSOCKETS_ENABLED: 'true'

volumes:
  pgdata:
```

### npm (Self-Hosted)
```bash
npx create-directus-project my-project
cd my-project
npx directus start
```

### Directus Cloud
Sign up at directus.io/cloud — managed hosting from $15/month with auto-scaling, CDN, and backups.

### One-Click Deploy
- **Railway**: One-click deploy with PostgreSQL, Redis, and S3 storage
- **DigitalOcean**: App Platform deployment
- **Render**: Web service deployment

---

## Schema Migration

Directus supports schema snapshots and diffs for migrating data models between environments.

### Export Schema Snapshot
```bash
# CLI
npx directus schema snapshot ./snapshot.yaml

# API
GET /schema/snapshot
```

### Apply Schema to Another Instance
```bash
# Generate a diff
npx directus schema diff ./snapshot.yaml

# Apply the diff
npx directus schema apply ./snapshot.yaml
```

### API-Based Migration
```typescript
// Export from source
const snapshot = await fetch('https://source.directus.app/schema/snapshot', {
  headers: { Authorization: 'Bearer admin-token' },
}).then(r => r.json());

// Diff against target
const diff = await fetch('https://target.directus.app/schema/diff', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer admin-token',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(snapshot),
}).then(r => r.json());

// Apply diff to target
await fetch('https://target.directus.app/schema/apply', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer admin-token',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(diff),
});
```

### Best Practices
- Always snapshot before upgrading Directus versions
- Use schema migration in CI/CD pipelines to keep staging and production in sync
- Schema snapshots include collections, fields, and relations — not content data
- For content migration, use the API to export/import items


path: references/troubleshooting.md
content: # Directus Troubleshooting

## Table of Contents
1. [Installation & Startup Issues](#installation--startup-issues)
2. [Authentication & Token Errors](#authentication--token-errors)
3. [CORS & Network Errors](#cors--network-errors)
4. [SDK Errors](#sdk-errors)
5. [Docker Issues](#docker-issues)
6. [Database Issues](#database-issues)
7. [File Upload & Asset Issues](#file-upload--asset-issues)
8. [Extension Issues](#extension-issues)
9. [Performance Issues](#performance-issues)
10. [Migration & Upgrade Issues](#migration--upgrade-issues)
11. [Flow & Automation Issues](#flow--automation-issues)
12. [Framework Integration Issues](#framework-integration-issues)

---

## Installation & Startup Issues

### "SECRET must be set" Error
Directus requires a `SECRET` environment variable for encryption.
```env
[REDACTED_SECRET]"
```
Generate one: `openssl rand -hex 32`

### "Port 8055 already in use"
Another process is using the port. Either stop it or change Directus's port:
```env
PORT=8056
```

### Directus Hangs on Startup
Common causes:
- Database connection failing — verify `DB_HOST`, `DB_PORT`, credentials
- Redis connection failing (if cache enabled) — verify `CACHE_REDIS` URL
- For Docker: ensure database service is healthy before Directus starts (use `depends_on` with health checks)

### "Cannot find module" Errors
After upgrading Directus or Node.js:
```bash
rm -rf node_modules
npm install
```

For Docker, pull the latest image:
```bash
docker compose pull
docker compose up -d
```

---

## Authentication & Token Errors

### "Invalid token" Error
Causes and fixes:
- **Token expired**: Access tokens have a short TTL. Refresh with `await client.refresh()` or re-login
- **Token not saved**: After generating a static token in the Data Studio, you MUST click Save on the user profile
- **Wrong token type**: Static tokens go in `staticToken()`, login tokens are managed by `authentication()`
- **SECRET changed**: If you change the `SECRET` env var, all existing tokens are invalidated

### "Forbidden" / 403 Error
The authenticated user (or Public role) doesn't have permission for the requested action.

Checklist:
1. Check **Settings → Access Policies** for the relevant role
2. Verify the collection has the needed CRUD permissions
3. Check for item-level permission rules that might filter out the item
4. If using the Public role, ensure read access is explicitly granted
5. For file access, ensure the Public role has read access to `directus_files`

### Login Returns "Invalid credentials"
- Verify email and password
- Check if the user exists and is active (not suspended)
- External auth provider users cannot use email/password login — they must use their provider's flow
- The `requestPasswordReset` endpoint throws a Forbidden error for external auth provider users

### Session/Cookie Auth Not Working
For SSR frameworks using cookie auth:
- `credentials: 'include'` must be set on both `authentication()` and `rest()`:
  ```typescript
  const client = createDirectus(url)
    .with(authentication('session', { credentials: 'include' }))
    .with(rest({ credentials: 'include' }));
  ```
- Ensure `CORS_CREDENTIALS=true` in Directus
- Cookies require matching domains or proper `SameSite` configuration

---

## CORS & Network Errors

### "Access-Control-Allow-Origin" Errors
Enable and configure CORS in Directus:
```env
CORS_ENABLED=true
CORS_ORIGIN=http://localhost:4321,https://your-site.com
CORS_METHODS=GET,POST,PATCH,DELETE
CORS_ALLOWED_HEADERS=Content-Type,Authorization
CORS_CREDENTIALS=true
```

Common mistakes:
- Using `CORS_ORIGIN=*` with `CORS_CREDENTIALS=true` — browsers reject this. List specific origins instead
- Forgetting to include both `http://localhost:PORT` for dev and production URL
- Missing `CORS_CREDENTIALS=true` when using cookie/session auth

### "Mixed Content" Errors
Browser blocks HTTP requests from HTTPS pages. Ensure both your frontend and Directus use HTTPS in production.

### Fetch Failures in SSG Build
During static build (e.g., `astro build`), your Directus server must be running and reachable. Verify `DIRECTUS_URL` in `.env` is correct and the server is up.

---

## SDK Errors

### "No URL provided" / "Request failed"
Ensure you're passing a valid URL to `createDirectus()`:
```typescript
// Wrong — missing protocol
const client = createDirectus('directus.example.com');

// Correct
const client = createDirectus('https://directus.example.com');
```

### "readItems is not a function" / Import Errors
Ensure you're importing from `@directus/sdk`, not an old SDK version:
```typescript
// Correct (SDK v13+)
import { createDirectus, rest, readItems } from '@directus/sdk';

// Wrong (old SDK)
import { Directus } from '@directus/sdk';
```

### TypeScript Type Errors with SDK
- Provide a Schema generic: `createDirectus<MySchema>(url)`
- Ensure your schema interface uses arrays for regular collections and singular types for singletons
- Relational fields should use union types: `number | RelatedType`
- After upgrading the SDK, check for breaking changes in type definitions

### "Cannot read property of undefined" on Response
The response might be empty or the collection name might be wrong. Always handle null:
```typescript
const posts = await client.request(readItems('posts'));
// posts could be an empty array — check length before accessing [0]
```

---

## Docker Issues

### Container Crashes on Startup
Check logs first:
```bash
docker compose logs directus
```

Common causes:
- Database not ready yet — add a health check:
  ```yaml
  database:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U directus"]
      interval: 5s
      timeout: 5s
      retries: 5

  directus:
    depends_on:
      database:
        condition: service_healthy
  ```
- Incorrect volume mounts — ensure paths exist on the host
- Incompatible env vars — check for typos

### Volumes / Permissions Errors
If Directus can't write to mounted volumes:
```bash
# Fix permissions (Linux/Mac)
sudo chown -R 1000:1000 ./uploads ./database ./extensions
```

Or in docker-compose:
```yaml
directus:
  user: "1000:1000"
```

### Extensions Not Loading in Docker
Ensure the extensions volume mount is correct:
```yaml
volumes:
  - ./extensions:/directus/extensions
```

And that built extensions (with `dist/` or `index.js`) are in the right structure:
```
extensions/
└── my-extension/
    ├── package.json
    └── dist/
        └── index.js
```

### Upgrading Directus in Docker
```bash
docker compose pull
docker compose down
docker compose up -d
```

Always snapshot your schema before upgrading:
```bash
# Before upgrade
docker compose exec directus npx directus schema snapshot /directus/snapshot.yaml
```

---

## Database Issues

### "Relation already exists" on Setup
If you're reusing a database that already has Directus tables, either:
- Use a fresh database
- Or run migrations: `npx directus database migrate:latest`

### Slow Queries
- Add database indexes for fields you frequently filter/sort on
- Use `fields` parameter to limit returned data
- Use `limit` to avoid fetching entire collections
- Enable caching: `CACHE_ENABLED=true`
- For PostgreSQL, run `ANALYZE` periodically

### Connection Pool Exhaustion
If you see "too many connections" errors:
```env
DB_POOL__MIN=0
DB_POOL__MAX=10
```

---

## File Upload & Asset Issues

### "Payload too large" on Upload
Increase the max payload size:
```env
MAX_PAYLOAD_SIZE="100mb"
```

For Docker with a reverse proxy, also configure the proxy's max body size (e.g., `client_max_body_size 100m;` in nginx).

### Assets Return 403
Ensure the accessing role has read permission on `directus_files`. For public access:
1. **Settings → Access Policies → Public**
2. Enable Read on `directus_files`

### Image Transformations Not Working
- Check `ASSETS_TRANSFORM` env var — must be `"all"` or `"presets"`
- Verify Sharp is installed (included in Docker image, may need manual install for npm setups)
- Very large images may exceed `ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION`

---

## Extension Issues

### Extension Not Appearing
1. Verify the extension is built: check for `dist/index.js`
2. Verify directory structure: `extensions/{extension-name}/package.json`
3. Check `package.json` has correct `directus:extension` metadata
4. Restart Directus (or enable `EXTENSIONS_AUTO_RELOAD=true`)
5. Check logs for extension loading errors

### "Route doesn't exist" for Custom Endpoints
- Endpoints are available at `/{extension-name}/` (the directory name or custom `id`)
- Ensure the extension exported correctly — `export default` for the handler
- Check that the extension built successfully without errors
- Verify Express route paths are correct (leading slash matters)

### Hooks Not Firing
- Verify event names: `items.create` not `item.create`
- Collection-specific events: use `posts.items.create` or check `meta.collection` inside a generic handler
- Filter hooks must `return` the payload — forgetting this silently drops the data
- Check that the hook is registered in the correct lifecycle phase

---

## Performance Issues

### Slow API Responses
1. **Enable caching**: `CACHE_ENABLED=true` with Redis
2. **Limit fields**: Always specify `fields` in queries instead of returning everything
3. **Use pagination**: Never fetch unbounded result sets
4. **Add database indexes**: For frequently filtered/sorted fields
5. **Optimize relations**: Don't deeply nest relational queries if you don't need the data

### High Memory Usage
- Directus with Sharp (image processing) can use significant memory
- Set `ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION` to limit max image size
- Use Redis for caching instead of in-memory
- For Docker, set memory limits:
  ```yaml
  directus:
    deploy:
      resources:
        limits:
          memory: 512M
  ```

### Slow Builds (SSG)
If `astro build` is slow due to many API calls:
- Reduce the number of collections/items fetched at build time
- Use incremental builds where supported
- Consider hybrid (SSR for dynamic pages, SSG for static)
- Cache Directus responses during build

---

## Migration & Upgrade Issues

### Breaking Changes Between Versions
Always check the [Directus release notes](https://github.com/directus/directus/releases) before upgrading. Major areas of change:
- SDK import paths and function signatures
- Environment variable names
- Database migration requirements
- Extension API changes

### Schema Migration Conflicts
If `schema apply` fails:
1. Take a fresh snapshot of the target
2. Compare manually with the source snapshot
3. Resolve conflicts in collection/field definitions
4. Apply incrementally if needed

### Data Loss Prevention
- Always back up your database before upgrading
- Test upgrades in a staging environment first
- Schema snapshots don't include content — back up your database separately

---

## Flow & Automation Issues

### Flow Not Triggering
- Check the flow's status is "Active"
- For Event Hooks: verify the correct scope (items.create, items.update) and collection are selected
- For Schedules: verify CRON syntax is correct
- For Webhooks: test the URL with curl
- For Manual: ensure the collection is selected in the trigger config

### Operations Failing Silently
- Check server logs: `docker compose logs directus`
- Use a "Log to Console" operation between steps to debug the data chain
- Web Request failures: check response status codes, verify URLs and headers
- Template variables: ensure they match exactly (e.g., `{{ operation_key.field }}`)

### Data Chain Variables Not Resolving
- Operation keys are case-sensitive
- Use `{{ $trigger }}` for trigger data, `{{ operation_key }}` for operation output
- Array access: `{{ operation_key[0].field }}`
- Check that the previous operation actually returned data

---

## Framework Integration Issues

### Astro: "getStaticPaths() requires at least one entry"
Your Directus query returned zero items. Causes:
- Collection is empty
- Filter excludes all items (e.g., nothing is "published")
- Public role doesn't have read access
- Token is invalid or missing

Handle gracefully:
```typescript
export async function getStaticPaths() {
  try {
    const pages = await directus.request(readItems('pages'));
    if (!pages.length) return [{ params: { slug: '404' }, props: {} }];
    return pages.map(p => ({ params: { slug: p.slug }, props: p }));
  } catch (error) {
    console.error('Failed to fetch pages:', error);
    return [{ params: { slug: '404' }, props: {} }];
  }
}
```

### Astro: Content Not Updating After Directus Changes
For SSG sites, you need to rebuild after content changes. Options:
- Set up a Directus Flow to trigger a deploy webhook on publish
- Use SSR mode (`output: 'server'`) for always-fresh content
- Use hybrid mode and mark content pages as SSR

### Next.js / Nuxt: ISR Cache Not Invalidating
Use `revalidate` (Next.js) or Directus webhooks to trigger revalidation:
```typescript
// Next.js API route for on-demand revalidation
export async function POST(request: Request) {
  const body = await request.json();
  revalidatePath(`/blog/${body.slug}`);
  return Response.json({ revalidated: true });
}
```

### Environment Variables Not Available
- In Astro: prefix with nothing (just use `import.meta.env.DIRECTUS_URL`)
- In Next.js: prefix with `NEXT_PUBLIC_` for client-side, no prefix for server-side
- In Nuxt: use `runtimeConfig` in `nuxt.config.ts`
- In plain Node.js: use `process.env.DIRECTUS_URL`
- Check `.env` file is in the project root and the dev server was restarted after changes

### "fetch is not defined" in Node.js
The Directus SDK uses `fetch` internally. Node.js 18+ includes it natively. For older versions:
```bash
npm install undici
```
Or use Node.js 18+.


path: references/flows-and-automation.md
content: # Directus Flows & Automation

## Table of Contents
1. [Flows Overview](#flows-overview)
2. [Trigger Types](#trigger-types)
3. [Operations](#operations)
4. [The Data Chain](#the-data-chain)
5. [AI Content Generation](#ai-content-generation)
6. [Scheduled Content Publishing](#scheduled-content-publishing)
7. [Deployment Webhooks](#deployment-webhooks)
8. [Content Approval Workflows](#content-approval-workflows)
9. [External Service Integration](#external-service-integration)
10. [Managing Flows via API](#managing-flows-via-api)

---

## Flows Overview

Directus Flows (also called Directus Automate) provide a no-code visual interface for building event-driven automations. A Flow consists of a **trigger** (what starts it) and one or more **operations** (what it does), connected in a chain.

Flows are configured in the Data Studio under **Settings → Flows**.

Key concepts:
- **Triggers** start the flow — they can be events, schedules, webhooks, or manual actions
- **Operations** are individual steps — reading data, making API calls, sending emails, running code, etc.
- **Data Chain** — each operation's output is available to subsequent operations via template syntax `{{ $trigger.body }}`, `{{ operationName.result }}`
- **Permissions** — flows run with configurable permissions: `$public`, `$trigger` (same as triggering user), `$full` (admin), or a specific role UUID

---

## Trigger Types

### Event Hook
Fires when something happens in Directus (item created, updated, deleted, etc.).

```
Type: Event Hook
Scope: items.create, items.update, items.delete
Collections: posts, pages (select which collections trigger the flow)
Response: Action (Non-Blocking) — runs in background
         Filter (Blocking) — can modify or reject the operation
```

**Action (Non-Blocking)** — the triggering operation completes immediately; the flow runs asynchronously. Use for notifications, logging, external sync.

**Filter (Blocking)** — the flow runs before the operation completes and can modify the payload or throw an error to cancel it. Use for validation, data enrichment before save.

### Schedule (CRON)
Fires on a time-based schedule using CRON syntax.
```
Type: Schedule (CRON)
Interval examples:
  */5 * * * *     — every 5 minutes
  0 * * * *       — every hour
  0 9 * * 1-5     — 9 AM weekdays
  0 0 * * *       — midnight daily
  0 0 1 * *       — first of each month
```

### Webhook
Fires when an external service POSTs to the flow's webhook URL.
```
Type: Webhook
Method: GET or POST
Async: true/false (respond immediately or wait for flow to complete)
```

The webhook URL is: `{DIRECTUS_URL}/flows/trigger/{flow-id}`

### Manual
Triggered by a user clicking a button in the Data Studio on a specific item.
```
Type: Manual Trigger
Collections: posts (which collections show the button)
```

The trigger payload includes `{{ $trigger.body.keys }}` — an array of selected item IDs.

### Another Flow
Fires when called by another flow's "Trigger Flow" operation. Used for modular, reusable flows.

---

## Operations

### Read Data
Reads items from a collection.
```
Collection: posts
Permissions: Full Access / From Trigger / From Role
Query: filter, fields, sort, limit
Result key: {{ read_data_operation_name }}
```

### Create Data
Creates a new item in a collection.

### Update Data
Updates items by ID or filter.
- Check "Emit Events" if you want this update to trigger other event-based flows.

### Delete Data
Deletes items by ID or filter.

### Web Request
Makes an HTTP request to an external service.
```
Method: GET, POST, PUT, PATCH, DELETE
URL: https://api.example.com/endpoint
Headers: { "Authorization": "Bearer {{$env.API_KEY}}", "Content-Type": "application/json" }
Body: JSON with template variables
```

### Send Email
Sends an email using Directus's configured email transport.
```
To: [REDACTED_SECRET] (or {{ $trigger.payload.user_email }})
Subject: New post published
Body: HTML template with variables
```

### Send Notification
Sends an in-app notification to a Directus user.

### Run Script
Executes custom JavaScript. Has access to the data chain via the `data` parameter:
```javascript
module.exports = async function(data) {
  const triggerData = data.$trigger;
  const previousStep = data.previous_operation_key;

  // Do processing
  const result = { processed: true, count: 42 };

  return result;  // Available as {{ this_operation_key }}
}
```

### Transform Payload
Transforms data using a JSON template with Liquid-style syntax.

### Condition
Branches the flow based on a condition. If true, runs the "success" branch; if false, runs the "reject" branch.
```
Rule: {{ $trigger.payload.status }} == "published"
```

### Log to Console
Logs data to the server console (useful for debugging).

### Trigger Flow
Calls another flow, enabling modular/reusable flow patterns.

### Sleep
Pauses execution for a specified duration (in milliseconds).

---

## The Data Chain

Every trigger and operation adds data to the flow's data chain. Access data from any previous step using template syntax:

```
{{ $trigger }}                          — Full trigger payload
{{ $trigger.body }}                     — Request body (webhooks)
{{ $trigger.body.keys[0] }}             — First selected item ID (manual triggers)
{{ $trigger.payload }}                  — The item data (event hooks)
{{ $trigger.payload.title }}            — Specific field from trigger payload
{{ $accountability.user }}              — ID of user who triggered

{{ operation_key }}                     — Full output of a named operation
{{ operation_key.field_name }}          — Specific field from operation output
{{ operation_key[0].title }}            — Array item access

{{ $env.MY_SECRET }}                    — Environment variable
{{ $now }}                              — Current ISO timestamp
{{ $last }}                             — Output of the immediately previous operation
```

---

## AI Content Generation

### Generate Social Posts with OpenAI

**Flow setup:**
1. **Trigger**: Manual, on the `posts` collection
2. **Read Data** (operation key: `article`): Read the triggered item from `posts` with full access
   - Collection: `posts`
   - Key: `{{ $trigger.body.keys[0] }}`
3. **Web Request** (key: `generate`): POST to OpenAI
   - URL: `https://api.openai.com/v1/chat/completions`
   - Headers: `{ "Authorization": "Bearer {{ $env.OPENAI_API_KEY }}", "Content-Type": "application/json" }`
   - Body:
   ```json
   {
     "model": "gpt-4",
     "messages": [
       {
         "role": "system",
         "content": "You are a social media manager. Write compelling promotional posts based on the content provided."
       },
       {
         "role": "user",
         "content": "Write a Twitter post for: {{ article.title }}"
       }
     ]
   }
   ```
4. **Update Data**: Save the result back to the post
   - Collection: `posts`
   - Key: `{{ $trigger.body.keys[0] }}`
   - Payload: `{ "social_output": "{{ generate.data.choices[0].message.content }}" }`

### Generate SEO Metadata

Same pattern, but the prompt asks for JSON output with `meta_title`, `meta_description`, and `keywords`. Use a **Run Script** operation to parse the JSON and then **Update Data** to save each field.

### Generate Images with DALL·E

**Web Request** to `https://api.openai.com/v1/images/generations`:
```json
{
  "model": "dall-e-3",
  "prompt": "A professional blog header image for an article about {{ article.title }}",
  "n": 1,
  "size": "1792x1024"
}
```

Then use another **Web Request** or **Run Script** to download the generated image URL and import it into Directus Files.

### Auto-Translate Content

Use a flow triggered on `items.create` or `items.update` for your content collection, then:
1. Read the source content
2. Web Request to a translation API (DeepL, Google Translate, or an LLM)
3. Create/Update items in the translations junction collection

---

## Scheduled Content Publishing

For SSG sites that need to publish content at a scheduled time:

### Flow: Publish Scheduled Articles

1. **Trigger**: Schedule (CRON) — e.g., `*/15 * * * *` (every 15 minutes)
2. **Update Data**: Update items in `posts` where:
   - Filter: `status` equals `scheduled` AND `date_published` is less than or equal to `$NOW`
   - Payload: `{ "status": "published" }`
   - Check "Emit Events" so downstream flows (like deploy hooks) are triggered
3. **Condition**: Check if any items were updated
4. **Web Request**: POST to your hosting deploy hook URL

### For SSR/Dynamic Sites
No flow needed — just filter in your frontend query:
```typescript
const posts = await client.request(readItems('posts', {
  filter: {
    _and: [
      { status: { _eq: 'published' } },
      { date_published: { _lte: '$NOW' } },
    ],
  },
}));
```

---

## Deployment Webhooks

### Trigger Rebuild on Content Change

1. **Trigger**: Event Hook, `items.create` / `items.update` on content collections, Action (Non-Blocking)
2. **Condition**: Check `{{ $trigger.payload.status }}` equals `published`
3. **Web Request**: POST to deploy hook

**Netlify:**
```
POST https://api.netlify.com/build_hooks/{your-hook-id}
```

**Vercel:**
```
POST https://api.vercel.com/v1/integrations/deploy/{your-hook-id}/{your-project-id}
```

**Cloudflare Pages:**
```
POST https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/{your-hook-id}
```

---

## Content Approval Workflows

### Multi-Stage Review

1. **Trigger**: Event Hook on `items.update` for `posts` (Filter/Blocking)
2. **Condition**: Status changed to `in_review`
3. **Read Data**: Get the editor/reviewer assigned to this post
4. **Send Email**: Notify the reviewer with a link to the item
5. **Send Notification**: Also send an in-app notification

### Prevent Publishing Without Approval
Use a Filter (Blocking) trigger:

1. **Trigger**: Event Hook on `items.update`, Filter (Blocking)
2. **Condition**: Status is being changed to `published`
3. **Read Data**: Check if the item has an `approved_by` field set
4. **Condition**: If `approved_by` is null, reject the operation (return an error)

---

## External Service Integration

### Sync to External Systems
When content is published, sync to Algolia, Elasticsearch, or a custom service:

1. **Trigger**: Event Hook on `items.create` / `items.update`
2. **Condition**: Status is `published`
3. **Transform Payload**: Shape data for the external API
4. **Web Request**: POST/PUT to external service

### Inbound Webhook Processing
Accept data from external services:

1. **Trigger**: Webhook (POST)
2. **Run Script**: Validate and transform incoming data
3. **Create Data**: Save to a Directus collection
4. **Send Notification**: Alert relevant users

---

## Managing Flows via API

Flows can also be managed programmatically:

```typescript
import { readFlows, readFlow, createFlow, updateFlow, deleteFlow } from '@directus/sdk';

// List all flows
const flows = await client.request(readFlows());

// Read a specific flow
const flow = await client.request(readFlow('flow-uuid'));

// Trigger a webhook flow
await fetch('https://directus.example.com/flows/trigger/flow-uuid', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ key: 'value' }),
});
```


path: references/extensions.md
content: # Directus Extensions

## Table of Contents
1. [Extension Types Overview](#extension-types-overview)
2. [Creating Extensions](#creating-extensions)
3. [Custom Endpoints](#custom-endpoints)
4. [Event Hooks](#event-hooks)
5. [Custom Operations](#custom-operations)
6. [App Extensions (Interfaces, Displays, Layouts, Modules, Panels)](#app-extensions)
7. [Bundles](#bundles)
8. [The Sandbox](#the-sandbox)
9. [Development Workflow](#development-workflow)

---

## Extension Types Overview

| Type | Category | Purpose |
|---|---|---|
| **Endpoints** | API | Register custom REST routes |
| **Hooks** | API | Run code on lifecycle/database events |
| **Operations** | API | Custom operations for use in Flows |
| **Interfaces** | App | Custom form inputs in the editor |
| **Displays** | App | Custom value renderers throughout the Data Studio |
| **Layouts** | App | Custom list/grid views for collection pages |
| **Modules** | App | Custom top-level pages in the Data Studio sidebar |
| **Panels** | App | Custom widgets for Insights dashboards |
| **Themes** | App | Custom visual themes for the Data Studio |
| **Bundles** | Both | Package multiple extensions together |

---

## Creating Extensions

### Scaffold an Extension
```bash
npx create-directus-extension@latest
```

You'll be prompted for:
- Extension type (endpoint, hook, interface, etc.)
- Name
- Language (JavaScript or TypeScript)

This creates a directory with:
```
my-extension/
├── src/
│   └── index.ts (or index.js)
├── package.json
└── tsconfig.json (if TypeScript)
```

### Build and Install
```bash
cd my-extension
npm install
npm run build
```

Copy the built extension to your Directus project's `extensions/` directory. The structure depends on how you're running Directus:

**Docker:**
```yaml
volumes:
  - ./extensions:/directus/extensions
```

**Self-hosted:**
Place in the `extensions/` directory at the root of your Directus project.

### Auto-Reload in Development
Set `EXTENSIONS_AUTO_RELOAD=true` in your Directus `.env` to automatically reload extensions when files change.

---

## Custom Endpoints

Endpoints add new API routes to your Directus instance. They are Express.js route handlers.

### Basic Endpoint
```typescript
// src/index.ts
import { defineEndpoint } from '@directus/extensions-sdk';

export default defineEndpoint((router, context) => {
  const { services, getSchema } = context;

  router.get('/', async (req, res) => {
    res.json({ message: 'Hello from custom endpoint!' });
  });

  router.get('/posts-count', async (req, res, next) => {
    try {
      const { ItemsService } = services;
      const schema = await getSchema();

      const postsService = new ItemsService('posts', {
        schema,
        accountability: req.accountability,
      });

      const posts = await postsService.readByQuery({
        aggregate: { count: '*' },
      });

      res.json({ count: posts[0].count });
    } catch (error) {
      next(error);
    }
  });
});
```

This endpoint is available at `/my-extension/` and `/my-extension/posts-count`.

### Endpoint with Custom ID
```typescript
export default {
  id: 'my-api',
  handler: (router, context) => {
    router.get('/', (req, res) => res.send('Available at /my-api'));
    router.post('/process', async (req, res, next) => {
      try {
        // process req.body
        res.json({ success: true });
      } catch (error) {
        next(error);
      }
    });
  },
};
```

### Context Object
The `context` parameter provides:

| Property | Description |
|---|---|
| `services` | All Directus service classes (ItemsService, FilesService, UsersService, etc.) |
| `getSchema` | Async function returning the current database schema |
| `database` | Knex database instance |
| `env` | Environment variables |
| `logger` | Pino logger instance |
| `emitter` | Event emitter for triggering hooks |

### Error Handling
Always use `next(error)` to pass errors to Directus's error handler:
```typescript
import { createError, ForbiddenError } from '@directus/errors';

const MyError = createError('MY_ERROR', 'Something went wrong.', 400);

router.get('/protected', async (req, res, next) => {
  if (!req.accountability?.user) {
    return next(new ForbiddenError());
  }
  // ...
});
```

---

## Event Hooks

Hooks run code when events occur — database operations, application lifecycle, or schedules.

### Basic Hook
```typescript
import { defineHook } from '@directus/extensions-sdk';

export default defineHook(({ filter, action, init, schedule, embed }) => {
  // FILTER: runs BEFORE event, can modify payload or reject
  filter('items.create', async (payload, meta, context) => {
    // Modify the payload before it's saved
    if (meta.collection === 'posts') {
      payload.slug = payload.title.toLowerCase().replace(/\s+/g, '-');
    }
    return payload;
  });

  // ACTION: runs AFTER event, for side effects
  action('items.create', async (meta, context) => {
    if (meta.collection === 'posts') {
      console.log(`Post created with ID: ${meta.key}`);
    }
  });

  // INIT: runs during Directus startup
  init('app.after', async () => {
    console.log('Directus is ready!');
  });

  // SCHEDULE: runs on a CRON schedule
  schedule('0 * * * *', async () => {
    console.log('Hourly task running...');
  });

  // EMBED: inject HTML into Data Studio
  embed('head', '<style>body { --primary: #ff6600; }</style>');
});
```

### Filter Events
Filters are synchronous and blocking. They can modify data or throw errors:

| Event | Description |
|---|---|
| `items.create` | Before item creation |
| `items.update` | Before item update |
| `items.delete` | Before item deletion |
| `items.query` | Before reading items |
| `<collection>.items.create` | Collection-specific events |
| `request.not_found` | When a route isn't found |
| `request.error` | When a request errors |

```typescript
filter('items.create', async (payload, meta, context) => {
  const { collection, accountability } = meta;
  const { database, schema } = context;

  if (collection === 'posts' && !payload.title) {
    throw new Error('Title is required');
  }

  return payload; // must return the (possibly modified) payload
});
```

### Action Events
Actions are asynchronous and non-blocking:

| Event | Description |
|---|---|
| `items.create` | After item creation |
| `items.update` | After item update |
| `items.delete` | After item deletion |
| `items.sort` | After items are sorted |
| `server.start` | After server starts |
| `server.stop` | Before server stops |
| `auth.login` | After user logs in |
| `auth.logout` | After user logs out |
| `files.upload` | After file upload |

```typescript
action('items.update', async (meta, context) => {
  const { collection, keys, payload } = meta;
  const { services, getSchema } = context;

  if (collection === 'posts' && payload.status === 'published') {
    // Send notification, sync to external service, etc.
    const { MailService } = services;
    const schema = await getSchema();
    const mailService = new MailService({ schema });

    await mailService.send({
      to: '[REDACTED_SECRET]',
      subject: 'New post published',
      text: `Post ${keys[0]} has been published.`,
    });
  }
});
```

### Important Warnings
- Do NOT emit the same event you're handling inside a hook — this creates infinite loops
- Filter hooks on `items.query` / read events can severely impact performance if they do database reads
- System collections use unprefixed names: `users.create` (not `directus_users.create`)
- The `files` collection does not emit create/update events on file upload — use `files.upload` action instead
- The `collections` and `fields` system collections do not emit read events
- The `relations` collection does not emit delete events

---

## Custom Operations

Operations are custom steps usable within Directus Flows.

### Define an Operation
```typescript
// src/index.ts
import { defineOperationApi, defineOperationApp } from '@directus/extensions-sdk';

// API side — the logic
export const api = defineOperationApi({
  id: 'my-operation',
  handler: async (options, context) => {
    const { text, uppercase } = options;
    const result = uppercase ? text.toUpperCase() : text;
    return { processed: result };
  },
});

// App side — the UI in the Flow editor
export const app = defineOperationApp({
  id: 'my-operation',
  name: 'My Custom Operation',
  icon: 'bolt',
  description: 'Processes text',
  overview: ({ text }) => [
    { label: 'Text', text: text || 'Not configured' },
  ],
  options: [
    {
      field: 'text',
      name: 'Text Input',
      type: 'string',
      meta: { interface: 'input', width: 'full' },
    },
    {
      field: 'uppercase',
      name: 'Uppercase',
      type: 'boolean',
      meta: { interface: 'boolean', width: 'half' },
      schema: { default_value: false },
    },
  ],
});
```

---

## App Extensions

### Interfaces
Custom form inputs used in the item editor.

```typescript
import { defineInterface } from '@directus/extensions-sdk';

export default defineInterface({
  id: 'my-color-picker',
  name: 'Color Picker',
  icon: 'palette',
  description: 'A custom color input',
  component: MyColorPicker,        // Vue component
  types: ['string'],                // Directus field types this works with
  options: [                        // Configuration options
    {
      field: 'presets',
      name: 'Preset Colors',
      type: 'json',
      meta: { interface: 'tags' },
    },
  ],
});
```

### Displays
Render a value anywhere items are shown (tables, cards, etc.).

```typescript
import { defineDisplay } from '@directus/extensions-sdk';

export default defineDisplay({
  id: 'my-badge',
  name: 'Status Badge',
  icon: 'flag',
  description: 'Shows status as a colored badge',
  component: MyBadge,
  types: ['string'],
  options: [],
});
```

### Layouts
Custom views for collection list pages.

### Modules
Top-level pages in the Data Studio navigation sidebar.

### Panels
Dashboard widgets for the Insights module.

---

## Bundles

Bundles package multiple extensions into a single installable unit:

```json
// package.json
{
  "name": "my-directus-bundle",
  "directus:bundle": {
    "entries": [
      { "type": "endpoint", "name": "my-endpoint", "source": "src/endpoint/index.ts" },
      { "type": "hook", "name": "my-hook", "source": "src/hook/index.ts" },
      { "type": "interface", "name": "my-interface", "source": "src/interface/index.ts" }
    ]
  }
}
```

---

## The Sandbox

Directus runs API extensions (hooks, endpoints, operations) inside an isolated VM sandbox for security. This means:

- Extensions can't access the file system directly
- Extensions can't require arbitrary Node.js modules
- Extensions use the `@directus/extensions-sdk` to access services
- The sandbox provides controlled access to the Directus API

If you need to bypass the sandbox (for specific packages or functionality), you can disable it for specific extensions in Directus configuration — but this is not recommended for production.

---

## Development Workflow

### Recommended Setup

1. **Docker Compose** for Directus with mounted `extensions/` volume
2. **Extension development** in a separate directory with `npm run dev` for hot-reloading
3. **`EXTENSIONS_AUTO_RELOAD=true`** in Directus env for auto-detection of changes

### Docker Compose for Extension Dev
```yaml
services:
  directus:
    image: directus/directus:latest
    ports:
      - 8055:8055
    volumes:
      - ./database:/directus/database
      - ./uploads:/directus/uploads
      - ./extensions:/directus/extensions
    environment:
      [REDACTED_SECRET]'
      ADMIN_EMAIL: '[REDACTED_SECRET]'
      ADMIN_PASSWORD: 'password'
      DB_CLIENT: 'sqlite3'
      DB_FILENAME: '/directus/database/data.db'
      EXTENSIONS_AUTO_RELOAD: 'true'
```

### Build & Test Loop
```bash
# In your extension directory
npm run build          # Build once
npm run dev            # Watch mode (auto-rebuild on changes)

# Directus auto-reloads the extension if EXTENSIONS_AUTO_RELOAD=true
```

### Debugging
- Use `context.logger.info()` or `context.logger.error()` in API extensions
- Use `console.log` in Run Script operations (visible in server logs)
- Check Docker logs: `docker compose logs -f directus`
- For app extensions, use Vue DevTools in the browser


path: references/sdk-and-api.md
content: # Directus SDK & API Reference

## Table of Contents
1. [SDK Setup & Composables](#sdk-setup--composables)
2. [Authentication](#authentication)
3. [CRUD Operations](#crud-operations)
4. [Filtering, Sorting & Pagination](#filtering-sorting--pagination)
5. [Relational Data & Fields](#relational-data--fields)
6. [File Management](#file-management)
7. [Real-Time Subscriptions](#real-time-subscriptions)
8. [GraphQL Usage](#graphql-usage)
9. [Raw REST API](#raw-rest-api)

---

## SDK Setup & Composables

The Directus SDK (`@directus/sdk`) uses a composable architecture. The client starts empty and gains features through `.with()`:

```bash
npm install @directus/sdk
```

### Available Composables

| Composable | Purpose |
|---|---|
| `rest()` | Enables REST API requests via `client.request()` |
| `graphql()` | Enables GraphQL queries via `client.query()` |
| `authentication(mode)` | Manages login/logout/refresh. Modes: `'json'`, `'session'`, `'cookie'` |
| `staticToken(token)` | Sets a fixed access token for all requests |
| `realtime(options)` | Enables WebSocket subscriptions |

### Client Creation Patterns

**Public (no auth):**
```typescript
import { createDirectus, rest } from '@directus/sdk';

const client = createDirectus<Schema>('https://directus.example.com')
  .with(rest());
```

**Static token (API key):**
```typescript
import { createDirectus, rest, staticToken } from '@directus/sdk';

const client = createDirectus<Schema>('https://directus.example.com')
  .with(staticToken('my-api-token'))
  .with(rest());
```

**Session-based auth (for SSR apps like Astro in server mode):**
```typescript
import { createDirectus, rest, authentication } from '@directus/sdk';

const client = createDirectus<Schema>('https://directus.example.com')
  .with(authentication('cookie'))
  .with(rest());
```

**JSON auth (for SPAs):**
```typescript
import { createDirectus, rest, authentication } from '@directus/sdk';

const client = createDirectus<Schema>('https://directus.example.com')
  .with(authentication('json'))
  .with(rest());
```

### Per-Request Token Override
```typescript
import { withToken, readItems } from '@directus/sdk';

const result = await client.request(
  withToken('temporary-token', readItems('posts'))
);
```

---

## Authentication

### Login / Logout / Refresh
```typescript
// Login (requires authentication() composable)
await client.login('[REDACTED_SECRET]', 'password');

// Refresh tokens
await client.refresh();

// Logout
await client.logout();

// Get current user
import { readMe } from '@directus/sdk';
const me = await client.request(readMe());
```

### Generate Static Token
In the Directus Data Studio: Users → select user → Token field → Generate → Save. Use this token with `staticToken()` or in API headers as `Authorization: Bearer <token>`.

### SSO / External Auth
Directus supports OAuth2, OpenID Connect, LDAP, and SAML. Configure via environment variables:
```env
AUTH_PROVIDERS="google"
AUTH_GOOGLE_DRIVER="openid"
AUTH_GOOGLE_CLIENT_ID="your-client-id"
AUTH_GOOGLE_CLIENT_SECRET="your-client-secret"
AUTH_GOOGLE_ISSUER_URL="https://accounts.google.com/.well-known/openid-configuration"
AUTH_GOOGLE_REDIRECT_ALLOW_LIST="http://localhost:3000/auth/callback"
```

---

## CRUD Operations

All operations use `client.request(operationFunction())`.

### Create
```typescript
import { createItem, createItems } from '@directus/sdk';

// Single item
const newPost = await client.request(createItem('posts', {
  title: 'Hello World',
  content: '<p>My first post</p>',
  status: 'published',
}));

// Multiple items
const newPosts = await client.request(createItems('posts', [
  { title: 'Post A', status: 'draft' },
  { title: 'Post B', status: 'draft' },
]));
```

### Read
```typescript
import { readItems, readItem, readSingleton } from '@directus/sdk';

// All items (with query)
const posts = await client.request(readItems('posts', {
  fields: ['id', 'title', 'status'],
  filter: { status: { _eq: 'published' } },
  sort: ['-date_created'],
  limit: 10,
}));

// Single item by ID
const post = await client.request(readItem('posts', 42));

// Singleton collection (e.g., global settings)
const settings = await client.request(readSingleton('global'));
```

### Update
```typescript
import { updateItem, updateItems, updateSingleton } from '@directus/sdk';

// Single item
await client.request(updateItem('posts', 42, {
  title: 'Updated Title',
}));

// Multiple items by query
await client.request(updateItems('posts', {
  filter: { status: { _eq: 'draft' } },
  limit: -1,
}, {
  status: 'archived',
}));

// Singleton
await client.request(updateSingleton('global', {
  site_title: 'New Site Title',
}));
```

### Delete
```typescript
import { deleteItem, deleteItems } from '@directus/sdk';

// Single
await client.request(deleteItem('posts', 42));

// Multiple
await client.request(deleteItems('posts', [1, 2, 3]));
```

---

## Filtering, Sorting & Pagination

### Filter Operators

| Operator | Meaning |
|---|---|
| `_eq` | Equals |
| `_neq` | Not equals |
| `_gt`, `_gte` | Greater than (or equal) |
| `_lt`, `_lte` | Less than (or equal) |
| `_in` | In array |
| `_nin` | Not in array |
| `_contains` | String contains (case-sensitive) |
| `_icontains` | String contains (case-insensitive) |
| `_starts_with` | String starts with |
| `_ends_with` | String ends with |
| `_null` | Is null |
| `_nnull` | Is not null |
| `_between` | Between two values |
| `_empty` | Is empty (null or '') |
| `_nempty` | Is not empty |

### Compound Filters
```typescript
const results = await client.request(readItems('posts', {
  filter: {
    _and: [
      { status: { _eq: 'published' } },
      {
        _or: [
          { category: { _eq: 'news' } },
          { featured: { _eq: true } },
        ],
      },
    ],
  },
}));
```

### Filtering Relational Fields
```typescript
const results = await client.request(readItems('posts', {
  filter: {
    author: {
      name: { _eq: 'John' },
    },
  },
}));
```

### Sorting
```typescript
// Ascending
const sorted = await client.request(readItems('posts', {
  sort: ['title'],
}));

// Descending (prefix with -)
const sortedDesc = await client.request(readItems('posts', {
  sort: ['-date_created'],
}));

// Multiple sort fields
const multiSort = await client.request(readItems('posts', {
  sort: ['-featured', 'title'],
}));
```

### Pagination
```typescript
// Offset-based
const page2 = await client.request(readItems('posts', {
  limit: 10,
  offset: 10,
}));

// Get total count with items
const withCount = await client.request(readItems('posts', {
  limit: 10,
  offset: 0,
  meta: ['total_count', 'filter_count'],
}));
```

### Search
```typescript
const results = await client.request(readItems('posts', {
  search: 'keyword',
}));
```

---

## Relational Data & Fields

### Selecting Specific Fields
```typescript
// Flat fields
const posts = await client.request(readItems('posts', {
  fields: ['id', 'title'],
}));

// Wildcard (all top-level fields)
const posts = await client.request(readItems('posts', {
  fields: ['*'],
}));

// Nested relational fields
const posts = await client.request(readItems('posts', {
  fields: ['id', 'title', { author: ['name', 'avatar'] }],
}));

// Deep nested
const posts = await client.request(readItems('posts', {
  fields: [
    'id',
    'title',
    { author: ['name', { role: ['name'] }] },
    { tags: [{ tags_id: ['name', 'slug'] }] },
  ],
}));
```

### Aggregation
```typescript
import { aggregate } from '@directus/sdk';

const stats = await client.request(aggregate('posts', {
  aggregate: { count: '*' },
  groupBy: ['status'],
}));
```

---

## File Management

### Upload a File
```typescript
import { uploadFiles } from '@directus/sdk';

const formData = new FormData();
formData.append('file', fileBlob, 'photo.jpg');

const result = await client.request(uploadFiles(formData));
```

### Import a File by URL
```typescript
import { importFile } from '@directus/sdk';

const result = await client.request(importFile('https://example.com/photo.jpg'));
```

### Access File URLs
Files are served at: `{DIRECTUS_URL}/assets/{file-id}`

**Transformations via query params:**
```
/assets/{id}?width=300&height=200&fit=cover&format=webp&quality=80
```

| Param | Values |
|---|---|
| `width` | Pixel width |
| `height` | Pixel height |
| `fit` | `cover`, `contain`, `inside`, `outside` |
| `format` | `jpg`, `png`, `webp`, `avif`, `tiff` |
| `quality` | 1–100 |
| `withoutEnlargement` | `true` to prevent upscaling |

---

## Real-Time Subscriptions

Requires `WEBSOCKETS_ENABLED=true` in Directus config.

```typescript
import { createDirectus, realtime, authentication } from '@directus/sdk';

const client = createDirectus<Schema>('https://directus.example.com')
  .with(authentication())
  .with(realtime());

await client.connect();
await client.login('[REDACTED_SECRET]', 'password');

// Subscribe to collection changes
const { subscription, unsubscribe } = await client.subscribe('posts', {
  query: { fields: ['id', 'title', 'status'] },
});

for await (const message of subscription) {
  console.log('Change:', message);
}
```

### Real-Time with Public Access
```typescript
const client = createDirectus<Schema>('https://directus.example.com')
  .with(realtime({ authMode: 'public' }));

await client.connect();
```

---

## GraphQL Usage

```typescript
import { createDirectus, graphql } from '@directus/sdk';

const client = createDirectus<Schema>('https://directus.example.com')
  .with(graphql());

const result = await client.query<{ posts: Post[] }>(`
  query {
    posts(filter: { status: { _eq: "published" } }) {
      id
      title
      content
    }
  }
`);
```

GraphQL endpoints:
- User collections: `POST /graphql`
- System collections: `POST /graphql/system`

---

## Raw REST API

When the SDK doesn't cover your use case, make custom requests:

```typescript
const result = await client.request(() => ({
  path: '/custom/my-endpoint',
  method: 'GET',
}));

// With body
const result = await client.request(() => ({
  path: '/custom/my-endpoint',
  method: 'POST',
  body: JSON.stringify({ key: 'value' }),
  headers: { 'Content-Type': 'application/json' },
}));
```

### REST Endpoints Reference

| Resource | Endpoint |
|---|---|
| Items | `GET/POST/PATCH/DELETE /items/{collection}` |
| Single Item | `GET/PATCH/DELETE /items/{collection}/{id}` |
| Singleton | `GET/PATCH /items/{collection}` |
| Files | `GET/POST /files` |
| Assets | `GET /assets/{id}` |
| Users | `GET/POST/PATCH/DELETE /users` |
| Auth | `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout` |
| Flows | `GET/POST/PATCH/DELETE /flows` |
| Activity | `GET /activity` |
| Schema | `GET /schema/snapshot`, `POST /schema/diff`, `POST /schema/apply` |


path: references/typescript-patterns.md
content: # Directus TypeScript Patterns

## Table of Contents
1. [Schema Definition](#schema-definition)
2. [Relation Types](#relation-types)
3. [Singleton Collections](#singleton-collections)
4. [Extending Core Collections](#extending-core-collections)
5. [Automatic Type Generation](#automatic-type-generation)
6. [Utility Types & Helpers](#utility-types--helpers)
7. [Typed SDK Requests](#typed-sdk-requests)
8. [Working with JavaScript (Non-TS)](#working-with-javascript-non-ts)

---

## Schema Definition

The SDK's type system revolves around a root schema interface that maps collection names to their item types. This is the single source of truth for all type checking and autocompletion.

```typescript
// src/lib/schema.ts

// Root schema — provided to createDirectus<Schema>()
export interface Schema {
  // Regular collections are ARRAYS
  posts: Post[];
  categories: Category[];
  authors: Author[];
  tags: Tag[];

  // Junction collections (for M2M) are also arrays
  posts_tags: PostTag[];

  // Singletons are SINGULAR types (not arrays)
  global_settings: GlobalSettings;

  // Custom fields on core collections are singular
  directus_users: CustomUser;
}

export interface Post {
  id: number;
  status: 'draft' | 'published' | 'archived';
  title: string;
  slug: string;
  content: string;
  excerpt: string | null;
  cover_image: string | null;           // file UUID
  date_created: string;                  // ISO 8601
  date_updated: string | null;
  // Relations — use union type: foreign key OR populated object
  author: number | Author;
  category: number | Category;
  tags: number[] | PostTag[];
}

export interface Author {
  id: number;
  name: string;
  bio: string | null;
  avatar: string | null;
}

export interface Category {
  id: number;
  name: string;
  slug: string;
  description: string | null;
}

export interface Tag {
  id: number;
  name: string;
  slug: string;
}

// Junction table for posts <-> tags (M2M)
export interface PostTag {
  id: number;
  posts_id: number | Post;
  tags_id: number | Tag;
}

export interface GlobalSettings {
  site_title: string;
  description: string;
  logo: string | null;
  social_links: Record<string, string>;
}

// Extend core DirectusUser type
export interface CustomUser {
  department: string;
  phone: string | null;
}
```

### Why Union Types for Relations?

Relational fields return either the raw foreign key (a number or string) or the fully populated object, depending on whether you included that relation in your `fields` query. The union type `number | Author` handles both cases:

```typescript
const post = await client.request(readItem('posts', 1));
// post.author is `number` (just the ID)

const post = await client.request(readItem('posts', 1, {
  fields: ['*', { author: ['name'] }],
}));
// post.author is `Author` (populated object)
```

---

## Relation Types

### Many-to-One (M2O)
A post belongs to one category. The `category` field stores the foreign key.
```typescript
interface Post {
  category: number | Category;
}
```

### One-to-Many (O2M)
A category has many posts. Represented as an array on the "one" side:
```typescript
interface Category {
  id: number;
  name: string;
  posts: number[] | Post[];  // O2M — array of IDs or populated items
}
```

### Many-to-Many (M2M)
Posts and tags, linked through a junction collection:
```typescript
// In the schema root:
export interface Schema {
  posts: Post[];
  tags: Tag[];
  posts_tags: PostTag[];  // junction MUST be listed
}

interface Post {
  tags: number[] | PostTag[];  // references the junction
}

interface PostTag {
  id: number;
  posts_id: number | Post;
  tags_id: number | Tag;
}
```

Fetching M2M data:
```typescript
const posts = await client.request(readItems('posts', {
  fields: ['title', { tags: [{ tags_id: ['name', 'slug'] }] }],
}));

// Access: posts[0].tags[0].tags_id.name
```

### Many-to-Any (M2A)
Used for page builder / block patterns where a field can reference items from multiple collections:
```typescript
interface Page {
  id: number;
  title: string;
  blocks: PageBlock[];
}

interface PageBlock {
  id: number;
  collection: string;          // which collection this block comes from
  item: BlockHero | BlockText | BlockGallery;
  sort: number;
}

interface BlockHero {
  headline: string;
  content: string;
  image: string | null;
}

interface BlockText {
  body: string;
}

interface BlockGallery {
  title: string;
  images: string[];
}
```

---

## Singleton Collections

Singletons store exactly one item — used for global settings, homepage content, etc. Define them as a singular type (not an array) in the schema:

```typescript
export interface Schema {
  global_settings: GlobalSettings;  // singular = singleton
  posts: Post[];                     // array = regular collection
}
```

Read/update singletons with dedicated functions:
```typescript
import { readSingleton, updateSingleton } from '@directus/sdk';

const settings = await client.request(readSingleton('global_settings'));
await client.request(updateSingleton('global_settings', {
  site_title: 'New Title',
}));
```

---

## Extending Core Collections

Directus has built-in system collections (`directus_users`, `directus_files`, etc.). If you add custom fields to these, include them in your schema:

```typescript
export interface Schema {
  // Your custom fields on directus_users (singular type)
  directus_users: CustomUser;
}

interface CustomUser {
  // Only YOUR custom fields go here — Directus provides the rest
  department: string;
  employee_id: string;
  phone: string | null;
}
```

---

## Automatic Type Generation

Rather than writing types by hand, you can generate them from a running Directus instance.

### Using directus-typescript-gen
```bash
npx directus-typescript-gen \
  --host http://localhost:8055 \
  --email [REDACTED_SECRET] \
  --password your-password \
  --outFile src/lib/schema.d.ts
```

This produces a complete schema file matching your current data model.

### Using the Directus CMS Starter's Built-In Script
The official Astro CMS starter includes a type generation script:
```bash
pnpm run generate:types
```
This requires an admin token with permission to read system collections like `directus_fields`.

### Manual Approach via OpenAPI Spec
Export your OpenAPI spec from Directus (`GET /server/specs/oas`) and use it to derive types manually or with tools like `openapi-typescript`.

---

## Utility Types & Helpers

### Extract Item Type from Schema
```typescript
// Get the item type for a collection
type PostItem = Schema['posts'] extends (infer T)[] ? T : never;
// Result: Post
```

### Narrowing Relational Fields
After fetching with populated relations, narrow the type:
```typescript
function isPopulated<T>(value: number | T): value is T {
  return typeof value === 'object' && value !== null;
}

const post = await client.request(readItem('posts', 1, {
  fields: ['*', { author: ['name'] }],
}));

if (isPopulated(post.author)) {
  console.log(post.author.name); // TypeScript knows it's Author
}
```

### Creating Reusable Query Helpers
```typescript
// src/lib/queries.ts
import { readItems, readItem } from '@directus/sdk';
import directus from './directus';

export async function getPublishedPosts(limit = 20) {
  return directus.request(readItems('posts', {
    fields: ['id', 'slug', 'title', 'excerpt', 'date_created', 'cover_image',
      { author: ['name', 'avatar'] }],
    filter: { status: { _eq: 'published' } },
    sort: ['-date_created'],
    limit,
  }));
}

export async function getPostBySlug(slug: string) {
  const results = await directus.request(readItems('posts', {
    fields: ['*', { author: ['name', 'avatar'] }, { tags: [{ tags_id: ['name', 'slug'] }] }],
    filter: { slug: { _eq: slug }, status: { _eq: 'published' } },
    limit: 1,
  }));
  return results[0] ?? null;
}
```

---

## Typed SDK Requests

### Type Inference on Responses
The SDK infers return types based on your schema and the `fields` parameter. When you specify fields, the return type is narrowed:

```typescript
// Returns full Post type
const post = await client.request(readItem('posts', 1));

// Returns only { id: number; title: string }
const post = await client.request(readItem('posts', 1, {
  fields: ['id', 'title'],
}));
```

### Typing Custom Endpoints
When calling custom API endpoints, you can specify the return type:
```typescript
const result = await client.request<{ total: number; average: number }>(() => ({
  path: '/custom/stats',
  method: 'GET',
}));
```

---

## Working with JavaScript (Non-TS)

If you're not using TypeScript, the SDK still works — you just won't get type checking or autocompletion.

### JavaScript Setup
```javascript
// src/lib/directus.js
import { createDirectus, rest, staticToken } from '@directus/sdk';

const directus = createDirectus(process.env.DIRECTUS_URL)
  .with(staticToken(process.env.DIRECTUS_TOKEN))
  .with(rest());

export default directus;
```

### JavaScript CRUD
```javascript
import { readItems, createItem, updateItem, deleteItem } from '@directus/sdk';
import directus from './directus.js';

// Read
const posts = await directus.request(readItems('posts', {
  fields: ['id', 'title'],
  filter: { status: { _eq: 'published' } },
}));

// Create
const newPost = await directus.request(createItem('posts', {
  title: 'New Post',
  content: 'Hello world',
  status: 'draft',
}));

// Update
await directus.request(updateItem('posts', newPost.id, {
  status: 'published',
}));

// Delete
await directus.request(deleteItem('posts', newPost.id));
```

### JSDoc Type Hints (Optional)
You can add type hints in JS files without full TypeScript:
```javascript
/**
 * @typedef {Object} Post
 * @property {number} id
 * @property {string} title
 * @property {string} slug
 * @property {string} content
 * @property {'draft'|'published'|'archived'} status
 */

/** @type {import('@directus/sdk').DirectusClient} */
const directus = createDirectus('https://example.com').with(rest());
```


path: references/astro-integration.md
content: # Directus + Astro Integration Guide

## Table of Contents
1. [Project Setup](#project-setup)
2. [Directus Client Configuration](#directus-client-configuration)
3. [Fetching & Displaying Data](#fetching--displaying-data)
4. [Dynamic Routes & Pages](#dynamic-routes--pages)
5. [Blog with Relational Data](#blog-with-relational-data)
6. [Images & Assets](#images--assets)
7. [Live Preview](#live-preview)
8. [Authentication in Astro](#authentication-in-astro)
9. [SSR vs SSG Considerations](#ssr-vs-ssg-considerations)
10. [Dynamic Blocks / Page Builder](#dynamic-blocks--page-builder)
11. [Real-Time with Astro](#real-time-with-astro)
12. [Multilingual Content](#multilingual-content)
13. [Deployment & Build Hooks](#deployment--build-hooks)

---

## Project Setup

### Create Astro Project
```bash
npm create astro@latest my-site
cd my-site
npm install @directus/sdk
```

### Environment Variables
Create `.env` in the project root:
```env
DIRECTUS_URL=https://your-directus-project.directus.app
DIRECTUS_TOKEN=your-static-token
```

For TypeScript projects, add types in `src/env.d.ts`:
```typescript
/// <reference types="astro/client" />
interface ImportMetaEnv {
  readonly DIRECTUS_URL: string;
  readonly DIRECTUS_TOKEN: string;
}
```

---

## Directus Client Configuration

Create `src/lib/directus.ts`:

### Public-Only Access (SSG)
```typescript
import { createDirectus, rest } from '@directus/sdk';

// Import your schema types
import type { Schema } from './schema';

const directus = createDirectus<Schema>(import.meta.env.DIRECTUS_URL)
  .with(rest());

export default directus;
```

### Token-Based Access (SSG/SSR)
```typescript
import { createDirectus, rest, staticToken } from '@directus/sdk';
import type { Schema } from './schema';

const directus = createDirectus<Schema>(import.meta.env.DIRECTUS_URL)
  .with(staticToken(import.meta.env.DIRECTUS_TOKEN))
  .with(rest());

export default directus;
```

### Authenticated Access (SSR Only)
```typescript
import { createDirectus, rest, authentication } from '@directus/sdk';
import type { Schema } from './schema';

const directus = createDirectus<Schema>(import.meta.env.DIRECTUS_URL)
  .with(authentication('cookie'))
  .with(rest());

export default directus;
```

---

## Fetching & Displaying Data

### Basic Page with Singleton Data
```astro
---
// src/pages/index.astro
import Layout from '../layouts/Layout.astro';
import directus from '../lib/directus';
import { readSingleton } from '@directus/sdk';

const global = await directus.request(readSingleton('global'));
---

<Layout title={global.site_title}>
  <h1>{global.site_title}</h1>
  <p>{global.description}</p>
</Layout>
```

### List Page
```astro
---
// src/pages/blog/index.astro
import Layout from '../../layouts/Layout.astro';
import directus from '../../lib/directus';
import { readItems } from '@directus/sdk';

const posts = await directus.request(readItems('posts', {
  fields: ['id', 'slug', 'title', 'excerpt', 'date_created', 'cover_image'],
  filter: { status: { _eq: 'published' } },
  sort: ['-date_created'],
  limit: 20,
}));
---

<Layout title="Blog">
  <h1>Blog</h1>
  <ul>
    {posts.map((post) => (
      <li>
        <a href={`/blog/${post.slug}`}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
          <time>{new Date(post.date_created).toLocaleDateString()}</time>
        </a>
      </li>
    ))}
  </ul>
</Layout>
```

---

## Dynamic Routes & Pages

### Static Generation (SSG) — `[slug].astro`
```astro
---
// src/pages/[slug].astro
import Layout from '../layouts/Layout.astro';
import directus from '../lib/directus';
import { readItems } from '@directus/sdk';

export async function getStaticPaths() {
  const pages = await directus.request(readItems('pages', {
    fields: ['slug', 'title', 'content'],
    filter: { status: { _eq: 'published' } },
  }));

  return pages.map((page) => ({
    params: { slug: page.slug },
    props: page,
  }));
}

const page = Astro.props;
---

<Layout title={page.title}>
  <h1>{page.title}</h1>
  <div set:html={page.content} />
</Layout>
```

### Blog Post with Relational Author
```astro
---
// src/pages/blog/[slug].astro
import Layout from '../../layouts/Layout.astro';
import directus from '../../lib/directus';
import { readItems } from '@directus/sdk';

export async function getStaticPaths() {
  const posts = await directus.request(readItems('posts', {
    fields: [
      'slug', 'title', 'content', 'date_created', 'cover_image',
      { author: ['name', 'avatar'] },
    ],
    filter: { status: { _eq: 'published' } },
  }));

  return posts.map((post) => ({
    params: { slug: post.slug },
    props: post,
  }));
}

const post = Astro.props;
const DIRECTUS_URL = import.meta.env.DIRECTUS_URL;
---

<Layout title={post.title}>
  {post.cover_image && (
    <img
      src={`${DIRECTUS_URL}/assets/${post.cover_image}?width=1200&format=webp`}
      alt={post.title}
    />
  )}
  <h1>{post.title}</h1>
  {typeof post.author === 'object' && post.author && (
    <p>By {post.author.name}</p>
  )}
  <time>{new Date(post.date_created).toLocaleDateString()}</time>
  <article set:html={post.content} />
</Layout>
```

### SSR Dynamic Route (No getStaticPaths)
For SSR mode (`output: 'server'` in astro.config), you don't need `getStaticPaths`:
```astro
---
// src/pages/blog/[slug].astro (SSR mode)
import directus from '../../lib/directus';
import { readItems } from '@directus/sdk';

const { slug } = Astro.params;

const posts = await directus.request(readItems('posts', {
  fields: ['*', { author: ['name'] }],
  filter: { slug: { _eq: slug }, status: { _eq: 'published' } },
  limit: 1,
}));

if (!posts.length) return Astro.redirect('/404');
const post = posts[0];
---
```

---

## Blog with Relational Data

### Many-to-Many Tags
Directus M2M relations use a junction collection (e.g., `posts_tags`).

```astro
---
const posts = await directus.request(readItems('posts', {
  fields: [
    'id', 'title', 'slug',
    { tags: [{ tags_id: ['name', 'slug'] }] },
  ],
  filter: { status: { _eq: 'published' } },
}));
---

{posts.map((post) => (
  <article>
    <h2>{post.title}</h2>
    <div class="tags">
      {post.tags?.map((junction) => (
        <span>{junction.tags_id.name}</span>
      ))}
    </div>
  </article>
))}
```

### Filter by Tag
```astro
---
// src/pages/tags/[tag].astro
export async function getStaticPaths() {
  const tags = await directus.request(readItems('tags', {
    fields: ['slug', 'name'],
  }));

  return tags.map((tag) => ({
    params: { tag: tag.slug },
    props: { tag },
  }));
}

const { tag } = Astro.props;

const posts = await directus.request(readItems('posts', {
  fields: ['slug', 'title'],
  filter: {
    tags: {
      tags_id: { slug: { _eq: tag.slug } },
    },
  },
}));
---
```

---

## Images & Assets

### Rendering Directus Images
```astro
---
const DIRECTUS_URL = import.meta.env.DIRECTUS_URL;
---

<!-- Basic image -->
<img src={`${DIRECTUS_URL}/assets/${imageId}`} alt="Description" />

<!-- With transformations -->
<img
  src={`${DIRECTUS_URL}/assets/${imageId}?width=800&height=450&fit=cover&format=webp&quality=80`}
  alt="Description"
  width="800"
  height="450"
  loading="lazy"
/>
```

### Responsive Image Helper
```typescript
// src/lib/image.ts
const DIRECTUS_URL = import.meta.env.DIRECTUS_URL;

export function getImageUrl(
  id: string,
  options?: { width?: number; height?: number; fit?: string; format?: string; quality?: number }
) {
  const params = new URLSearchParams();
  if (options?.width) params.set('width', String(options.width));
  if (options?.height) params.set('height', String(options.height));
  if (options?.fit) params.set('fit', options.fit);
  if (options?.format) params.set('format', options.format);
  if (options?.quality) params.set('quality', String(options.quality));

  const query = params.toString();
  return `${DIRECTUS_URL}/assets/${id}${query ? `?${query}` : ''}`;
}
```

---

## Live Preview

### Directus Config
In Settings → Data Model → your collection, enable Live Preview with URL pattern:
```
http://localhost:4321/{slug}?preview=true
```

### Astro Config (requires SSR)
```javascript
// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  output: 'server',
});
```

### Preview-Aware Page
```astro
---
// src/pages/blog/[slug].astro
import directus from '../../lib/directus';
import { readItems, withToken } from '@directus/sdk';

const { slug } = Astro.params;
const preview = Astro.url.searchParams.get('preview') === 'true';
const [REDACTED_SECRET]'token');

let filter: any = {
  slug: { _eq: slug },
};

// In preview mode, don't filter by status
if (!preview) {
  filter.status = { _eq: 'published' };
}

let request = readItems('posts', {
  fields: ['*'],
  filter,
  limit: 1,
});

// Use token for draft content access
const posts = token
  ? await directus.request(withToken(token, request))
  : await directus.request(request);

if (!posts.length) return Astro.redirect('/404');
const post = posts[0];
---
```

### Visual Editing Support
Install the visual editing library for real-time field editing:
```bash
npm install @directus/visual-editing
```

---

## Authentication in Astro

### SSR Login Flow
```astro
---
// src/pages/login.astro
import directus from '../lib/directus';

if (Astro.request.method === 'POST') {
  const formData = await Astro.request.formData();
  const email = formData.get('email') as string;
  const [REDACTED_SECRET]'password') as string;

  try {
    const result = await directus.login(email, password);
    // Store tokens in cookies
    Astro.cookies.set('access_token', result.access_token, {
      httpOnly: true,
      secure: true,
      path: '/',
      maxAge: 60 * 15, // 15 minutes
    });
    Astro.cookies.set('refresh_token', result.refresh_token, {
      httpOnly: true,
      secure: true,
      path: '/',
      maxAge: 60 * 60 * 24 * 7, // 7 days
    });
    return Astro.redirect('/dashboard');
  } catch (e) {
    // handle error
  }
}
---

<form method="POST">
  <input type="email" name="email" required />
  <input type="password" name="password" required />
  <button type="submit">Login</button>
</form>
```

### Protected Routes via Middleware
```typescript
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { createDirectus, rest, withToken, readMe } from '@directus/sdk';

export const onRequest = defineMiddleware(async (context, next) => {
  const protectedPaths = ['/dashboard', '/profile'];
  const isProtected = protectedPaths.some(p => context.url.pathname.startsWith(p));

  if (!isProtected) return next();

  const [REDACTED_SECRET]'access_token')?.value;
  if (!token) return context.redirect('/login');

  try {
    const client = createDirectus(import.meta.env.DIRECTUS_URL).with(rest());
    const user = await client.request(withToken(token, readMe()));
    context.locals.user = user;
    return next();
  } catch {
    return context.redirect('/login');
  }
});
```

---

## SSR vs SSG Considerations

| Feature | SSG (Static) | SSR (Server) |
|---|---|---|
| Config | `output: 'static'` (default) | `output: 'server'` |
| Data freshness | Build-time only | Every request |
| Dynamic routes | Requires `getStaticPaths()` | Uses `Astro.params` directly |
| Auth / login flows | Not supported | Supported |
| Live preview | Not supported | Supported |
| Hosting | Any static host | Node.js server, serverless, or edge |
| Build hooks | Rebuild on content change | Not needed |

### Hybrid Mode
Astro supports `output: 'hybrid'` where pages default to static but individual pages can opt into SSR:
```astro
---
// This specific page renders on the server
export const prerender = false;
---
```

---

## Dynamic Blocks / Page Builder

Directus Many-to-Any (M2A) relationships enable page-builder patterns where pages contain ordered blocks of different types.

### Data Model
- Collection `pages`: slug, title
- Collection `block_hero`: headline, content, image, buttons (JSON)
- Collection `block_richtext`: content (WYSIWYG)
- Collection `block_gallery`: title, images (M2M to directus_files)
- Junction: `pages_blocks` (M2A linking pages → various block collections)

### Astro Implementation
```astro
---
// src/pages/[slug].astro
import directus from '../lib/directus';
import { readItems } from '@directus/sdk';
import Hero from '../components/blocks/Hero.astro';
import RichText from '../components/blocks/RichText.astro';
import Gallery from '../components/blocks/Gallery.astro';

export async function getStaticPaths() {
  const pages = await directus.request(readItems('pages', {
    fields: [
      'slug', 'title',
      {
        blocks: [
          'id', 'collection', 'sort',
          { item: { block_hero: ['headline', 'content', 'image', 'buttons'] } },
          { item: { block_richtext: ['content'] } },
          { item: { block_gallery: ['title', { images: [{ directus_files_id: ['id', 'title'] }] }] } },
        ],
      },
    ],
  }));

  return pages.map((page) => ({
    params: { slug: page.slug },
    props: page,
  }));
}

const page = Astro.props;

const blockComponents: Record<string, any> = {
  block_hero: Hero,
  block_richtext: RichText,
  block_gallery: Gallery,
};
---

<h1>{page.title}</h1>
{page.blocks
  ?.sort((a, b) => a.sort - b.sort)
  .map((block) => {
    const Component = blockComponents[block.collection];
    return Component ? <Component item={block.item} /> : null;
  })}
```

---

## Real-Time with Astro

Real-time features require client-side JavaScript. Use an Astro island:

```astro
---
// src/pages/live.astro
import LiveFeed from '../components/LiveFeed';
---

<LiveFeed client:only="react" />
```

```tsx
// src/components/LiveFeed.tsx (React island)
import { useEffect, useState } from 'react';
import { createDirectus, realtime } from '@directus/sdk';

export default function LiveFeed() {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const client = createDirectus('https://directus.example.com')
      .with(realtime({ authMode: 'public' }));

    async function connect() {
      await client.connect();
      const { subscription } = await client.subscribe('messages', {
        query: { fields: ['id', 'text', 'date_created'] },
      });

      for await (const msg of subscription) {
        if (msg.event !== 'init') {
          setMessages(prev => [...prev, ...msg.data]);
        }
      }
    }

    connect();
  }, []);

  return (
    <ul>
      {messages.map((m) => <li key={m.id}>{m.text}</li>)}
    </ul>
  );
}
```

---

## Multilingual Content

Directus handles translations via a translations interface that creates a junction collection.

### Fetching Translated Content
```astro
---
const posts = await directus.request(readItems('posts', {
  fields: ['id', 'slug', { translations: ['title', 'content', 'languages_code'] }],
  deep: {
    translations: {
      _filter: { languages_code: { _eq: 'en-US' } },
    },
  },
}));
---

{posts.map((post) => {
  const t = post.translations?.[0];
  return t ? <h2>{t.title}</h2> : null;
})}
```

---

## Deployment & Build Hooks

### Triggering Astro Rebuilds on Content Change
Use Directus Flows to send a webhook when content is published:

1. Create a Flow with an Event Hook trigger on `items.create` / `items.update` for your content collections
2. Add a Condition operation to check `{{ $trigger.payload.status }}` equals `published`
3. Add a Web Request operation to POST to your hosting provider's deploy hook:
   - **Netlify**: `https://api.netlify.com/build_hooks/{hook-id}`
   - **Vercel**: `https://api.vercel.com/v1/integrations/deploy/{hook-id}`
   - **Cloudflare Pages**: Use their deploy hook URL

### CORS Configuration
If your Astro frontend and Directus backend are on different domains, configure CORS in Directus:
```env
CORS_ENABLED=true
CORS_ORIGIN=https://your-astro-site.com
CORS_METHODS=GET,POST,PATCH,DELETE
CORS_ALLOWED_HEADERS=Content-Type,Authorization
```

For local development:
```env
CORS_ENABLED=true
CORS_ORIGIN=http://localhost:4321
```


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)