`) | Card background, border, `rounded-xl`, vertical padding |
| `EmailCardHeader` | `Section` | Horizontal padding and space below |
| `EmailCardTitle` | `Text` (a ``) | `text-xl font-semibold`, with the line height zeroed |
| `EmailCardDescription` | `Text` | Muted, small, no margin |
| `EmailCardContent` | `Section` | Horizontal padding |
| `EmailCardFooter` | `Section` | Horizontal padding and space above |
## Props [#props]
Each component takes the props of the primitive it renders - so `className`,
`style` and children, plus the table or paragraph attributes underneath.
## Gotchas [#gotchas]
`bg-card`, `border-border` and `text-muted-foreground` are defined by
`DefaultTemplateEmail`, which wraps your content in a `` carrying
VitNode's palette. In a template that builds its own document, those classes
resolve to nothing. [Templates](/docs/dev/email/templates#colour-tokens) lists
the tokens.
A React Email `Text` renders a `` with its own margins, which stack oddly
inside a padded section. That is why the snippets above pass `m-0` on the
paragraphs they place directly in a card.
Each of these renders a ``, because that is what survives Outlook.
Nesting them is fine and expected; floating or absolutely positioning them is
not a thing email can do.
## Next [#next]
# Custom Email Adapter
You can connect any transactional email service (e.g. Postmark, AWS SES, SendGrid, Mailgun) by implementing the `EmailApiPlugin` interface. VitNode handles template rendering, i18n localization, and recipient resolution; your adapter just sends the email.
## The `EmailApiPlugin` Interface [#the-emailapiplugin-interface]
The adapter contract consists of a single `sendEmail` method:
```ts
export interface EmailApiPlugin {
sendEmail: (args: {
to: string
subject: string
html: string
text: string
metadata: { title: string; shortTitle?: string }
replyTo?: string
}) => Promise
}
```
***
## Quick start: Implementing an Adapter [#quick-start-implementing-an-adapter]
### 1. Create the Adapter Factory [#1-create-the-adapter-factory]
```ts title="apps/api/src/lib/email/postmark-adapter.ts"
import type { EmailApiPlugin } from "@vitnode/core/api/models/email"
export const PostmarkEmailAdapter = ({
serverToken,
from,
}: {
serverToken: string
from: string
}): EmailApiPlugin => ({
// [!code ++:16]
sendEmail: async ({ to, subject, html, text, metadata }) => {
const res = await fetch("https://api.postmarkapp.com/email", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Postmark-Server-Token": serverToken,
},
body: JSON.stringify({
From: `${metadata.shortTitle ?? metadata.title} <${from}>`,
To: to,
Subject: subject,
HtmlBody: html,
TextBody: text,
}),
})
if (!res.ok) throw new Error(`Postmark error: ${res.statusText}`)
},
})
```
***
### 2. Register in API Configuration [#2-register-in-api-configuration]
Attach the adapter to `vitNodeApiConfig` in `apps/api/src/vitnode.api.config.ts`:
```ts title="apps/api/src/vitnode.api.config.ts"
import { buildApiConfig } from "@vitnode/core/vitnode.config"
import { PostmarkEmailAdapter } from "./lib/email/postmark-adapter"
export const vitNodeApiConfig = buildApiConfig({
// [!code ++:7]
email: {
adapter: PostmarkEmailAdapter({
serverToken: process.env.POSTMARK_SERVER_TOKEN!,
from: "notifications@yourdomain.com",
}),
},
})
```
***
## `sendEmail` Arguments [#sendemail-arguments]
## Learn More [#learn-more]
# Email
VitNode provides a unified transactional email service accessible via `c.get("email")`. Emails are rendered using React components, localized to the recipient's language, and delivered through your chosen provider.
## Quick start [#quick-start]
### Queue an email from a Hono route [#queue-an-email-from-a-hono-route]
```ts title="plugins/blog/src/api/modules/newsletter/routes/welcome.route.ts"
import { buildRoute } from '@vitnode/core/api/lib/route'
export const sendWelcomeRoute = buildRoute({
pluginId: 'blog',
route: {
method: 'post',
path: '/welcome',
responses: { 200: { description: 'Email sent' } },
},
handler: async (c) => {
const user = c.get('user')
// [!code ++:5]
await c.get('email').send({
user, // Resolves email address and preferred locale
subject: 'Welcome to VitNode!',
content: () => 'Thank you for joining our community.',
})
return c.json({ sent: true })
},
})
```
`send()` renders the template and queues delivery. It keeps a successful API
response quick even when the mail provider is having a moody afternoon.
Use `await c.get('email').build(args)` followed by `deliver(email)` only when
a route truly needs immediate delivery. `send(args)` is the normal, queued
path.
## Configuring Email Adapters [#configuring-email-adapters]
Configure your delivery transport in `apps/api/src/vitnode.api.config.ts`:
### 1. Resend Adapter [#1-resend-adapter]
```ts title="apps/api/src/vitnode.api.config.ts"
import { ResendEmailAdapter } from '@vitnode/core/api/adapters/email/resend'
export const vitNodeApiConfig = buildApiConfig({
email: {
from: 'noreply@yourdomain.com',
adapter: ResendEmailAdapter({
apiKey: process.env.RESEND_API_KEY!,
}),
},
})
```
### 2. SMTP Adapter [#2-smtp-adapter]
```ts title="apps/api/src/vitnode.api.config.ts"
import { SmtpEmailAdapter } from '@vitnode/core/api/adapters/email/smtp'
export const vitNodeApiConfig = buildApiConfig({
email: {
from: 'noreply@yourdomain.com',
adapter: SmtpEmailAdapter({
host: process.env.SMTP_HOST!,
port: Number(process.env.SMTP_PORT ?? 587),
user: process.env.SMTP_USER!,
password: process.env.SMTP_PASSWORD!,
}),
},
})
```
***
## Testing Email in AdminCP [#testing-email-in-admincp]
{/* Image prompt: VitNode AdminCP System -> Integrations screen at /admin/core/system/integrations. Email card showing status "Configured" with a "Send Test Email" modal containing recipient address input and delivery confirmation toast. Dark theme, 1440x900. */}
Verify email configuration in the AdminCP at **System → Integrations** (`/admin/core/system/integrations`):
* Click **Test Email** on the email card.
* Enter an email address to dispatch an immediate test delivery.
## Learn More [#learn-more]
# Nodemailer (SMTP)
The Nodemailer adapter speaks plain SMTP, so it works with anything that has a
host, a port and a login: your own Postfix, a provider's relay, or a mail
catcher running on your laptop.
| Cloud | Self-Hosted | Links |
| -------------------- | ----------- | ------------------------------------------------------------------------------------------------------------- |
| ⚠️ Runtime-dependent | ✅ Supported | [NPM Package](https://www.npmjs.com/package/@vitnode/nodemailer) · [Nodemailer docs](https://nodemailer.com/) |
SMTP needs an outbound TCP connection, which edge runtimes cannot open and many
serverless platforms block on the usual mail ports. If that is where you deploy,
use [Resend](/docs/dev/email/resend) instead.
## Quick start [#quick-start]
```ts title="src/vitnode.api.config.ts"
import { buildApiConfig } from '@vitnode/core/vitnode.config'
import { NodemailerEmailAdapter } from '@vitnode/nodemailer'
export const vitNodeApiConfig = buildApiConfig({
email: {
adapter: NodemailerEmailAdapter({
from: process.env.NODE_MAILER_FROM,
host: process.env.NODE_MAILER_HOST,
user: process.env.NODE_MAILER_USER,
password: process.env.NODE_MAILER_PASSWORD,
}),
},
})
```
`port` defaults to `587` and `secure` to `false`, which is the STARTTLS
combination almost every relay wants.
## Setup [#setup]
### Install the adapter [#install-the-adapter]
`nodemailer` itself ships as a dependency of the adapter.
```bash
bun i @vitnode/nodemailer
```
```bash
pnpm i @vitnode/nodemailer
```
```bash
npm i @vitnode/nodemailer
```
### Get an SMTP server to talk to [#get-an-smtp-server-to-talk-to]
In development, catch the mail locally instead of posting it to strangers.
[Mailpit](https://mailpit.axllent.org/) is one binary: an SMTP server on `1025`
and a web inbox on `8025`.
```bash title="Catch mail locally"
docker run -p 8025:8025 -p 1025:1025 \
-e MP_SMTP_AUTH_ACCEPT_ANY=1 \
-e MP_SMTP_AUTH_ALLOW_INSECURE=1 \
axllent/mailpit
```
Both flags matter: the adapter always authenticates, and it does so over an
unencrypted local connection, which Mailpit refuses by default.
In production, take the four values from your provider's SMTP page - it is
usually called "SMTP relay", "Sending" or "Integration".
{/* Image prompt: A screenshot of a transactional email provider's SMTP credentials page, showing the server host, port 587, username and a masked password, with a copy button beside each field. Neutral light theme, 1440x900. */}
### Register the adapter [#register-the-adapter]
The adapter reads options, not the environment, so a port other than `587` has
to be passed through as well:
```ts title="src/vitnode.api.config.ts"
import { buildApiConfig } from '@vitnode/core/vitnode.config'
import { NodemailerEmailAdapter } from '@vitnode/nodemailer' // [!code ++]
export const vitNodeApiConfig = buildApiConfig({
// [!code ++:10]
email: {
adapter: NodemailerEmailAdapter({
from: process.env.NODE_MAILER_FROM,
host: process.env.NODE_MAILER_HOST,
user: process.env.NODE_MAILER_USER,
password: process.env.NODE_MAILER_PASSWORD,
port: Number(process.env.NODE_MAILER_PORT ?? 587),
secure: process.env.NODE_MAILER_PORT === '465',
}),
},
metadata: {
title: 'My Community',
shortTitle: 'Community',
},
})
```
Recipients see `Community `: the display name is
`metadata.shortTitle ?? metadata.title`, and `from` supplies only the address.
### Set the environment variables [#set-the-environment-variables]
Mailpit accepts any credentials, so locally the username and password only have
to be non-empty:
```bash title=".env"
NODE_MAILER_HOST=localhost
NODE_MAILER_PORT=1025
NODE_MAILER_USER=dev
NODE_MAILER_PASSWORD=dev
NODE_MAILER_FROM=hello@localhost
```
Production is the same five lines with your provider's values and, on most
relays, `NODE_MAILER_PORT=587`.
### Send a test mail [#send-a-test-mail]
Send one from a route - the [email overview](/docs/dev/email#quick-start) has the
`build()` plus `deliver()` form that works from any plugin - then open
[localhost:8025](http://localhost:8025). The message appears in Mailpit's inbox,
where you can read the rendered HTML and the raw source that left your app.
{/* Image prompt: A screenshot of the Mailpit web inbox at localhost:8025 with one captured message selected, showing the rendered HTML email in the reading pane and the HTML/Text/Raw tabs above it. Light theme, 1440x900. */}
Nothing there? `deliver()` throws inside the request, so the failing request is
where Nodemailer's own message is - in the response body while `NODE_ENV` is
`development`, in the log otherwise. Mail sent with the queued `send()` is a
tick behind instead, and its story is in the row:
```sql
select status, attempts, "lastError"
from core_queue
where name = 'send-email'
order by id desc
limit 1;
```
## Options [#options]
## Environment variables [#environment-variables]
The adapter takes values, not variable names, so these are yours to rename -
just keep both sides in step.
| Variable | Maps to | Example | Notes |
| ---------------------- | ---------- | --------------- | ---------------------------------------------------------------- |
| `NODE_MAILER_HOST` | `host` | `smtp.host.com` | Required |
| `NODE_MAILER_USER` | `user` | `apikey` | Required. Some providers use a fixed literal here |
| `NODE_MAILER_PASSWORD` | `password` | - | Required |
| `NODE_MAILER_FROM` | `from` | `hi@you.com` | Required. Address only |
| `NODE_MAILER_PORT` | `port` | `587` | Optional - pass it through yourself, the adapter defaults to 587 |
## Gotchas [#gotchas]
The adapter checks `host`, `user`, `password` and `from` inside `sendEmail`,
so a half-configured app still starts. With `deliver()` the failure is the
route's own 500, throwing `Missing nodemailer configuration`. With the queued
`send()` the request returns 200 and the same message turns up in
`core_queue.lastError` three attempts later.
Port 465 expects TLS immediately, while 587 negotiates it with STARTTLS.
Leaving `secure` at its default on 465 gives you a connection that hangs and
then times out rather than a clear error.
The transport is created inside `sendEmail`, so each message opens its own
SMTP connection - simple and stateless, and fine at the volumes a queue
drained once a minute produces. A relay that rate-limits connections rather
than messages is the case to watch.
VitNode renders both an HTML and a plain-text version of every email, but this
adapter passes `html` alone.
## Next [#next]
# Resend
[Resend](https://resend.com/) sends mail over an ordinary HTTPS request, which
makes it the adapter that works everywhere your app can `fetch` - including
serverless and edge deployments where an SMTP connection would never open.
| Cloud | Self-Hosted | Links |
| ----------- | ----------- | ----------------------------------------------------------------------------------------------------- |
| ✅ Supported | ✅ Supported | [NPM Package](https://www.npmjs.com/package/@vitnode/resend) · [Resend docs](https://resend.com/docs) |
## Quick start [#quick-start]
```ts title="src/vitnode.api.config.ts"
import { buildApiConfig } from '@vitnode/core/vitnode.config'
import { ResendEmailAdapter } from '@vitnode/resend'
export const vitNodeApiConfig = buildApiConfig({
email: {
adapter: ResendEmailAdapter({
apiKey: process.env.RESEND_API_KEY,
from: process.env.RESEND_FROM_EMAIL,
}),
},
})
```
That is the whole integration. The steps below are the account setup around it.
## Setup [#setup]
### Install the adapter [#install-the-adapter]
The `resend` SDK ships as a dependency of the adapter, so this is the only
package you add.
```bash
bun i @vitnode/resend
```
```bash
pnpm i @vitnode/resend
```
```bash
npm i @vitnode/resend
```
### Create an API key [#create-an-api-key]
In the Resend dashboard, open **API Keys** and create one with send permission.
The value starts with `re_` and is shown exactly once, so copy it now.
{/* Image prompt: A screenshot of the Resend dashboard API Keys page with the "Create API Key" dialog open - name field filled in, permission set to sending access, the generated re_ key partially masked. Dark theme, 1440x900. */}
### Verify a sending domain [#verify-a-sending-domain]
Resend only delivers from a domain you own. Add yours under **Domains**, copy
the DKIM and SPF records it gives you into your DNS, and wait for the status to
go green.
For a first local test you can skip this and send from
`onboarding@resend.dev`, which Resend allows for testing only - it will not
carry your production mail.
{/* Image prompt: A screenshot of the Resend dashboard Domains page showing one domain with a green "Verified" status and its DKIM/SPF DNS records listed below. Dark theme, 1440x900. */}
### Register the adapter [#register-the-adapter]
Add the `email` block to your API config. Both values come from the
environment, so nothing secret lives in the repository:
```ts title="src/vitnode.api.config.ts"
import { buildApiConfig } from '@vitnode/core/vitnode.config'
import { ResendEmailAdapter } from '@vitnode/resend' // [!code ++]
export const vitNodeApiConfig = buildApiConfig({
// [!code ++:6]
email: {
adapter: ResendEmailAdapter({
apiKey: process.env.RESEND_API_KEY,
from: process.env.RESEND_FROM_EMAIL,
}),
},
metadata: {
title: 'My Community',
shortTitle: 'Community',
},
})
```
Recipients see `Community `: the display name is
`metadata.shortTitle ?? metadata.title`, and `from` supplies only the address.
### Set the environment variables [#set-the-environment-variables]
```bash title=".env"
RESEND_API_KEY=re_your_api_key
RESEND_FROM_EMAIL=hello@your-verified-domain.com
```
### Send a test mail [#send-a-test-mail]
Send one from a route - the [email overview](/docs/dev/email#quick-start) has the
`build()` plus `deliver()` form that works from any plugin. Because `deliver()`
runs inside the request, a rejection fails that request: the adapter rethrows
Resend's error as `[error_name]: message`, which is the response body in
development and a log line in production.
An accepted message appears under **Emails** in the dashboard within seconds,
with its delivery events.
Mail sent with the queued `send()` - core's password resets, for instance - is
one tick behind, and the row tells you where it got to:
```sql
select status, attempts, "lastError"
from core_queue
where name = 'send-email'
order by id desc
limit 1;
```
## Options [#options]
## Environment variables [#environment-variables]
Both are read in `src/vitnode.api.config.ts`, so the names are yours to choose;
these are the ones this documentation uses.
| Variable | Maps to | Example | Notes |
| ------------------- | -------- | ----------------------- | -------------------------------------------------------------------------- |
| `RESEND_API_KEY` | `apiKey` | `re_123...` | Created under **API Keys**. Needs sending permission |
| `RESEND_FROM_EMAIL` | `from` | `hello@your-domain.com` | Must sit on a verified domain, or be `onboarding@resend.dev` while testing |
## Gotchas [#gotchas]
The adapter validates its own config inside `sendEmail`, so an unset
`RESEND_API_KEY` never stops the API from starting. With `deliver()` the
failure is the route's own 500, throwing `Missing Resend configuration`. With
the queued `send()` the request still returns 200 and the same message turns
up in `core_queue.lastError` three attempts later.
Resend answers with an error rather than holding the mail, and the adapter
rethrows it as `[error_name]: message`. If sends start failing the moment you
switch from `onboarding@resend.dev` to your own domain, check the domain
status before you check your code.
VitNode renders both an HTML and a plain-text version of every email, but this
adapter passes `html` alone - the `text` argument reaches it and goes unused.
## Next [#next]
# Email Templates
VitNode renders email templates using [React Email](https://react.email). Templates are standard React components transformed into cross-client compatible HTML tables with localized text and inline styles.
## Quick start [#quick-start]
### 1. Build the Template Component [#1-build-the-template-component]
Wrap template content inside `DefaultTemplateEmail`:
```tsx title="plugins/blog/src/emails/welcome-email.tsx"
import DefaultTemplateEmail, {
type DefaultTemplateEmailProps,
} from "@vitnode/core/emails/default-template"
import { Button, Section, Text } from "react-email"
interface WelcomeEmailProps extends DefaultTemplateEmailProps {
loginUrl?: string
}
export default function WelcomeEmail({
user,
loginUrl = "https://example.com/login",
...props
}: WelcomeEmailProps) {
return (
// [!code ++:13]
Welcome, {user.name}!
We are thrilled to have you in the community.
Visit Your Account
)
}
```
***
### 2. Send the Email [#2-send-the-email]
Send or queue the email from any Hono route or task:
```ts
import WelcomeEmail from "@/emails/welcome-email"
// [!code ++:6]
await c.get("email").send({
user: { email: user.email, name: user.name },
subject: "Welcome to VitNode!",
content: WelcomeEmail,
})
```
`send()` handles locale resolution, renders the JSX to HTML, and pushes the delivery to the mail transport.
***
## Preview Templates Locally [#preview-templates-locally]
Preview emails in your browser during development using the React Email preview server:
```bash
bun run email dev
```
```bash
pnpm exec email dev
```
```bash
npx email dev
```
Visit `http://localhost:3001` to view your email rendered live with hot reloading.
***
## Localization in Emails [#localization-in-emails]
Email templates receive the recipient's preferred locale automatically:
```tsx
export default function OrderEmail({ i18n, ...props }: DefaultTemplateEmailProps) {
return (
{i18n.t("order.confirmed")}
)
}
```
## Learn More [#learn-more]
# Built-in Events
Every event VitNode and its first-party plugins emit today, grouped by domain.
Listen to any of them from your own plugin with
[`buildEventListener`](/docs/dev/events) - no import from the emitting plugin is
needed, because the event map is global.
## Quick start [#quick-start]
Pick a name from the tables below, write a listener for it, and register the
listener on a top-level module's `events` array:
```ts title="plugins/shop/src/api/lib/events.ts"
import { buildEventListener } from '@vitnode/core/api/lib/events'
export const welcomeListener = buildEventListener({
event: 'user.created', // [!code ++]
name: 'send-welcome-email',
handler: async (c, payload) => {
await c.get('queue').dispatch({
name: 'send-welcome-email',
payload: { userId: payload.userId, email: payload.email },
})
},
})
```
Nothing else has to change: the payload type comes from the name, and the
[delivery guarantees](/docs/dev/events#delivery-guarantees) are the same for a
core event as for one of your own.
## Core events (`@vitnode/core`) [#core-events-vitnodecore]
Six names, all declared in `VitNodeEvents` in
`packages/vitnode/src/api/models/events.ts`. Every one of them fires **after**
the write it describes has committed.
| Event | Payload | Fires when |
| -------------- | ---------------------------------------- | ------------------------------------------------------------------------- |
| `user.created` | `{ userId, email, name, emailVerified }` | A user row is inserted - public sign-up, AdminCP creation, or SSO sign-up |
| `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP (profile fields and/or role assignments) |
| `user.deleted` | `{ userId, email }` | Never - the name is declared for plugins, core has no deletion flow |
| `role.created` | `{ roleId }` | A role is created in the AdminCP |
| `role.updated` | `{ roleId }` | A role is edited in the AdminCP |
| `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP |
Emitted from `UserModel.signUp`, which is the one insert path into
`core_users` - so it covers the public sign-up form, user creation in the
AdminCP, and the first sign-in through an [SSO provider](/docs/dev/sso).
**A listener would** send a welcome or verification email (via a
[queue task](/docs/dev/advanced/queue) so it retries), subscribe the address to
a newsletter audience, or provision plugin-owned data such as a profile row.
```ts title="Example: welcome email listener"
export const welcomeListener = buildEventListener({
event: 'user.created',
name: 'send-welcome-email',
handler: async (c, payload) => {
await c.get('queue').dispatch({
name: 'send-welcome-email',
payload: { userId: payload.userId, email: payload.email },
})
},
})
```
Emitted by the AdminCP user `PATCH` after it commits - profile fields (email,
name, name code) and/or role assignments. The payload carries the user's
**current** values.
**A listener would** sync the identity into an external system (CRM, mailing
list), invalidate a plugin-owned cache keyed by user, or audit-log the staff
edit using the envelope's `actor`.
The name and payload live on the `VitNodeEvents` map so listeners type-check,
but **core never emits it**: there is no user-deletion flow yet. It is the
agreed-upon name for plugins that implement account deletion themselves, and
core will emit it once deletion lands.
Emitted after a role is created or edited in the AdminCP, including its
translated names - those live in `core_languages_words`, so the payload has no
name field to carry.
**A listener would** provision plugin-side permission defaults for a new role,
or refresh an externally-cached permission matrix when one changes.
Emitted after the delete transaction commits. Its translated names are removed
with it, and its secondary-role assignments and staff-permission entries are
dropped by database cascade. Members point at their primary role through a
`NOT NULL` restricted foreign key, so anyone in the role is reassigned
**before** the delete - by the time this fires, no user references it.
**A listener would** clean up plugin-owned rows keyed by role id. The role row
is already gone, so key the cleanup off `payload.roleId` rather than re-reading
`core_roles`:
```ts title="Example: clean up plugin data on role deletion"
export const roleCleanupListener = buildEventListener({
event: 'role.deleted',
name: 'cleanup-role-settings',
handler: async (c, payload) => {
await c
.get('db')
.delete(shop_role_settings)
.where(eq(shop_role_settings.roleId, payload.roleId))
},
})
```
## Content Engine events [#content-engine-events]
Every content type declared with the
[Content Engine](/docs/dev/content-engine) gets its own event names, built from
its id: `content..`. For the example plugin's `example.article`
that means `content.example.article.created`, and for the blog's `blog.post` it
means `content.blog.post.created`.
Which actions exist depends on what the definition opts into. A content type
that declares nothing beyond its fields emits three events; one that declares
everything emits sixteen.
| The definition declares | Events it adds |
| ------------------------------------ | ------------------------------------------------------------------- |
| nothing extra (always) | `created`, `updated`, `deleted` |
| `publication` | `published`, `unpublished` |
| `editorial` | `restored` |
| `editorial.scheduling` | `scheduled`, `schedule_cancelled` |
| `localization` | `translation_created`, `translation_updated`, `translation_deleted` |
| `localization` **and** `publication` | `translation_published`, `translation_unpublished` |
| `localization` **and** `editorial` | `translation_restored` |
| `delivery` | `delivery_slug_changed`, `delivery_redirect_created` |
The gating is in the types, not just at runtime: `ContentEventsFor` expands to
literal keys only for the features the definition enables. The example plugin's
`example.article` declares `editorial`, so
`content.example.article.restored` type-checks; `example.category` is one text
field and nothing else, so `content.example.category.restored` is not a key on
the map and a listener for it does not compile.
### Record events [#record-events]
| Event | Payload | Fires when |
| -------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `created` | `{ contentId }` | A record is inserted |
| `updated` | `{ contentId, changedFields }` | At least one declared field actually changed |
| `deleted` | `{ contentId }` | A record is deleted |
| `published` | `{ contentId, publishedAt, scheduledBy?, scheduleId? }` | A record becomes publicly visible - interactively or when a schedule fires |
| `unpublished` | `{ contentId, scheduledBy?, scheduleId? }` | A record is withdrawn |
| `restored` | `{ contentId, changedFields, version, revisionId, restoredFromRevisionId }` | A record is rolled back to an earlier revision - emitted **instead of** `updated` |
| `scheduled` | `{ contentId, action, actorUserId, scheduledFor, scheduleId }` | A publish or unpublish is booked for later |
| `schedule_cancelled` | `{ contentId, action, actorUserId, scheduleId }` | A pending booking is called off |
**A listener would** mirror the row into a plugin-owned projection, reindex it
for an external search engine, or purge a CDN entry. `changedFields` is what
lets it skip the work when the field it cares about did not move.
`publishedAt` is when the record went live for the **first** time and is never
rewritten by a later unpublish and republish. `scheduledBy` and `scheduleId`
are present only when a booking caused the transition; an interactive publish
carries neither.
**A listener would** announce the record (a newsletter, a social post, a
realtime toast), add or remove it from an external index, or expire a cache
that only holds public rows. Because the same publish can arrive twice, key
anything that must happen once off `scheduleId` - see
[A scheduled event may arrive twice](#a-scheduled-event-may-arrive-twice).
A rollback to an earlier revision's field values. It carries `changedFields`
exactly like `updated`, so porting an `updated` listener is a rename rather
than a rewrite - which is why a restore emits one event and not both.
**A listener would** do exactly what its `updated` listener does. A restore
never moves `status` or `publishedAt`, so nothing watching visibility has to
handle it.
Booking a transition changes no field value, so it consumes no version and
writes no revision - `scheduled` is a different fact from `published`. When the
booking fires, the transition emits the ordinary `published` or `unpublished`
with `scheduleId` and `scheduledBy` set.
**A listener would** show an editorial calendar, warn an author that two
bookings collide, or pre-warm a render shortly before `scheduledFor`.
### Translation events [#translation-events]
A content type with [`localization`](/docs/dev/content-engine/localization)
emits one event per translation mutation, in addition to the record events
above. Every one of them carries `locale` and `languageId`, so a listener never
has to go and ask which language moved.
| Event | Payload | Fires when |
| ------------------------- | ---------------------------------------------------------------- | ----------------------------------------- |
| `translation_created` | `{ contentId, locale, languageId, version, revisionId? }` | A language's translation row is written |
| `translation_updated` | `{ ...base, changedFields }` | A language's localized fields changed |
| `translation_deleted` | `{ ...base }` | A language's translation is removed |
| `translation_published` | `{ ...base, publishedAt }` | One language becomes publicly visible |
| `translation_unpublished` | `{ ...base }` | One language is withdrawn |
| `translation_restored` | `{ ...base, changedFields, revisionId, restoredFromRevisionId }` | One language is rolled back to a revision |
**A listener would** invalidate one locale's cache, reindex one language's
search document, or tell a translation vendor that the Polish copy moved.
A shared update and a Polish translation update are different domain facts with
different consequences: one invalidates every language, the other invalidates
one. A listener that had to inspect `changedFields` to tell them apart would get
it wrong the first time a field was renamed - so `changedFields` on a
translation event names localized fields only, and never a shared one.
### Delivery events [#delivery-events]
A content type with
[`delivery`](/docs/dev/content-engine/content-delivery-and-seo) emits two more
when its public URL moves. Both arrive **alongside** the lifecycle event that
moved it - `updated`, `restored`, a publication transition or a
`translation_*` - never instead of one: a field moving and a URL moving are
different facts with different audiences.
| Event | Payload | Fires when |
| --------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------- |
| `delivery_slug_changed` | `{ contentId, slug, previousSlug, previousPath, canonicalPath, locale }` | The canonical public path is different from what it was |
| `delivery_redirect_created` | `{ contentId, previousSlug, previousPath, canonicalPath, locale }` | A path that had genuinely been live now answers as a redirect |
**A listener would** purge a CDN entry, write to an edge redirect table, or tell
an external search engine the address changed. Without this event it would have
to inspect `changedFields` for a slug field whose name it cannot know.
`delivery_redirect_created` fires only when the old address had genuinely been
**publicly addressable** - the engine has a retired history row to prove it. An
article whose slug was corrected three times while it was still a draft emits
three `delivery_slug_changed` and not one redirect; a published article that
moves emits one of each. That is the difference between "a URL now needs a
redirect" and "somebody edited a field".
There is deliberately no sitemap event either: every mutation that changes a
sitemap line already emits `published`, `unpublished`, `deleted` or one of these
two, and a third carrying no new information would be one more thing to keep
consistent for no listener's benefit.
### A scheduled event may arrive twice [#a-scheduled-event-may-arrive-twice]
Announcements for a scheduled transition run in a durable
[queue task](/docs/dev/advanced/queue) that retries whenever the event, the
search write or a cache origin failed - and a retry re-emits an event some
listeners already received. Delivery is **at-least-once**, deliberately: the
alternative is a transactional outbox, which the engine does not have.
A listener whose work must happen exactly once keys off `scheduleId`, which is
stable across every attempt at the same booking:
```ts
handler: async (c, payload) => {
if (payload.scheduleId && (await alreadyDone(payload.scheduleId))) return
await sendTheAnnouncement(payload.contentId)
}
```
An interactive publish is emitted once, by the route that performed it, and
carries no `scheduleId`.
### The envelope's owner is the content type's plugin [#the-envelopes-owner-is-the-content-types-plugin]
`pluginId` on the envelope answers "whose event is this", not "who was running
at the time". Those come apart the moment something happens on a schedule: core
owns the queue handler, so `c.get('plugin')` says `@vitnode/core`, while
`content.example.article.published` belongs to the example plugin as much as it
ever did.
```text
queue task owner @vitnode/core ← who runs the handler
event envelope @vitnode/example ← who owns the content type
```
The engine passes the owner explicitly on every emit, so ownership does not
depend on which route module or queue handler invoked it. Your own code can do
the same when it emits on someone else's behalf:
```ts
await c.get('events').emit('blog.post.created', payload, {
pluginId: '@vitnode/blog', // [!code ++]
})
```
Omit the option and nothing changes: the envelope falls back to
`c.get('plugin')` and then to `@vitnode/core`.
### Failures are reported, not thrown [#failures-are-reported-not-thrown]
`emit()` reports rather than throws. Listeners run after the write it describes
has committed, and a broken listener is not a reason to tell somebody their save
failed - so a failure comes back in the result instead, and is written to
`core_logs` twice over: once per listener by the transport
(`Event listener "plugin:module:listener" for "" failed: ...`), and once
as a JSON summary behind the greppable `[content-effects]` prefix by whichever
effects helper emitted it - the editorial, translation, delivery and scheduled
paths. A plain `created`/`updated`/`deleted` on a content type without
`editorial` goes straight to the transport, so it produces the first line only.
```ts
const result = await c.get('events').emit('blog.post.created', payload)
result.delivered // listeners that ran
result.failures // [{ pluginId, module, listener, error }]
```
Interactive routes ignore that result on purpose, because the mutation succeeded
either way. Background work usually should not: the scheduled-effects task
inspects `failures` and retries the whole delivery when it is non-empty.
{/* Image prompt: A screenshot of the VitNode AdminCP debug panel at /admin/core/debug, dark theme, 1440x900, showing one red "error" log row whose content is the JSON line [content-effects] {"action":"published","contentTypeId":"blog.post","delivered":1,"eventId":"6f1c...","failures":[{"error":"fetch failed","listener":"@acme/shop:orders:mirror-post"}],"itemId":42}. */}
A generated route emits one lifecycle event per successful mutation, after the
database write has returned - plus the delivery pair when the URL moved with
it. A failed validation, a delete blocked by a foreign key, a no-op update, a
no-op publish and a restore that changed nothing all emit nothing. Calling
`service.publish()` - or any other content service method - directly changes
the database and emits **nothing**; that code owns its own follow-up. See
[Services and API](/docs/dev/content-engine/services-and-api).
## Blog events (`@vitnode/blog`) [#blog-events-vitnodeblog]
The blog runs on the [Content Engine](/docs/dev/content-engine), so the events
that describe what actually happened are `content.blog.post.*` and
`content.blog.category.*` - they carry changed fields, revision ids,
publication transitions, per-locale translation events and slug history. The
six names below are re-emitted from those by listeners registered on the
plugin's own `admin` module, so existing consumers keep working. Prefer the
`content.*` ones for anything new.
| Event | Payload | Re-emitted from |
| ----------------------- | ------------------------- | ------------------------------- |
| `blog.post.created` | `{ postId, categoryId }` | `content.blog.post.created` |
| `blog.post.updated` | `{ postId, categoryId }` | `content.blog.post.updated` |
| `blog.post.deleted` | `{ postId }` | `content.blog.post.deleted` |
| `blog.category.created` | `{ categoryId }` | `content.blog.category.created` |
| `blog.category.updated` | `{ categoryId }` | `content.blog.category.updated` |
| `blog.category.deleted` | `{ categoryId, postIds }` | `content.blog.category.deleted` |
### blog.post.created and blog.post.updated [#blogpostcreated-and-blogpostupdated]
The adapter reads the article's categories back to fill in `categoryId`, so a
record deleted in between is simply not announced - and so is one with no
categories yet.
**A listener would** push a realtime "new post" notification, ping a webhook
(from a queue task) that shares the post to social media, invalidate an external
cache, or keep plugin-owned derived data such as related posts in sync.
### blog.post.deleted [#blogpostdeleted]
No `categoryId`, unlike created and updated: the row is gone by the time this is
emitted, so there is nothing left to read it from - and inventing one would put
a wrong id into an audit trail. A listener that needs the category should watch
`content.blog.post.deleted` and keep its own index.
**A listener would** remove the post from an external index or feed.
### blog.category.created and blog.category.updated [#blogcategorycreated-and-blogcategoryupdated]
**A listener would** keep a navigation menu or an externally-cached category
tree in sync, or notify an external CMS of a taxonomy change.
### blog.category.deleted [#blogcategorydeleted]
**A listener would** drop the category from a cached navigation tree. It never
has to fan out to the category's posts, because a category with posts cannot be
deleted in the first place.
## Gotchas [#gotchas]
The AdminCP user `PATCH` emits `user.updated` whenever it succeeds, even if
every field was written with the value it already had, and the role routes
behave the same way. Only the Content Engine's `updated` is gated on a real
diff. A listener that does expensive work should compare before acting.
Events fire at runtime for every registered content type, but the names appear
on `VitNodeEvents` only where the owning plugin declares
`ContentEventsFor`. The example plugin grafts
`example.article` and `example.category` but not its two localized fixtures,
so `content.example.localized-article.created` really is emitted and still
cannot be given a type-checked listener. If your event name does not
autocomplete, that declaration is what is missing - see
[Plugin registration](/docs/dev/content-engine/plugin-registration).
A content type with `editorial` goes through the shared effects helper, which
stamps `version` and `revisionId` onto **every** payload it emits, including
`created` and `deleted`. Neither field is on the declared type for those
actions, so treat them as an implementation detail rather than a contract -
the fields you can rely on are the ones in the tables above.
The person who booked a transition is `actorUserId` on `scheduled` and
`schedule_cancelled`, and `scheduledBy` on the `published` and `unpublished`
that the booking eventually fires. Same human, two keys.
## Deliberately not emitted yet [#deliberately-not-emitted-yet]
High-frequency or consumer-less events are added only when a listener needs
them, so the catalog stays meaningful. There is currently no `user.signedIn`,
`user.passwordResetRequested`, or `file.uploaded`. If you need one, open an
issue or PR - adding an event is a one-line `emit` plus an entry in the
`VitNodeEvents` map. (`user.deleted` is the special case: it is already on the
map, and waits on core growing a user-deletion flow.)
## Next [#next]
# Custom Event Adapter
By default, VitNode delivers domain events in-process on the originating machine. By implementing a custom `EventsApiPlugin`, you can forward events to an external message broker (Redis Streams, RabbitMQ, NATS) to fan them out across a distributed cluster.
## The `EventsApiPlugin` Interface [#the-eventsapiplugin-interface]
```ts
import type { Context } from "hono"
import type { EventEnvelope, EventEmitResult } from "@vitnode/core/api/models/events"
export interface EventsApiPlugin {
name: string
publish: (c: Context, envelope: EventEnvelope) => Promise
}
```
***
## Quick start [#quick-start]
### 1. Build the Adapter [#1-build-the-adapter]
```ts title="apps/api/src/lib/events/redis-stream-adapter.ts"
import type { EventsApiPlugin } from "@vitnode/core/api/models/events"
export const RedisStreamEventsAdapter = (): EventsApiPlugin => ({
name: "redis-stream",
// [!code ++:13]
publish: async (c, envelope) => {
const redis = c.get("redis")
if (redis) {
await redis.xadd("vitnode:events", "*", "payload", JSON.stringify(envelope))
}
return {
eventId: envelope.eventId,
status: "queued",
delivered: 0,
failures: [],
}
},
})
```
***
### 2. Register in API Configuration [#2-register-in-api-configuration]
```ts title="apps/api/src/vitnode.api.config.ts"
import { buildApiConfig } from "@vitnode/core/vitnode.config"
import { RedisStreamEventsAdapter } from "./lib/events/redis-stream-adapter"
export const vitNodeApiConfig = buildApiConfig({
// [!code ++:3]
events: {
adapter: RedisStreamEventsAdapter(),
},
})
```
All calls to `c.get("events").emit()` will now publish events through your custom adapter.
## Learn More [#learn-more]
# Events
VitNode includes an in-process event bus accessible via `c.get("events")`. Events decouple features across plugins (e.g. sending a welcome email when a user registers) without direct inter-plugin dependencies.
## Quick start [#quick-start]
### 1. Emit an Event [#1-emit-an-event]
Emit events from any Hono route or service after a database write:
```ts
await c.get("events").emit("user.created", {
userId: user.id,
email: user.email,
name: user.name,
emailVerified: user.emailVerified,
})
```
***
### 2. Subscribe with an Event Listener [#2-subscribe-with-an-event-listener]
Define a listener in your plugin:
```ts title="plugins/shop/src/api/lib/events.ts"
import { buildEventListener } from "@vitnode/core/api/lib/events"
export const welcomeListener = buildEventListener({
event: "user.created",
name: "send-welcome-email",
handler: async (c, payload) => {
await c.get("queue").dispatch({
name: "send-welcome-email",
payload: { userId: payload.userId, email: payload.email },
})
},
})
```
Register the listener in your module's `events` array:
```ts title="plugins/shop/src/api/modules/orders/orders.module.ts"
import { buildModule } from "@vitnode/core/api/lib/module"
import { welcomeListener } from "../../lib/events"
export const ordersModule = buildModule({
name: "orders",
routes: [listOrdersRoute],
events: [welcomeListener], // [!code ++]
})
```
***
## Declare Custom Event Types [#declare-custom-event-types]
Extend VitNode's global `EventMap` interface in your plugin so payload types are strictly enforced and autocompleted:
```ts title="plugins/shop/src/api/lib/events.ts"
export interface OrderPlacedPayload {
orderId: number
userId: number
total: number
}
// [!code ++:7]
declare module "@vitnode/core/lib/events" {
interface EventMap {
"shop.order.placed": OrderPlacedPayload
}
}
```
Now `emit("shop.order.placed", ...)` and `buildEventListener({ event: "shop.order.placed", ... })` are strictly type-checked.
***
## Delivery Guarantees [#delivery-guarantees]
| Feature | Local Transport |
| :------------------ | :----------------------------------------------------------------------------------------- |
| **Execution** | Runs in-process on the emitting instance. |
| **Ordering** | Sequential execution in registration order. |
| **Error Isolation** | Per listener. An exception in one listener does not affect others. |
| **Durability** | In-memory. For distributed brokers, see [Custom Adapter](/docs/dev/events/custom-adapter). |
## Learn More [#learn-more]
# Fetcher
In a TanStack Start app, use `fetcher` through a plugin API client. The same
request works during SSR and browser navigation.
During SSR, VitNode forwards the visitor’s request to the API. In the browser,
it calls `/api/*` directly. You do not need to write `createIsomorphicFn()` or
choose a transport.
## Create your API client once [#create-your-api-client-once]
### Define it in your plugin [#define-it-in-your-plugin]
Keep this in one plugin file. Features import `notesApi`; they never set up a
module reference themselves.
```ts title="plugins/site-notes/src/api/client.ts"
import type { notesModule } from "../api/notes.module"
import { createApiClient } from "@vitnode/core/tanstack/fetcher"
export const notesApi = createApiClient("@acme/site-notes")
```
### Fetch data [#fetch-data]
```ts title="plugins/site-notes/src/features/notes/notes-query.ts"
import { queryOptions } from "@tanstack/react-query"
import { notesApi } from "../../api/client"
export const notesQueryKey = ["@acme/site-notes", "notes"] as const
export const notesQuery = () =>
queryOptions({
queryKey: notesQueryKey,
queryFn: async ({ signal }) => {
const response = await notesApi.fetch({
method: "get",
module: "notes",
options: { signal },
path: "/",
})
if (!response.ok) {
throw new Error(`The notes API answered ${response.status}.`)
}
return await response.json()
},
})
```
## Use it on a page or in a mutation [#use-it-on-a-page-or-in-a-mutation]
Warm the query in the route loader. The component reads that same cache entry
with `useQuery(notesQuery())`.
```ts title="plugins/site-notes/src/routes/notes.tsx"
import { definePluginRoute } from "@vitnode/core/routing"
import { notesQuery } from "../features/notes/notes-query"
export const route = definePluginRoute({
load: async ({ context }) =>
await context.queryClient.ensureQueryData(notesQuery()),
})
```
Use the same API client, then invalidate the data that changed.
```tsx title="plugins/site-notes/src/features/notes/create-note.tsx"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { notesApi } from "../../api/client"
import { notesQueryKey } from "./notes-query"
export const useCreateNote = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (title: string) => {
const response = await notesApi.fetch({
args: { body: { title } },
method: "post",
module: "notes",
path: "/",
})
if (!response.ok) throw new Error("Could not create the note.")
return await response.json()
},
onSuccess: async () =>
await queryClient.invalidateQueries({ queryKey: notesQueryKey }),
})
}
```
## Server-only work [#server-only-work]
Use `@vitnode/core/tanstack/fetcher/server` only for a server function, cookie
relay, cron/job, secret, or a custom API origin.
Put code that imports this fetcher in a `*.server.ts` file, or call it only
from a server function.
## What the types do [#what-the-types-do]
* `method`, `module`, and `path` are always required.
* `args` is required when the route declares a body, params, or query.
* TypeScript infers the valid route, arguments, response status, and JSON body.
Generated Content Engine modules have no static module type, so use
`rawFetcher` for them instead.
# Languages & Localization
VitNode provides full internationalization out of the box. Every package (`@vitnode/core` and plugins) maintains its own locale files, which VitNode merges per request: core strings first, plugin strings second, and your host app overrides last.
{/* Image prompt: Layered merge diagram showing @vitnode/core strings, plugin strings, and app overrides merging into a unified message tree with defaultLocale fallback. Dark theme, 1600x700. */}
## Quick start [#quick-start]
Add a new language in two CLI commands:
```bash
bun run vitnode i18n:create de Deutsch
bun run vitnode i18n:check
```
```bash
pnpm vitnode i18n:create de Deutsch
pnpm vitnode i18n:check
```
```bash
npx vitnode i18n:create de Deutsch
npx vitnode i18n:check
```
`i18n:create` adds the language to `src/vitnode.config.ts`, seeds a translation file per installed package, and registers the loaders in `src/locales/app.ts`. `i18n:check` scans for missing or untranslated keys - including a file nobody imports, which is the usual reason a translation "does not apply".
***
## Language Configuration [#language-configuration]
Locale metadata is plain data, so it lives in the browser-safe [shared config](/docs/dev/configuration) - one declaration, read by the router, the document shell and (in a single app) the API that sends your emails:
```ts title="apps/web/src/vitnode.config.ts"
export const vitNodeConfig = buildConfig({
i18n: {
defaultLocale: 'en',
locales: [
{ code: 'en', name: 'English' },
// [!code ++:1]
{ code: 'de', name: 'Deutsch' },
],
timeZone: 'UTC',
},
// ...
})
```
`timeZone` is explicit on purpose: your app renders on a server, and without one `use-intl` formats dates in whatever zone the server happens to run in - then warns that the client will disagree.
Because `buildConfig` keeps those codes as literal types, `'de'` is now part of your `Locale` union and a typo in `defaultLocale` is a type error.
***
## Where the loaders go [#where-the-loaders-go]
A `() => import('./de.json')` reads a file out of a package's build output, so it is the one part of i18n that must never reach a browser. Two files own it, and both are registered through the **server-only** config:
| File | Holds |
| :------------------------ | :--------------------------------------------------- |
| `src/locales/packages.ts` | one loader per language each installed package ships |
| `src/locales/app.ts` | your own rewordings, merged last |
```ts title="apps/web/src/vitnode.server.config.ts"
export const vitNodeServerConfig = buildServerConfig({
config: vitNodeConfig, // the locale list above
messages: appMessages, // src/locales/app.ts
packageMessages, // src/locales/packages.ts
})
```
Putting a loader in the shared config puts every plugin's AdminCP copy in your
browser bundle, and makes your Vite build execute it. `vitnode i18n:create`
writes to the right file for you.
Adding a language to a package that ships it needs one line in `src/locales/packages.ts`:
```ts title="apps/web/src/locales/packages.ts"
[CORE.pluginId]: {
en: async () => await import('@vitnode/core/locales/en.json'),
de: async () => await import('@vitnode/core/locales/de.json'), // [!code ++]
},
```
***
## Overriding Strings [#overriding-strings]
To customize existing text from core or a third-party plugin, add an override file in `apps/web/src/locales/{pluginId}/{locale}.json` and register it in `src/locales/app.ts`:
```json title="apps/web/src/locales/@vitnode/core/en.json"
{
"core": {
"global": {
"save": "Update Changes"
}
}
}
```
```ts title="apps/web/src/locales/app.ts"
export const appMessages: AppMessagesMap = {
// [!code ++:3]
en: {
'@vitnode/core': async () => await import('./@vitnode/core/en.json'),
},
}
```
Because your app overrides are merged last, only the keys you specify are overwritten. Everything else continues to fall back to the package defaults.
***
## Translation Architecture [#translation-architecture]
| Source | Role | Order |
| :------------------- | :------------------------------------------------- | :---------------------- |
| `@vitnode/core` | Base strings for auth, admin shells, and dialogs | Base layer |
| **Plugins** | Domain strings declared in `plugins/*/src/locales` | Second layer |
| **Host Application** | Custom overrides in `apps/web/src/locales` | Highest priority (wins) |
Missing keys automatically fall back to `defaultLocale` (`en`), preventing raw key paths from displaying in production.
## Learn More [#learn-more]
# Messages & ICU Syntax
VitNode localizes strings using standard ICU MessageFormat, parsed and rendered through [`use-intl`](https://use-intl.dev).
## Quick start [#quick-start]
### 1. Define Message Strings [#1-define-message-strings]
```json title="plugins/blog/src/locales/en.json"
{
"@vitnode/blog": {
"welcome": "Welcome back, {name}!",
"articles_count": "{count, plural, =0 {No articles} one {1 article} other {# articles}}",
"terms_notice": "By clicking continue, you agree to our Terms of Service."
}
}
```
***
### 2. Render in React Components [#2-render-in-react-components]
```tsx title="plugins/blog/src/views/blog-header.tsx"
import { useTranslations } from "use-intl"
import { Link } from "@tanstack/react-router"
export const BlogHeader = ({ count, name }: { count: number; name: string }) => {
const t = useTranslations("@vitnode/blog")
return (
)
}
```
***
## ICU Syntax Reference [#icu-syntax-reference]
### 1. Variables & Numbers [#1-variables--numbers]
```json
{
"price": "Total: {amount, number, ::currency/USD}",
"date": "Published on {date, date, medium}"
}
```
### 2. Cardinal Pluralization [#2-cardinal-pluralization]
```json
{
"unread": "{count, plural, =0 {No unread messages} one {1 unread message} other {# unread messages}}"
}
```
### 3. Select / Enums [#3-select--enums]
```json
{
"status": "{status, select, draft {Draft} published {Published} other {Archived}}"
}
```
***
## Where Message Files Live [#where-message-files-live]
| Folder | Audience | Purpose |
| :------------------------------ | :------------- | :------------------------------------------------- |
| `src/locales/{locale}.json` | **Frontend** | Browser UI, page text, and AdminCP navigation |
| `src/locales/api/{locale}.json` | **API Server** | Transactional emails and backend validation errors |
## Learn More [#learn-more]
# Namespaces
A namespace is a dotted path into the merged message tree - `core.global`,
`@vitnode/blog.admin.article`. It is the unit of two things at once: what
`useTranslations` reads from, and what a page is allowed to download. The merged
tree holds every installed plugin's copy, and no page should ship all of it, so a
page names the branches it renders and gets exactly those.
## Example [#example]
```json title="plugins/my-plugin/src/locales/en.json"
{
"my-plugin": {
"home": { "title": "Hello World" },
"admin": { "overview": { "title": "My plugin" } }
}
}
```
```tsx
const t = useTranslations('my-plugin.home')
```
Two namespaces exist there - `my-plugin.home` and `my-plugin.admin.overview` -
and a page that renders the public one never downloads the admin one.
## The tree [#the-tree]
Everything sits under the id of the package that owns it. Core owns three
branches; a plugin owns exactly one, named after itself.
| Namespace | Owner | What is in it |
| ----------------- | --------------- | ----------------------------------------------------------- |
| `core.global` | core | Design-system strings every VitNode page needs |
| `core.*` | core | Auth, files, search, content - the public site |
| `admin.*` | core | The AdminCP: `admin.global`, `admin.user`, `admin.staff`, … |
| `@vitnode/blog.*` | `@vitnode/blog` | Everything that plugin ships |
| `{your-plugin}.*` | your plugin | Everything yours ships |
`core.global` is special only in that it is provided above every route, so any
shared component can translate itself wherever it is mounted. Everything else is
asked for.
## The per-plugin prefix rule [#the-per-plugin-prefix-rule]
**Every top-level key a plugin ships is the plugin's own id.** A key outside it
collides with core and with every other plugin, so VitNode ignores it rather than
letting two packages fight over one path.
```json title="plugins/my-plugin/src/locales/en.json"
{
"my-plugin": {
"hello": "Hello World" // [!code ++]
},
"world": "World" // [!code --]
}
```
Scoped ids work exactly the same way - `@vitnode/blog` is one path segment even
though it contains a slash, so `@vitnode/blog.admin` is a legal two-segment
namespace.
A staff permission's label is a **flat top-level key**, not a branch:
`@vitnode/blog:posts:can_view`. There is nothing to slice a namespace out of,
so the AdminCP asks for those keys as if they were namespaces and has to chunk
the request to stay under the limit below. If you are writing them, see [Staff
permissions](/docs/dev/working-with-users/staff-permissions).
## Asking for a namespace [#asking-for-a-namespace]
Declare the exact branches a plugin page renders as the route's `messages`.
VitNode loads them with the route chunk, so public pages do not download a
plugin’s AdminCP copy just because it exists.
```ts title="plugins/my-plugin/src/routes.ts"
import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
export const routes = definePluginRoutes([
page('/reports', {
component: lazy(() => import('./pages/reports-page')),
// [!code ++]
messages: ['my-plugin.reports'],
}),
])
```
A route inherits every namespace its layouts declare, so a shared frame can name
them once for a whole subtree. The route module then calls
`useTranslations('my-plugin.reports')`; if a key renders as its own name, confirm
the route declared the matching namespace.
## Limits [#limits]
A namespace list reaches the server through a server function, which is a public
`POST` endpoint once the app is built. So the rules are enforced rather than
assumed, and the same rules validate a plugin's build-time declaration - one
definition, in `@vitnode/core/routing`, so a route tree cannot accept something the
server refuses.
On top of the numbers: a namespace must be a non-empty string, must not contain
an empty segment (`core..global`, a leading or trailing dot), and must not
contain `__proto__`, `constructor` or `prototype` in any segment. All three are
rejected outright rather than filtered away - a namespace containing one is not a
namespace with a typo in it.
Import the constants if you need to budget against them:
```ts
import { MAX_NAMESPACES } from '@vitnode/core/tanstack/i18n'
```
## Gotchas [#gotchas]
The symptom of a namespace that was never asked for. Check that the route
declares it in the plugin's `routes.ts`.
`At most 16 namespaces may be requested.` is a thrown error, not a truncation.
If you genuinely need more - a screen rendering one flat key per permission -
split the request the way the staff screens do and merge the results, rather
than reaching for a bigger number.
Name what the page renders. Declaring your plugin's whole tree on every route
puts your AdminCP copy in the bundle of a public page nobody logged in is
looking at.
A layout route's `pendingComponent` renders *in place of* the layout, so it is
outside the `RouteMessages` that layout mounts. Either keep it free of
translated text or mount a provider inside it. A child route's
`pendingComponent` is fine - it renders into the layout's `
`, which only exists once the provider has.
## Next [#next]
# Translating Pages
VitNode uses [`use-intl`](https://use-intl.dev) for frontend translations. To keep initial payloads small, pages only download the translation namespaces they explicitly declare.
## Quick start [#quick-start]
### 1. In a Plugin Route (Recommended) [#1-in-a-plugin-route-recommended]
Plugin routes declare their namespaces as `messages` in `routes.ts`. VitNode loads the strings in parallel with the route chunk automatically:
```ts title="plugins/blog/src/routes.ts"
import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
export const routes = definePluginRoutes([
page('/blog/about', {
component: lazy(() => import('./pages/about-page')),
messages: ['@vitnode/blog.about'], // [!code ++]
}),
])
```
Read strings directly with `useTranslations`:
```tsx title="plugins/blog/src/pages/about-page.tsx"
import { useTranslations } from 'use-intl'
const AboutPage = () => {
const t = useTranslations('@vitnode/blog.about')
return {t('title')}
}
export default AboutPage
```
And define the messages in `plugins/blog/src/locales/en.json`:
```json title="plugins/blog/src/locales/en.json"
{
"@vitnode/blog": {
"about": {
"title": "About Our Blog"
}
}
}
```
Host messages are for the site shell. A product page should declare its plugin
namespace as the route's `messages` and keep its locale JSON beside the route.
## Placeholders and Pluralization [#placeholders-and-pluralization]
VitNode supports ICU message syntax out of the box:
```json title="plugins/blog/src/locales/en.json"
{
"cart": {
"greeting": "Hello, {name}!",
"items": "{count, plural, =0 {No items} one {1 item} other {# items}}"
}
}
```
In your React component:
```tsx
const t = useTranslations('cart')
return (
{t('greeting', { name: 'Alex' })}
{t('items', { count: 3 })}
)
```
`core.global` is provided by the root shell to every route, supplying shared
strings for dialogs, toasts, and buttons.
## Learn More [#learn-more]
# API i18n
The API gets the same message tree the frontend does, reachable in any route as `c.get("i18n")`.
```ts
const t = await c.get('i18n').getTranslator()
return c.json({ message: t('core.global.save') })
```
The API reads locale files straight out of `node_modules`. There is no build
step to remember and no `src/locales/` folder to keep in sync - install a
plugin and its translations are available on the server immediately.
The server loads a different, smaller set of strings than the frontend. Packages ship two trees - the frontend's UI copy in `src/locales/`, and the server's strings (emails) in `src/locales/api/` - and the API only ever loads the second. An API-only app never pulls in the admin UI's messages.
## Which locale a request gets [#which-locale-a-request-gets]
`resolveLocale` walks four options in order and takes the first one your app actually lists in `i18n.locales`:
### An explicit choice [#an-explicit-choice]
`getTranslator("pl")`, or the `locale` you passed to `email.send()`. Use it when the language is a property of the thing you are rendering rather than of the caller.
### The signed-in user's language [#the-signed-in-users-language]
`core_users.language`, already loaded on `c.get("user")`.
### The `Accept-Language` header [#the-accept-language-header]
Parsed with its `q` weights, matched exactly first and then on the primary subtag - so `pl-PL` still finds `pl`.
### `defaultLocale` [#defaultlocale]
`en` unless your `i18n` config says otherwise.
Anything not in `i18n.locales` is skipped rather than used, so a stale language code on a user row cannot produce a half-English page.
### Rendering for someone else [#rendering-for-someone-else]
That chain describes the *caller*. When the language belongs to whoever you are rendering for rather than to whoever made the request - an email recipient, a queued job's target - reach for `resolveSupportedLocale` instead:
```ts
const locale = c.get('i18n').resolveSupportedLocale(recipient.language)
```
It takes the language you hand it when the app ships it and `defaultLocale` when it doesn't, and it never touches the request. Dropping a language from `i18n.locales` should send that user English, not the language of the admin who clicked the button.
## The API [#the-api]
Merged trees are cached per locale for the life of the process, so only the first call per language touches the filesystem.
## Emails [#emails]
Emails run through `resolveSupportedLocale`, so they render in the recipient's language or `defaultLocale` - never in the sender's. You rarely call the translator yourself:
```ts title="src/api/modules/users/routes/welcome.route.ts"
await c.get("email").send({
user, // `user.language` decides the locale
subject: ({ i18n }) => createTranslator(i18n)("welcome.email.subject"),
content: props => ,
});
```
Sending to a bare address instead of a user? Pass `locale` explicitly:
```ts
await c.get("email").send({
to: "hello@example.com",
locale: "pl", // [!code highlight]
subject: "Cześć",
content: props => ,
});
```
The template receives `i18n` as a prop and turns it into a translator with `createTranslator` from `use-intl`:
```tsx title="src/emails/welcome.tsx"
import { createTranslator } from 'use-intl' // [!code ++]
export default function WelcomeEmail({ i18n }: DefaultTemplateEmailProps) {
const t = createTranslator(i18n)
return {t('welcome.email.body')}
}
```
## Shipping translations with a plugin [#shipping-translations-with-a-plugin]
A plugin owns its languages, and it splits them the same way the framework does: frontend strings in `src/locales/`, server strings (emails) in `src/locales/api/`. Each tree gets a barrel and is registered with the matching config - the frontend tree with `buildPlugin` in `config.tsx`, the server tree with `buildApiPlugin` in `config.api.ts`.
Most plugins render nothing server-side, so they ship only the frontend tree and register `messages` in `config.tsx` alone. Add the `api/` tree only when your plugin sends email:
```ts title="plugins/{your_plugin}/src/locales/api/index.ts"
import type { LocaleMessagesMap } from '@vitnode/core/lib/i18n/types'
const messages: LocaleMessagesMap = {
en: async () => await import('./en.json', { with: { type: 'json' } }),
}
export default messages
```
```ts title="plugins/{your_plugin}/src/config.api.ts"
import messages from './locales/api' // [!code ++]
export const yourApiPlugin = () =>
buildApiPlugin({
pluginId: CONFIG_PLUGIN.pluginId,
messages, // [!code ++]
modules: [postsModule],
})
```
Adding a language later is a new file plus one line in the barrel - apps pick it up on their next install, and can translate your plugin without forking it by dropping a file in their own `src/locales/{your_plugin}/`.
## Typing the keys [#typing-the-keys]
`t()` autocompletes from a `global.d.ts` that augments use-intl's `Messages`. Import the tree an app actually uses: an API-only app types against the server tree alone, a frontend-only app against the frontend tree, and a single app - one that serves the frontend and runs the API together - against both.
```ts title="global.d.ts (single app)"
///
import coreApi from '@vitnode/core/locales/api/en.json' with { type: 'json' }
import core from '@vitnode/core/locales/en.json' with { type: 'json' }
declare module 'use-intl' {
interface AppConfig {
Messages: typeof core & typeof coreApi // [!code highlight]
}
}
```
Everything a plugin ships belongs under its own namespace, or it will collide
with core. See [Namespaces](/docs/dev/i18n/namespaces).
## When a translation goes missing [#when-a-translation-goes-missing]
VitNode says so, once per package and locale, rather than quietly rendering raw keys:
```
[VitNode i18n] Could not load "pl" messages for "@vitnode/blog" - its strings will render as raw keys.
```
Run [`vitnode i18n:check`](/docs/dev/i18n) to find gaps before your users do.
# Introduction
VitNode is a **TanStack Start** front end, a **Hono** API, Postgres, and an
AdminCP. Its important rule is pleasantly simple: product features live in
**plugins**. A plugin owns its pages, APIs, data, translations, and AdminCP
extensions, so your host app does not become a drawer full of mystery cables.
{/* Image prompt: Clean dark-theme architecture diagram. A TanStack Start application and Hono API sit in the center, Postgres below, and three colorful plugin packages connect to routes, API modules, data, and AdminCP. Keep labels large and legible, 1600x900. */}
These pages follow VitNode 2.0 on the `canary` branch. It moves quickly; when
code and prose disagree, trust the code and send the prose a friendly PR.
## Start with a running app [#start-with-a-running-app]
## Create an app [#create-an-app]
```bash
bun create vitnode-app@canary
```
```bash
pnpm create vitnode-app@canary
```
```bash
npm create vitnode-app@canary
```
Choose **Single App** for one TanStack Start app with Hono at `/api`, or a
monorepo when web and API deploy separately. If you plan to create plugins,
enable **Turborepo** during setup: the generator needs a workspace root.
## The five-minute path [#the-five-minute-path]
1. Start Postgres (Docker is the low-drama local choice).
2. Run `db:migrate` to create core tables and your first administrator.
3. Run `dev`, then visit `http://localhost:3000/admin`.
4. Create a plugin for your first product page, API endpoint, content type, or
dashboard widget.
Every command above is expanded in [Getting started](/docs/dev/setup). From
there, [Build your first plugin](/docs/guides/first-plugin) gives you a real
route to visit—not just a philosophical plugin.
## Find the right reference [#find-the-right-reference]
# Performance
TanStack Start splits components into separate lazy chunks by default, keeping the initial entry bundle light. Follow these key practices to maintain instant page loads.
## Performance Checklist [#performance-checklist]
1. **Keep `head` metadata isolated**: Never import UI components into route `head`.
2. **Lazy-load heavy dialogs**: Use `React.lazy` + `Suspense` for complex editors and dialogs.
3. **Dynamic imports inside loaders**: Lazy-load heavy server/client utilities with `await import(...)`.
4. **Optimize images**: Use `loading="lazy"`, responsive widths, and explicit dimensions.
5. **Analyze your bundle**: Regularly check client chunk sizes.
***
## What Lands in the Client Entry Bundle [#what-lands-in-the-client-entry-bundle]
TanStack Router automatically extracts route `component`, `errorComponent`, and `notFoundComponent` into lazy chunks:
| Route Option | Read When | In Initial Bundle? | Optimization |
| :----------------- | :-------------------- | :------------------ | :---------------------------------------------------------------------- |
| `path`, `id` | Route tree generation | **Yes** | Keep route paths concise |
| `head` | Page navigation | **Yes** | Isolate metadata strings in leaf files |
| `loader` | Pre-render | **Yes (fn only)** | `await import()` large dependencies inside fn |
| `pendingComponent` | Route loading | **Yes** | Plain skeleton markup only - never a layout component from a UI library |
| `component` | Render | **No (Lazy Chunk)** | Automatically code-split |
***
## Best Practices [#best-practices]
### 1. Isolate Metadata from Components [#1-isolate-metadata-from-components]
Because `head` is evaluated in the main route bundle, importing strings from component files accidentally pulls entire component trees into the initial download:
```tsx title="plugins/home/src/pages/home-page.tsx"
import { definePluginRoute } from '@vitnode/core/routing'
// BAD: Pulls HomeRouteContent and all its heavy icons/charts into main entry
import { HOME_TITLE, HomeRouteContent } from '../views/home-content' // [!code --]
// GOOD: Metadata strings live in a lightweight leaf file
import { HomeRouteContent } from '../views/home-content' // [!code ++]
import { HOME_DESCRIPTION, HOME_TITLE } from '../views/metadata' // [!code ++]
export const route = definePluginRoute({
head: () =>
pageHead({
title: HOME_TITLE,
description: HOME_DESCRIPTION,
}),
})
export default HomeRouteContent
```
### 2. Lazy Dialogs and Heavy Form Editors [#2-lazy-dialogs-and-heavy-form-editors]
Heavy editors (like Tiptap) or complex modals should be lazy-loaded with `React.lazy` and `Suspense`:
```tsx title="plugins/blog/src/views/admin/article-editor.tsx"
import React, { Suspense } from 'react'
import { Loader } from '@vitnode/core/components/ui/loader'
// [!code ++:6]
const RichEditor = React.lazy(async () =>
import('@vitnode/core/components/form/fields/editor').then((mod) => ({
default: mod.AutoFormEditor,
})),
)
export const ArticleEditor = (props) => (
}>
)
```
### 3. Dynamic Imports Inside Loaders [#3-dynamic-imports-inside-loaders]
When a route loader requires a heavy calculation or parsing library, import it dynamically:
```tsx title="plugins/stats/src/pages/stats-page.tsx"
import { definePluginRoute } from '@vitnode/core/routing'
export const route = definePluginRoute({
load: async () => {
// Only downloaded when visitor navigates to /stats
const { calculateStats } = await import('../features/stats/calculator') // [!code ++]
return calculateStats()
},
})
```
### 4. Lazy Images Below the Fold [#4-lazy-images-below-the-fold]
Always specify explicit aspect ratios and `loading="lazy"` on images outside the initial viewport, and ship WebP with a `srcSet` so a phone never downloads the desktop rendition:
```tsx
```
The one image that is the page's largest element on arrival - a hero screenshot - is the exception: give it `loading="eager"` and `fetchPriority="high"` so the browser asks for it first.
### 5. Skip Rendering Work for Offscreen Sections [#5-skip-rendering-work-for-offscreen-sections]
A long marketing page can carry a hundred CSS animations, most of them below the fold. Give each section `content-visibility: auto` with a `contain-intrinsic-size` estimate and the browser skips style, layout and animation work for everything not near the viewport:
```css title="apps/web/src/site/marketing/marketing.css"
.mk-section-anchor {
scroll-margin-top: 5rem;
content-visibility: auto;
contain-intrinsic-size: auto 48rem;
}
```
The home page went from 133 running animations to 42 on arrival with this one rule, and the pages still scroll and deep-link normally.
### 6. Keep the Plugin Factory Light [#6-keep-the-plugin-factory-light]
`vitnode.config.ts` is bundled with the document shell, so everything a plugin factory imports ships to every visitor. A factory that spreads `admin/content` or `admin/nav` drags the AdminCP editing stack - AutoForm, schemas, dialogs - into the public bundle. Keep it to `pluginId`, `messages` and `routes`; see [Plugin frontend modules](/docs/dev/content-engine/plugin-registration).
### 7. Route-Scoped Stylesheets [#7-route-scoped-stylesheets]
Tailwind emits one utility per class it finds in the sources you list. A documentation UI kit scanned globally pays its CSS on the home page too. Give it a stylesheet of its own and load it from the layout route that renders it:
```css title="apps/web/src/docs/docs.css"
@layer theme, base, components, utilities;
@import 'tailwindcss/theme.css';
@import '../theme.css' theme(reference);
@import 'tailwindcss/utilities.css' layer(utilities) source(none);
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
@source '../../node_modules/fumadocs-ui/dist/**/*.js';
```
```tsx title="apps/web/src/routes/_docs.tsx"
import docsCss from '#/docs/docs.css?url'
export const Route = createFileRoute('/_docs')({
loader: async () => ({ pageTree: await getDocsPageTree() }),
head: () => ({ links: [{ href: docsCss, rel: 'stylesheet' }] }),
// ...
})
```
`theme(reference)` makes the app's design tokens available to the second stylesheet without emitting them twice, and `source(none)` stops it from scanning the whole app again.
### 8. Group Shared Vendor Modules [#8-group-shared-vendor-modules]
Rolldown splits every module shared by two routes into its own file, which for an icon library means dozens of 300-byte requests per page. Group the libraries that are shared by design into one chunk each:
```ts title="apps/web/vite.config.ts"
build: {
rolldownOptions: {
output: {
advancedChunks: {
groups: [
{ name: 'icons', test: /node_modules[\\/].*lucide-react/, minShareCount: 2 },
],
},
},
},
},
```
Group only libraries whose shared surface is small. A UI primitive library shared by every AdminCP screen turns into a single 280 KB chunk that public pages then download for a tooltip.
***
## Measuring Bundle Size [#measuring-bundle-size]
Analyze your client bundle with Vite:
```bash
bun run build
```
```bash
pnpm build
```
```bash
npm run build
```
Inspect the output chunk sizes in `apps/web/.output/public/assets/`, then load a page in the browser's network panel and check what the document shell actually requests - a chunk the manifest never preloads can still arrive through a static import. Keep any single lazy chunk below **150 KB** for optimal mobile performance.
## Learn More [#learn-more]
# Dashboard Widgets
Dashboard widgets are **AdminCP plugin extensions**. Define the component and
register it in the same plugin factory that owns the feature; the host dashboard
then discovers it automatically.
{/* Image prompt: VitNode AdminCP dashboard with a “Site notes” statistics widget in a draggable grid. Show the widget drawer, resize affordance, and settings gear. Dark theme, 1600x900. */}
### Build the widget component [#build-the-widget-component]
```tsx title="plugins/site-notes/src/views/admin/widgets/stats-widget.tsx"
import type { AdminDashboardWidgetProps } from '@vitnode/core/lib/plugin'
export const StatsWidget = ({ settings }: AdminDashboardWidgetProps) => (
Published notes
{String(settings.total ?? 42)}
)
```
### Register it in the existing plugin factory [#register-it-in-the-existing-plugin-factory]
```tsx title="plugins/site-notes/src/config.tsx"
import { BarChart3Icon } from 'lucide-react'
import { StatsWidget } from './views/admin/widgets/stats-widget'
export const siteNotesPlugin = () =>
buildPlugin({
admin: {
dashboard: {
widgets: [
// [!code ++:9]
{
component: StatsWidget,
defaultEnabled: true,
defaultRows: 1,
defaultSpan: 1,
icon: ,
id: 'stats',
},
],
},
},
messages,
pluginId: '@acme/site-notes',
routes,
})
```
The full widget id is namespaced by the plugin, so `stats` will not collide with
another plugin’s idea of a stats card. A good thing—statistics are dramatic
enough already.
### Add a permission or settings UI when needed [#add-a-permission-or-settings-ui-when-needed]
Add `permission` to hide a widget from staff who should not see it. Add
`settingsComponent` when administrators need to save preferences; both stay in
the plugin alongside the widget.
```tsx title="plugins/site-notes/src/config.tsx"
{
component: StatsWidget,
// [!code ++:4]
permission: { module: 'site_notes', permission: 'can_view_stats' },
settingsComponent: StatsSettings,
id: 'stats',
}
```
## Widget options [#widget-options]
| Option | What it controls |
| ----------------------------- | ------------------------------------------------- |
| `defaultSpan` / `defaultRows` | Initial grid size from 1 to 3 columns or rows. |
| `minSpan` | Narrowest allowed width. |
| `defaultEnabled` | Whether a new dashboard receives the widget. |
| `allowMultiple` | Whether an admin may place more than one copy. |
| `permission` | Staff permission required to see the widget. |
| `settingsComponent` | Plugin form shown by the widget settings control. |
# AdminCP Pages
AdminCP is a plugin surface. Give the plugin an `area: 'admin'` route and a
browser-safe navigation declaration. The host supplies the panel shell; your
plugin supplies the useful bit.
### Claim an AdminCP route [#claim-an-admincp-route]
```ts title="plugins/site-notes/src/routes.ts"
import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
export const routes = definePluginRoutes([
// [!code ++:4]
page('/admin/site-notes/settings', {
area: 'admin',
component: lazy(() => import('./pages/admin-settings-page')),
}),
])
```
### Render the plugin page [#render-the-plugin-page]
```tsx title="plugins/site-notes/src/pages/admin-settings-page.tsx"
const AdminSettingsPage = () => (
Site notes settings
Manage the notes plugin.
)
export default AdminSettingsPage
```
### Add a sidebar entry [#add-a-sidebar-entry]
Export `adminNav` from the plugin. Its small browser-safe shape is what the
generated AdminCP registry imports—do not edit `admin-nav.gen.ts` yourself.
```tsx title="plugins/site-notes/src/admin/nav.tsx"
import type { AdminNavPluginSource } from '@vitnode/core/lib/plugin'
import { SettingsIcon } from 'lucide-react'
export const adminNav = {
pluginId: '@acme/site-notes',
admin: {
nav: [
// [!code ++:6]
{
href: '/admin/site-notes/settings',
icon: ,
id: 'settings',
permission: { module: 'site_notes', permission: 'can_manage_settings' },
},
],
},
} satisfies AdminNavPluginSource
```
The generator’s `./*` package export already exposes `admin/nav`; no manual
`package.json` export is required, and nothing changes in `config.tsx`. The
generated `admin-nav.gen.ts` imports `admin/nav` directly, and keeping the
factory to plain data is what keeps the sidebar registration out of every
public page's bundle.
### Verify the screen [#verify-the-screen]
```bash
bun dev
```
```bash
pnpm dev
```
```bash
npm run dev
```
Visit `http://localhost:3000/admin/site-notes/settings` as a staff account with
the declared permission.
{/* Image prompt: VitNode AdminCP settings page contributed by a plugin. Show the existing admin sidebar with “Site notes” selected, a compact settings screen, breadcrumb, and staff-permission badge. Dark theme, 1440x900. */}
## Extend the panel [#extend-the-panel]
# API Modules
Start with [a plugin](/docs/dev/plugins/create), not a host endpoint. A module
groups the plugin's Hono routes under one URL prefix and gives OpenAPI a tidy
place to describe them.
{/* Image prompt: Dark-theme API ownership diagram. A Site notes plugin contains a Hono route, notes module, and config.api file; the app API configuration composes the plugin once. Show resulting GET endpoint, 1600x900. */}
### Define one plugin endpoint [#define-one-plugin-endpoint]
```ts title="plugins/site-notes/src/api/modules/notes/list.route.ts"
import { z } from '@hono/zod-openapi'
import { buildRoute } from '@vitnode/core/api/lib/route'
export const listNotesRoute = buildRoute({
pluginId: '@acme/site-notes',
route: {
method: 'get',
path: '/',
responses: {
// [!code ++:7]
200: {
content: {
'application/json': {
schema: z.object({ notes: z.array(z.string()) }),
},
},
description: 'Published site notes.',
},
},
},
handler: (c) => c.json({ notes: ['Hello plugin'] }),
})
```
### Group it and export the plugin API [#group-it-and-export-the-plugin-api]
```ts title="plugins/site-notes/src/api/modules/notes/notes.module.ts"
import { buildModule } from '@vitnode/core/api/lib/module'
import { listNotesRoute } from './list.route'
export const notesModule = buildModule({
name: 'notes',
pluginId: '@acme/site-notes',
routes: [listNotesRoute], // [!code ++]
})
```
```ts title="plugins/site-notes/src/config.api.ts"
import { buildApiPlugin } from '@vitnode/core/api/lib/plugin'
import { notesModule } from './api/modules/notes/notes.module'
export const siteNotesApiPlugin = () =>
buildApiPlugin({
modules: [notesModule], // [!code ++]
pluginId: '@acme/site-notes',
})
```
### Compose it in the app API config [#compose-it-in-the-app-api-config]
The app decides which installed plugins are active. Add the factory to the Hono
config that serves your app (`apps/web` for a single app, or `apps/api` when it
is separate):
```ts title="apps/web/src/vitnode.api.config.ts"
import { siteNotesApiPlugin } from '@acme/site-notes/config.api' // [!code ++]
export const vitNodeApiConfig = buildApiConfig({
plugins: [siteNotesApiPlugin()], // [!code ++]
})
```
The endpoint is now `GET /api/@acme/site-notes/notes`. OpenAPI picks it up too;
one less hand-written map to maintain.
# API Routes
Create [the plugin](/docs/dev/plugins/create) and its [API module](/docs/dev/plugins/api/modules)
first. Then put the endpoint in that module so its validation, permission, and
OpenAPI record travel together.
### Validate input and response [#validate-input-and-response]
```ts title="plugins/site-notes/src/api/modules/notes/get.route.ts"
import { z } from '@hono/zod-openapi'
import { buildRoute } from '@vitnode/core/api/lib/route'
export const getNoteRoute = buildRoute({
pluginId: '@acme/site-notes',
route: {
method: 'get',
path: '/{id}',
request: {
params: z.object({ id: z.coerce.number().int().positive() }),
},
responses: {
// [!code ++:7]
200: {
content: {
'application/json': {
schema: z.object({ id: z.number(), title: z.string() }),
},
},
description: 'One site note.',
},
},
},
handler: (c) => {
const { id } = c.req.valid('param')
return c.json({ id, title: 'Plugin-owned note' })
},
})
```
### Gate an AdminCP action [#gate-an-admincp-action]
Add `adminStaffPermission` to an action that only staff should call. The API
still authorizes server-side; a hidden button is merely good manners.
```ts title="plugins/site-notes/src/api/modules/notes/publish.route.ts"
import { z } from '@hono/zod-openapi'
import { buildRoute } from '@vitnode/core/api/lib/route'
export const publishNoteRoute = buildRoute({
// [!code ++:13]
adminStaffPermission: {
module: 'site_notes',
permission: 'can_publish',
},
pluginId: '@acme/site-notes',
route: {
method: 'post',
path: '/{id}/publish',
responses: {
200: {
content: {
'application/json': {
schema: z.object({ published: z.literal(true) }),
},
},
description: 'The note was published.',
},
},
},
handler: async (c) => c.json({ published: true }),
})
```
{/* Image prompt: Dark-theme API documentation screenshot. Show an OpenAPI endpoint for a plugin route with path parameter validation, a successful JSON response, and a staff-permission lock badge. 1440x900. */}
Keep handlers with the feature that owns the data. The host API config only
composes plugins; it should not become a surprise sequel to your business
logic.
# Breadcrumbs
VitNode renders a breadcrumb trail in both the AdminCP header and the public
site layout. **Every matched route contributes one crumb**, parent to child:
```text
Home / Catalog / Products / Laptops / MacBook Pro
```
So a route says what it is called and nothing else. VitNode owns the separators,
the `nav` and `aria-current` semantics, and the locale-aware link to each
route's own URL—a plugin never builds a router link, and never restates the
crumbs of the layouts above it.
## Quick start [#quick-start]
### A static, translated crumb [#a-static-translated-crumb]
Declare a component on `definePluginRoute`. It renders inside the message
namespaces the route declared, so `useTranslations` just works:
```tsx title="plugins/catalog/src/pages/products-layout.tsx"
import { definePluginRoute } from '@vitnode/core/routing'
import { useTranslations } from 'use-intl'
function ProductsBreadcrumb() {
const t = useTranslations('@acme/catalog')
return t('breadcrumbs.products')
}
// [!code ++:3]
export const route = definePluginRoute({
breadcrumb: ProductsBreadcrumb,
})
```
### A crumb read from the loader [#a-crumb-read-from-the-loader]
The crumb is handed **its own** match's data, so a dynamic route can name itself
with what it fetched—no second request, and no guessing from the URL:
```tsx title="plugins/catalog/src/pages/category-layout.tsx"
import type { PluginRouteBreadcrumbProps } from '@vitnode/core/routing'
import { definePluginRoute } from '@vitnode/core/routing'
interface Category {
name: string
}
function CategoryBreadcrumb({
loaderData,
}: PluginRouteBreadcrumbProps) {
return loaderData.name
}
export const route = definePluginRoute({
load: async ({ params }) => await fetchCategory(params.categorySlug),
breadcrumb: CategoryBreadcrumb,
})
```
`PluginRouteBreadcrumbProps` carries `loaderData`, `params` and
`search`—the same three names the loader, `head` and the page component receive.
### Leaving a route out [#leaving-a-route-out]
A route that declares no `breadcrumb` contributes nothing, and its parents' crumbs
stay exactly where they were. `false` says the same thing on purpose, which is
worth doing when a page's frame already names the screen:
```tsx title="plugins/catalog/src/pages/products-index-page.tsx"
export const route = definePluginRoute({
breadcrumb: false, // [!code ++]
})
```
## Rules [#rules]
| Declaration | What the trail does |
| ----------------- | ---------------------------------------------------- |
| A component | One crumb, given this route's loader data and params |
| `false` | This route is left out; its parents' crumbs remain |
| Nothing at all | The same, said by omission |
| The last crumb | Rendered as the current page, not as a link |
| Every other crumb | A locale-aware link to that route's own URL |
An AdminCP screen's trail is named by the sidebar this administrator can
actually see, so a plugin that adds a nav entry gets its label for free—in
every language. Keep the route, the sidebar entry and the translations in the
same package.
A crumb returns text or an element. Do not render a ``, a
separator, or a link: the shell draws the trail *above* the page outlet and
needs each crumb as one item so it can put them in one navigation landmark.
## Learn More [#learn-more]
# Create a Plugin
A plugin is the starting point for a VitNode feature. It keeps routes, API
modules, data, translations, and AdminCP extensions together in one installable
package. Nice boundaries; fewer archaeological digs later.
Run the generator from a repository with `turbo.json`. When creating an app,
turn on Turborepo first; a plain single-folder app has nowhere for
`plugins/*`.
### Generate the package [#generate-the-package]
Run this at the workspace root and enter a package name such as
`@acme/site-notes` when prompted:
```bash
bun create vitnode-app@canary --plugin
```
```bash
pnpm create vitnode-app@canary --plugin
```
```bash
npm create vitnode-app@canary -- --plugin
```
The CLI creates `plugins/site-notes`, adds it as a workspace dependency, and
gives it a route, locale, and config skeleton. It does **not** enable the
feature for the host—that explicit switch is next.
### Keep the route in the plugin [#keep-the-route-in-the-plugin]
The generated `routes.ts` is the public contract. Add another `page()` here when
the plugin needs another URL; never copy its page into `apps/web/src/routes`.
```ts title="plugins/site-notes/src/routes.ts"
import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
export const routes = definePluginRoutes([
// [!code ++:3]
page('/site-notes', {
component: lazy(() => import('./pages/home-page')),
}),
])
```
### Register the plugin with the host [#register-the-plugin-with-the-host]
Import the plugin factory in the host config and add it to `plugins`:
```ts title="apps/web/src/vitnode.config.ts"
import { siteNotesPlugin } from '@acme/site-notes/config' // [!code ++]
import { buildConfig } from '@vitnode/core/vitnode.config'
export const vitNodeConfig = buildConfig({
plugins: [
siteNotesPlugin(), // [!code ++]
],
})
```
That is the only composition step - the factory carries the plugin's routes,
content types and AdminCP navigation, and the feature stays in its package. Your
build reads this list and generates one literal import per plugin for each of
those, so a page or an editing screen loads with the route that needs it rather
than with the config. See [Configuration](/docs/dev/configuration).
Its translations need one more line, in `src/locales/packages.ts` - see
[Languages & Localization](/docs/dev/i18n).
### Run it and visit the route [#run-it-and-visit-the-route]
```bash
bun dev
```
```bash
pnpm dev
```
```bash
npm run dev
```
Open `http://localhost:3000/site-notes`. The page comes from the plugin, gets
its own chunk, and never moves house. Tiny victory dance optional.
{/* Image prompt: Split-screen developer tutorial image. Left shows a plugin folder with routes.ts, locale, and pages files. Right shows the resulting /site-notes page in a VitNode app. Dark theme, precise code-like labels, 1600x900. */}
## Add the next capability [#add-the-next-capability]
# Plugin Routes
Start by [creating a plugin](/docs/dev/plugins/create). A plugin's `src/routes.ts`
is its promise to the app: which URLs it owns, and which module renders each one.
The host turns that promise into lazy TanStack Start routes—no copied page files,
no drama.
{/* Image prompt: Dark-theme developer diagram: a plugin routes.ts tree (layout → index → dynamic page) on the left, each node pointing at a lazily loaded page chunk on the right, then into a TanStack Start route inside the app shell. Emphasize “plugin owns feature”, “one chunk per page”, “host composes”. Clean labels, 1600x900. */}
### Declare the URL in the plugin [#declare-the-url-in-the-plugin]
```ts title="plugins/site-notes/src/routes.ts"
import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
export const routes = definePluginRoutes([
// [!code ++:3]
page('/notes/:slug', {
component: lazy(() => import('./pages/note-page')),
}),
])
```
Use `:slug` for dynamic segments. VitNode converts it to TanStack Start's
internal `$slug` spelling while keeping your plugin portable.
### Keep behavior beside the page [#keep-behavior-beside-the-page]
```tsx title="plugins/site-notes/src/pages/note-page.tsx"
import type { PluginRoutePageProps } from '@vitnode/core/routing'
import { definePluginRoute } from '@vitnode/core/routing'
interface Note {
title: string
}
// [!code ++:8]
export const route = definePluginRoute({
load: async ({ params }) => ({ title: `Note: ${params.slug}` }),
head: ({ loaderData }) => ({
description: 'A note delivered by the Site notes plugin.',
title: loaderData?.title,
}),
})
const NotePage = ({ loaderData }: PluginRoutePageProps) => (
{loaderData.title}
)
export default NotePage
```
### Run the plugin route [#run-the-plugin-route]
```bash
bun dev
```
```bash
pnpm dev
```
```bash
npm run dev
```
Visit `http://localhost:3000/notes/hello`. The page's code, data, and SEO stay
with the feature that needs them. A surprisingly polite route.
## What `lazy(() => import('./pages/note-page'))` means [#what-lazy--importpagesnote-page-means]
It names the module VitNode loads **when the route is needed**—on a navigation,
or a moment earlier when the visitor hovers a link and the router preloads it.
Nothing about that import runs while your app boots. `lazy` stores the callback;
Vite reads the literal `import()` inside it at build time and Rollup gives that
page a chunk of its own. So `routes.ts` stays a few lines of data the app can
hold cheaply, and a visitor downloads a page only if they open it.
Importing the component at the top of `routes.ts` would put it in the initial
bundle of *every* page on the site, and route-level splitting would be gone.
VitNode refuses it in the types and again at build time, with the replacement
in the message:
```ts
import NotePage from './pages/note-page'
page('/notes/:slug', {
component: NotePage, // [!code --]
component: lazy(() => import('./pages/note-page')), // [!code ++]
})
```
Keep the `import()` literal. A specifier built from a variable is not something
a bundler can follow, so the page never gets a chunk and the build cannot tell
you the module is missing:
```ts
page('/notes/:slug', {
component: lazy(() => import(`./pages/${slug}-page`)), // [!code --]
component: lazy(() => import('./pages/note-page')), // [!code ++]
})
```
## Nest routes with `layout()` and `index()` [#nest-routes-with-layout-and-index]
A `layout()` renders a frame around its `children` and claims no URL of its own.
`index()` is the route that renders at the layout's own URL. Every path inside a
layout is **relative** to it, so moving a subtree is one edit:
```ts title="plugins/catalog/src/routes.ts"
import {
definePluginRoutes,
index,
layout,
lazy,
page,
} from '@vitnode/core/routing'
export const routes = definePluginRoutes([
layout('/catalog', {
component: lazy(() => import('./pages/catalog-layout')),
messages: ['@acme/catalog'],
children: [
page('dashboard', {
component: lazy(() => import('./pages/dashboard-page')),
}),
layout('products', {
component: lazy(() => import('./pages/products-layout')),
children: [
index({
component: lazy(() => import('./pages/products-index-page')),
}),
layout(':categorySlug', {
component: lazy(() => import('./pages/category-layout')),
children: [
index({
component: lazy(() => import('./pages/category-index-page')),
}),
page(':productId', {
component: lazy(() => import('./pages/product-page')),
}),
],
}),
],
}),
],
}),
])
```
That tree serves `/catalog/dashboard`, `/catalog/products`,
`/catalog/products/laptops` and `/catalog/products/laptops/42`, and a page opens
inside every frame above it.
| Rule | What VitNode does |
| ------------------------- | -------------------------------------------------------- |
| Top-level path | Absolute: `page('/catalog', …)` |
| Nested path | Relative: `page('dashboard', …)` joins onto its parent |
| `index()` | The child at the layout's exact URL—no path of its own |
| Layout with no `children` | A build error: nothing could ever render it |
| Route ids | Derived by VitNode while flattening. You never write one |
A layout's frame is a component with `children`:
```tsx title="plugins/catalog/src/pages/catalog-layout.tsx"
const CatalogLayout = ({ children }: { children: React.ReactNode }) => (
Catalog
{children}
)
export default CatalogLayout
```
`children`, not an ` `: a plugin layout that imported a router's outlet
could only be installed into one kind of app.
## Choose the route shape [#choose-the-route-shape]
| Need | Add to the tree |
| ------------------- | --------------------------------------------------------- |
| Public feature page | `page('/notes', { component })`—`area` defaults to `main` |
| Staff screen | `area: 'admin'` and a full path such as `/admin/notes` |
| Signed-in visitor | `requires: 'authenticated'` |
| Shared frame | `layout()` with `children` |
| Translated strings | `messages: ['@acme/catalog']` |
| URL-as-state | `search: productsSearchSchema` |
## An AdminCP route [#an-admincp-route]
`area: 'admin'` picks the shell—the sidebar, the breadcrumb area, the command
palette, and the admin session guard. It never changes the path, so write the
`/admin/…` URL in full:
```ts title="plugins/site-notes/src/routes.ts"
page('/admin/notes', {
area: 'admin', // [!code ++]
component: lazy(() => import('./pages/admin-notes-page')),
messages: ['@acme/site-notes.admin'],
})
```
`area` belongs to top-level routes only. Everything inside a layout renders in
the shell that layout renders in, and `requires` is refused in the admin
area—the AdminCP has its own session, and a staff permission gates the page's
*content*. See [AdminCP pages](/docs/dev/plugins/admin).
## Route messages [#route-messages]
`messages` lists the translation namespaces the route renders. VitNode warms
them **alongside** the page's chunk instead of after it, which is the whole
reason they are declared on the route rather than inside the module:
```ts
layout('/catalog', {
component: lazy(() => import('./pages/catalog-layout')),
messages: ['@acme/catalog'], // [!code ++]
children: [index({ component: lazy(() => import('./pages/index-page')) })],
})
```
A route inherits every namespace its layouts declare, so naming them once on the
frame is enough for the whole subtree. Inside the module, read them with
`use-intl`:
```tsx
import { useTranslations } from 'use-intl'
const CatalogIndexPage = () => {
const t = useTranslations('@acme/catalog')
return {t('index.intro')}
}
```
See [namespaces](/docs/dev/i18n/namespaces) for how a namespace is named and
where its JSON lives.
## `search` is the one eager field [#search-is-the-one-eager-field]
TanStack Router validates a URL's query string **while it matches the URL**,
before any chunk is fetched. A schema inside the lazy page module would arrive
too late, so a route declares it in `routes.ts`:
```ts title="plugins/catalog/src/routes.ts"
import { productsSearchSchema } from './pages/products-search'
page('/catalog/products', {
component: lazy(() => import('./pages/products-page')),
search: productsSearchSchema, // [!code ++]
})
```
```ts title="plugins/catalog/src/pages/products-search.ts"
export interface ProductsSearch {
page: number
}
export const productsSearchSchema = (
input: Record,
): ProductsSearch => {
const parsed = Number.parseInt(String(input.page ?? ''), 10)
// Total, never throwing: the router calls this on whatever somebody pasted.
return { page: Number.isFinite(parsed) ? Math.max(parsed, 1) : 1 }
}
```
The page then gets a typed `search` and a `navigate` that changes it:
```tsx title="plugins/catalog/src/pages/products-page.tsx"
import type { PluginRoutePageProps } from '@vitnode/core/routing'
import type { ProductsSearch } from './products-search'
const ProductsPage = ({
navigate,
search,
}: PluginRoutePageProps) => (
void navigate({ search: { page: search.page + 1 } })}
type="button"
>
Page {search.page}
)
export default ProductsPage
```
TypeScript checks the two halves against each other: the schema has to return
what the page says it reads, even though the page itself is lazy.
`search` is a function, so it lives in `routes.ts`—which the app imports
statically. Everything that file imports is in the initial bundle with it, so
keep the schema module small: no React, no component, no import of the page it
belongs to.
Declare it only for a screen whose URL *is* its state—a paginated list whose
`?page=999` has to be clamped, a filter whose links must be typed. For a page
that merely reads a parameter, use the module's own lazy `parseSearch`
instead; it normalises in the loader and adds nothing to the initial bundle.
## Dynamic breadcrumbs [#dynamic-breadcrumbs]
Every matched route contributes **one crumb**, parent to child, and VitNode owns
the separators, the accessibility semantics, and the locale-aware links. A crumb
returns a label:
```tsx title="plugins/catalog/src/pages/product-page.tsx"
import type {
PluginRouteBreadcrumbProps,
PluginRoutePageProps,
} from '@vitnode/core/routing'
import { definePluginRoute } from '@vitnode/core/routing'
interface Product {
description: string
name: string
}
function ProductBreadcrumb({ loaderData }: PluginRouteBreadcrumbProps) {
return loaderData.name
}
export const route = definePluginRoute({
load: async ({ params }) =>
await fetchProduct({
categorySlug: params.categorySlug,
productId: params.productId,
}),
head: ({ loaderData }) => ({
description: loaderData?.description,
title: loaderData?.name,
}),
breadcrumb: ProductBreadcrumb,
})
export default function ProductPage({
loaderData,
}: PluginRoutePageProps) {
return (
{loaderData.name}
{loaderData.description}
)
}
```
With the catalog tree above, that renders `Catalog / Products / Laptops /
MacBook Pro`—each crumb from the route that owns it. See
[breadcrumbs](/docs/dev/plugins/breadcrumbs) for static crumbs, `breadcrumb:
false`, and how the trail is assembled.
Use host routes only for shells, docs, or site-wide infrastructure. A product
page belongs in its plugin, even when it starts life as one brave little URL.
## How the app picks this up [#how-the-app-picks-this-up]
The `vitnode:plugin-routes` Vite plugin reads the plugins in
`src/vitnode.config.ts`, imports each one's `routes` module in Node, validates
and flattens every tree, refuses two routes that claim one URL—including one of
the app's own—and writes a single `src/plugin-routes.gen.ts`:
```ts title="apps/web/src/plugin-routes.gen.ts"
import { routes as pluginRoutes0 } from '@acme/catalog/routes'
export const pluginRouteSources = [
{ pluginId: '@acme/catalog', routes: pluginRoutes0 },
] as const satisfies readonly PluginRouteDeclarationSource[]
```
That is the only generated file, it names no page module, and it is committed
like any other generated artefact. Your pages stay in your package's own
`dist`, one chunk each.
# Routing
VitNode uses TanStack Start, but feature routes begin in a plugin. A plugin's
`routes.ts` is a small tree of declarations - a path, and the module that renders
it - so the package owns the page without copying files into
`apps/web/src/routes`.
| Put it in | Use it for | Default |
| ---------------- | --------------------------------------------------------------- | -------- |
| **Plugin route** | Product pages, feature flows, content delivery, AdminCP screens | Yes |
| **Host route** | Shell, framework wiring, docs, or a truly site-wide integration | Rare |
| **Core route** | Login, AdminCP frame, search, and other VitNode system screens | Built in |
### Declare the plugin URL [#declare-the-plugin-url]
```ts title="plugins/site-notes/src/routes.ts"
import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
export const routes = definePluginRoutes([
// [!code ++:3]
page('/notes', {
component: lazy(() => import('./pages/notes-page')),
}),
])
```
Use `:slug` for a dynamic segment, such as `page('/notes/:slug', …)`. VitNode
maps it to TanStack Start’s internal `$slug` spelling for you.
`lazy` names the page module without importing it: the literal `import()` is
what Vite follows to give the page a chunk of its own, and it does not run until
somebody opens the route.
### Add the page module [#add-the-page-module]
```tsx title="plugins/site-notes/src/pages/notes-page.tsx"
const NotesPage = () => (
Site notes
A route that lives with its feature.
)
export default NotesPage
```
### Run it [#run-it]
```bash
bun dev
```
```bash
pnpm dev
```
```bash
npm run dev
```
Open `http://localhost:3000/notes`.
## Load data and set metadata [#load-data-and-set-metadata]
Export `route` from the same plugin module. Declare `load` before `head` so
TypeScript carries the inferred data into your metadata:
```tsx title="plugins/site-notes/src/pages/note-page.tsx"
import type { PluginRoutePageProps } from '@vitnode/core/routing'
import { definePluginRoute } from '@vitnode/core/routing'
interface Note {
body: string
title: string
}
// [!code ++:9]
export const route = definePluginRoute({
load: async ({ params }) => await fetchNote(params.slug),
head: ({ loaderData }) => ({
description: loaderData?.body.slice(0, 155),
title: loaderData?.title,
}),
})
const NotePage = ({ loaderData }: PluginRoutePageProps) => (
{loaderData.title}
{loaderData.body}
)
export default NotePage
```
Keep host routes for app-wide framing or infrastructure. If a route belongs to
a feature, make a plugin first—even when the feature starts small. Small
things have a habit of bringing friends.
## Continue from the route [#continue-from-the-route]
# Loading States
While a route's loader runs or its code chunk downloads, TanStack Router displays a pending component. VitNode provides pre-built skeleton layouts matching common UI patterns.
## Quick start [#quick-start]
### Use Suspense in a plugin component [#use-suspense-in-a-plugin-component]
Plugin routes load dynamically. Use React `Suspense` with VitNode's pending skeletons:
```tsx title="plugins/blog/src/pages/posts-page.tsx"
import { FeedPendingSkeleton } from '@vitnode/core/tanstack/pending'
import React, { Suspense } from 'react'
const PostsList = () => {
// Data loading component
return Posts List
}
const PostsPage = () => (
// [!code ++:3]
}>
)
export default PostsPage
```
## Pre-Built Pending Skeletons [#pre-built-pending-skeletons]
Import these shapes from `@vitnode/core/tanstack/pending`:
| Shape | Layout | Typical Use Case |
| :--------------------- | :---------------------------------- | :--------------------------------------- |
| `FeedPendingSkeleton` | Card timeline with avatars | Activity feeds, search results, articles |
| `TablePendingSkeleton` | Toolbar, table header, and rows | Data tables, AdminCP lists, files |
| `FormPendingSkeleton` | Card with inputs and button actions | Settings pages, edit dialogs |
| `CardsPendingSkeleton` | Responsive 1/2/3 column card grid | Dashboard overview, integrations |
| `AuthPendingSkeleton` | Centered authentication card | Sign in, registration, password reset |
| `RoutePendingSpinner` | Centered accessible spinner | Minimalistic or unconventional pages |
***
## Skeleton Props [#skeleton-props]
Standard skeleton shapes accept custom classes and row counts:
## Learn More [#learn-more]
# Metadata & SEO
VitNode plugin routes declare their own metadata through `head`. Keep it short,
specific, and useful enough that a search result does not sound like it was
written by a toaster.
## Quick start [#quick-start]
### 1. In a Plugin Route (Recommended) [#1-in-a-plugin-route-recommended]
Plugins declare metadata using `definePluginRoute`. Metadata can read dynamically from `loaderData`:
```tsx title="plugins/blog/src/pages/article-page.tsx"
import { definePluginRoute } from '@vitnode/core/routing'
export const route = definePluginRoute({
load: async ({ params }) => await fetchArticle(params.slug),
// [!code ++:6]
head: ({ loaderData }) => ({
title: loaderData?.title,
description: loaderData?.summary,
robots: 'index, follow',
}),
})
```
The browser tab automatically displays **Article Title - VitNode**.
## Plugin metadata fields [#plugin-metadata-fields]
| Field | Use it for |
| ------------- | ------------------------------------------------------------------------- |
| `title` | A specific, human-readable page title. |
| `description` | A concise search snippet that explains the page’s value. |
| `robots` | `index, follow` for public pages or `noindex, nofollow` for private ones. |
For canonical URLs, redirects, Open Graph fields, and XML sitemaps on Content
Engine records, configure [Content delivery and SEO](/docs/dev/content-engine/content-delivery-and-seo)
inside the plugin that owns those records.
## Learn More [#learn-more]
# Navigation
Inside the app, use TanStack Router's `Link`. It builds the href, applies the
locale prefix, and preloads the destination on hover. You write the logical
path; the router writes the language.
## Example [#example]
```tsx
import { Link } from '@tanstack/react-router'
const DiscoverLink = () => Discover
```
That renders `/discover` for an English reader and `/pl/discover` for a Polish
one. Same component, same `to`, no branch and no `useLocale()` call.
## One route, two URLs [#one-route-two-urls]
`/discover` and `/pl/discover` are **one route**. The router's `rewrite` is what
makes that work, and it runs in both directions:
| Stage | Value |
| --------------------------------------------- | ---------------- |
| The address bar | `/pl/discover` |
| What the route tree matches (`rewrite.input`) | `/discover` |
| What you write | `to="/discover"` |
| What React renders (`rewrite.output`) | `/pl/discover` |
`input` is why no route file anywhere in the app mentions a locale - there is no
`routes/pl/` directory and there is not going to be one. `output` reads the
locale off the router's own current location rather than off `window`, so the
href rendered during SSR is byte-identical to the one rendered after hydration.
So write the logical path. Two things that look reasonable and are not:
* **Building the prefix yourself.** ``to={`/${locale}/discover`}`` does not
double up, because `localizeUrl` de-localizes before it prefixes and is
therefore idempotent. What it does instead is discard the prefix you wrote and
re-apply the reader's *current* locale, so an English reader following your
`/pl/discover` link lands on `/discover`. Not honoured; overwritten.
* **Naming a prefixed route.** `to="/pl/discover"` is not a path in the route
tree, so the router's typed `to` refuses it.
Both sit outside the localized URL space, so nothing is stripped from them and
nothing is added. `/pl/admin/core` is therefore a mistake rather than a Polish
page, and the app's request middleware 308-redirects it to `/admin/core` while
storing `pl` in the language cookie - so the AdminCP still renders in the
language the visitor just asked for. The same middleware canonicalises
`/en/discover` to `/discover`, because the default locale is unprefixed and
two indexable URLs for one page is one too many.
Only a prefix the app would itself emit gets stripped, so `/xx/discover`
reaches the route tree intact and matches nothing. That is deliberate: a
silent fallback would serve the same page at infinitely many URLs.
## Update query state from a plugin page [#update-query-state-from-a-plugin-page]
Plugin route props expose a narrow `navigate` function for filters, sorting, and
pagination on the page already being viewed. It keeps the plugin independent of
the host router’s entire route tree.
```tsx title="plugins/catalog/src/pages/catalog-page.tsx"
import type { PluginRoutePageProps } from '@vitnode/core/routing'
interface CatalogSearch {
page: number
}
const CatalogPage = ({
navigate,
search,
}: PluginRoutePageProps) => (
void navigate({
resetScroll: false,
search: { page: search.page + 1 }, // [!code ++]
})
}
type="button"
>
Next page
)
export default CatalogPage
```
## Plugin route modules [#plugin-route-modules]
A plugin route should not import the host router. Use its `navigate` prop for
same-page filters, sort order, and pagination. For links to another internal
screen, let the host render a link component; a plain `` is for another
origin only.
`navigate` only changes this plugin page's query string. That small boundary
is intentional—and saves future hosts from router spaghetti.
## Next [#next]
# 404 Not Found
In TanStack Start, 404 handling is configured as a route option via `notFoundComponent` rather than a static file. VitNode provides localized 404 layouts with history-aware navigation buttons out of the box — and mounts the one your visitors actually hit for you.
{/* Image prompt: VitNode 404 error page showing the site header above large 404 typography, localized "Page Not Found" title, descriptive message, and "Go back" / "Back to home" action buttons. Dark theme, 1440x900. */}
## Mistyped URLs get the full site [#mistyped-urls-get-the-full-site]
A URL no route claims is answered by a catch-all that `withCoreMainRoutes` mounts **inside your main shell**. So a visitor who mistypes a link gets the site header, the same `` landmark every page renders in, and a way back — plus a real `404` status for the crawlers, because the route answers `notFound()` from `beforeLoad` rather than quietly rendering a `200`.
You do not wire this up. It arrives with the mount your `router.tsx` already has:
```tsx title="apps/web/src/router.tsx"
const routeTree = withCoreMainRoutes(fileRouteTree, {
localeRouting,
mountUnder: mainShellRoute,
pageHead,
})
```
Why a route and not a `notFoundComponent` on `_main`? Because a pathless shell
is only in the match branch when something below it matched. When *nothing*
matches, the router hands back the root route on its own — so a boundary on
the shell would never run, and the 404 would be the one screen on your site
without a header.
## Root 404 Handler [#root-404-handler]
Your `apps/web/src/routes/__root.tsx` still defines a boundary, and it is the
last resort: a `notFound()` thrown where no closer route declared one.
```tsx title="apps/web/src/routes/__root.tsx"
import { createRootRouteWithContext } from '@tanstack/react-router'
import { ErrorActions, NotFound } from '@vitnode/core/tanstack/layout'
export const Route = createRootRouteWithContext()({
// [!code ++:4]
notFoundComponent: () => } />,
component: RootComponent,
})
```
`NotFound` automatically renders translated text from `core.global.errors.404`. `ErrorActions` renders **Go Back** (`history.back()`) and **Back to Home** (`/`) buttons.
***
## Triggering 404 in Loaders [#triggering-404-in-loaders]
When a requested resource (like an article slug or user ID) is not found in the database, throw `notFound()` inside the loader:
```tsx title="plugins/blog/src/pages/article-page.tsx"
import { notFound } from '@tanstack/react-router'
import { definePluginRoute } from '@vitnode/core/routing'
export const route = definePluginRoute({
load: async ({ params }) => {
const post = await fetchPost(params.slug)
// [!code ++:3]
if (!post) {
throw notFound()
}
return post
},
component: PostPage,
})
```
***
## Keep the fallback in the host [#keep-the-fallback-in-the-host]
The root 404 boundary is host infrastructure, so configure it once. Feature
routes should throw `notFound()` from their plugin loader and let that shared,
localized fallback do its work.
## Learn More [#learn-more]
# Elasticsearch
Use Elasticsearch when your site-wide search needs fuzzy matching, custom
ranking, or a search cluster separate from Postgres. VitNode keeps Postgres as
the canonical index; this adapter mirrors it, so switching is pleasantly boring.
### Install the adapter [#install-the-adapter]
Run this in the app or API workspace that owns `vitnode.api.config.ts`.
```bash
bun add @vitnode/elasticsearch@canary
```
```bash
pnpm add @vitnode/elasticsearch@canary
```
```bash
npm install @vitnode/elasticsearch@canary
```
### Add the cluster URL and adapter [#add-the-cluster-url-and-adapter]
Set `ELASTICSEARCH_NODE` to your Elasticsearch or OpenSearch endpoint, then
register the adapter in the API config.
```bash title=".env"
ELASTICSEARCH_NODE=http://localhost:9200
```
```ts title="apps/api/src/vitnode.api.config.ts"
import { ElasticsearchSearchAdapter } from '@vitnode/elasticsearch' // [!code ++]
export const vitNodeApiConfig = buildApiConfig({
// [!code ++:5]
search: {
adapter: ElasticsearchSearchAdapter({
node: process.env.ELASTICSEARCH_NODE,
index: 'vitnode',
}),
},
plugins: [blogApiPlugin()],
})
```
For Elastic Cloud, use `cloudId` and `apiKey` instead of `node`. The adapter
also accepts `username` and `password` for a self-hosted secured cluster.
### Rebuild the mirror [#rebuild-the-mirror]
Restart the API, then open **Core → Advanced → Search** in the AdminCP and run
**Rebuild index**. New changes are mirrored automatically; the rebuild fills
the historical records already in Postgres.
{/* Image prompt: VitNode AdminCP search page showing Elasticsearch as connected, one “Rebuild index” button, a progress state, and collection counts. Dark theme, 1440x900. */}
Do not delete `core_search_index`. It remains VitNode's source of truth and
lets you switch providers without losing your map back home.
## Next [#next]
# Search & Discovery
VitNode includes a unified site-wide search and discovery engine. Searchable records across all plugins are projected into the `core_search_index` table, powering `/search` and `/discover`.
This guide covers **site-wide search**. For search boxes on individual tables,
see [Search](/docs/dev/database/search).
## Quick start [#quick-start]
### Index when a record changes [#index-when-a-record-changes]
Index or update an item from any Hono route handler via `c.get("search")`:
```ts
// [!code ++:10]
await c.get('search').index({
itemType: 'article',
itemId: article.id,
title: article.title,
content: article.content, // HTML automatically stripped to plain text
url: `/articles/${article.slug}`,
authorId: article.authorId,
createdAt: article.createdAt,
})
```
### Delete removed records [#delete-removed-records]
When an item is deleted, remove it from the index:
```ts
await c.get('search').delete('article', article.id)
```
### Register a rebuild indexer [#register-a-rebuild-indexer]
To allow admins to re-index all historical content from the AdminCP, register a search indexer in your plugin's `config.api.ts`:
```ts title="plugins/blog/src/api/indexers/post.indexer.ts"
import { buildSearchIndexer } from '@vitnode/core/api/lib/search'
import { blog_posts } from '@/database/posts'
export const postSearchIndexer = buildSearchIndexer({
itemType: 'article',
totalCount: async (c) => {
return await c.get('db').$count(blog_posts)
},
batch: async (c, { limit, offset }) => {
const posts = await c
.get('db')
.select()
.from(blog_posts)
.limit(limit)
.offset(offset)
return posts.map((post) => ({
itemType: 'article',
itemId: post.id,
title: post.title,
content: post.content,
url: `/articles/${post.slug}`,
authorId: post.authorId,
createdAt: post.createdAt,
}))
},
})
```
Register it in `config.api.ts`:
```ts title="plugins/blog/src/config.api.ts"
export const blogApiPlugin = () =>
buildApiPlugin({
pluginId: 'blog',
searchIndexers: [postSearchIndexer], // [!code ++]
})
```
## Pluggable Search Engines [#pluggable-search-engines]
VitNode supports two search engines:
1. **PostgreSQL Full-Text Search (Default)**:
* Uses native `tsvector` + GIN indexes with weighted ranking (`title` weighted above `content`).
* Zero additional infrastructure required.
2. **Elasticsearch (`@vitnode/elasticsearch`)**:
* Offloads indexing and search to an Elasticsearch or OpenSearch cluster.
* Unlocks fuzzy matching, phrase boosts, and decay scoring.
For setup, credentials, configuration, and the first rebuild, follow the
[Elasticsearch tutorial](/docs/dev/search-elasticsearch).
***
## AdminCP Search Management [#admincp-search-management]
{/* Image prompt: VitNode AdminCP Search settings screen at /admin/core/advanced/search. Dashboard displaying search index statistics, engine status (PostgreSQL / Elasticsearch), registered item types with indexed document counts, and a "Rebuild Index" button. Dark theme, 1440x900. */}
Manage search status at **Core → Advanced → Search** (`/admin/core/advanced/search`):
* View indexed record counts across all collections.
* Trigger background rebuilds of specific collections or the entire site.
***
## Search Document Reference [#search-document-reference]
## Learn More [#learn-more]
# Server Functions & Isomorphic Fetching
VitNode keeps backend business logic in Hono API routes. The frontend's only job
is to call them - and for that, one universal fetcher covers SSR and browser
navigation at once.
## Decision Matrix [#decision-matrix]
| Goal | Recommended tool | Rationale |
| :----------------------------- | :---------------------------------------------- | :-------------------------------------------------------------------------------------------- |
| **Route data fetching** | `fetcher` from `@vitnode/core/tanstack/fetcher` | One call site. SSR forwards the request; the browser calls `/api/*` directly. |
| **API endpoints & mutations** | Hono API routes | Enforces staff permissions, validation schemas, and database transactions. |
| **Cookie minting on the host** | `createServerFn` + the server fetcher | Only code inside the host request can set response headers directly. |
| **Plugin server code** | Hono API modules | **Plugins must never declare `createServerFn`** (uncompiled handlers resolve to `undefined`). |
A plugin package may declare `createIsomorphicFn`, but never `createServerFn`.
Server functions belong exclusively to the host application.
***
## Fetching does not need `createIsomorphicFn` [#fetching-does-not-need-createisomorphicfn]
TanStack Router loaders run on the server for the first paint and in the browser
for every navigation after it. That used to mean writing both halves by hand:
```ts
// [!code --:3]
export const fetchDevices = createIsomorphicFn()
.server(fetchDevicesOnServer)
.client(fetchDevicesInBrowser)
```
The universal `fetcher` already is that boundary, so a feature writes the fetch
once:
```ts title="plugins/devices/src/lib/fetch-devices.ts"
import type { usersModule } from '@vitnode/core/api/modules/users/users.module'
import { clientModule } from '@vitnode/core/lib/fetcher-client'
import { fetcher } from '@vitnode/core/tanstack/fetcher'
const users = clientModule('@vitnode/core')
// [!code ++:8]
export const fetchDevices = async () => {
const response = await fetcher(users, {
method: 'get',
module: 'users',
path: '/devices',
})
return await response.json()
}
```
```tsx title="plugins/devices/src/pages/devices-page.tsx"
import { definePluginRoute } from '@vitnode/core/routing'
export const route = definePluginRoute({
load: async () => await fetchDevices(), // [!code ++]
})
```
The initial render forwards the visitor's cookie, user-agent and IP through the
request-aware transport; later navigations are a direct same-origin `fetch` to
Hono. See [Fetcher](/docs/dev/fetcher) for the full contract.
`createIsomorphicFn` is still the right tool when the *two implementations
genuinely differ* - reading a cookie from the request on the server and from
`document.cookie` in the browser, for instance. It is no longer how you fetch.
***
## When to use `createServerFn` (host app only) [#when-to-use-createserverfn-host-app-only]
Use `createServerFn` when your host application has to touch the response
itself - which in practice means cookies:
```ts title="apps/web/src/lib/auth.ts"
import { createServerFn } from '@tanstack/react-start'
import { usersModule } from '@vitnode/core/api/modules/users/users.module'
import { fetcher } from '@vitnode/core/tanstack/fetcher/server'
export const signIn = createServerFn({ method: 'POST' })
.validator((body: { email: string; password: string }) => body)
.handler(async ({ data }) => {
const response = await fetcher(usersModule, {
// [!code ++]
allowSaveCookies: true,
args: { body: data },
method: 'post',
module: 'users',
path: '/sign_in',
})
return { ok: response.ok }
})
```
Two things make this the exception rather than the rule:
* it needs `allowSaveCookies`, which only the **server** fetcher offers;
* it costs an extra hop - browser → server function → API. Reads should not pay
it, which is exactly why they use the universal `fetcher` instead.
## Learn More [#learn-more]
# Getting Started
You need Node.js 22+, Postgres (or Docker), and your favorite package manager.
Choose a workspace with Turborepo if you will create plugins; plugins are the
home for product work, not an optional side quest.
### Scaffold the app [#scaffold-the-app]
```bash
bun create vitnode-app@canary
```
```bash
pnpm create vitnode-app@canary
```
```bash
npm create vitnode-app@canary
```
Pick **Single App** for TanStack Start plus Hono at `/api`. Turn on
**Turborepo** if you intend to use `--plugin`; it gives the generator a
workspace root and a place for `plugins/*`.
### Start the database [#start-the-database]
If you selected Docker, start Postgres and Redis locally:
```bash
bun run docker:dev
```
```bash
pnpm docker:dev
```
```bash
npm run docker:dev
```
### Start VitNode [#start-vitnode]
```bash
bun dev
```
```bash
pnpm dev
```
```bash
npm run dev
```
Open `http://localhost:3000`.
## Keep going [#keep-going]
# Custom SSO Adapter
VitNode includes built-in SSO adapters for Google, Discord, and Facebook. You can connect any other OAuth2 provider (e.g. GitHub, Apple, Slack) by implementing the `SSOApiPlugin` interface. State cookie generation, user session linking, and login buttons are handled automatically.
## Quick start: GitHub SSO Adapter [#quick-start-github-sso-adapter]
Implement the 5 interface methods and register the adapter in `vitnode.api.config.ts`:
### 1. Build the Adapter [#1-build-the-adapter]
```ts title="apps/api/src/utils/sso/github.ts"
import type { SSOApiPlugin } from "@vitnode/core/api/models/sso"
import { getRedirectUri } from "@vitnode/core/api/models/sso"
export const GitHubSSOApiPlugin = ({
clientId,
clientSecret,
}: {
clientId: string
clientSecret: string
}): SSOApiPlugin => {
const id = "github"
const redirectUri = getRedirectUri(id)
return {
id,
name: "GitHub",
// 1. Build Authorization URL
getUrl: ({ state }) =>
`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=read:user,user:email&state=${state}`,
// 2. Exchange Authorization Code for Token
fetchToken: async (code) => {
const response = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: redirectUri,
}),
})
return await response.json()
},
// 3. Retrieve User Profile & Email
fetchUser: async ({ access_token, token_type }) => {
const headers = { Authorization: `${token_type} ${access_token}` }
// Fetch Profile
const userRes = await fetch("https://api.github.com/user", { headers })
const user = await userRes.json()
// Fetch Primary Email
const emailRes = await fetch("https://api.github.com/user/emails", { headers })
const emails: Array<{ email: string; primary: boolean; verified: boolean }> =
await emailRes.json()
const primaryEmail = emails.find((e) => e.primary && e.verified)?.email ?? user.email
return {
id: String(user.id),
email: primaryEmail,
username: user.login,
avatarUrl: user.avatar_url,
}
},
}
}
```
***
### 2. Register in API Configuration [#2-register-in-api-configuration]
```ts title="apps/api/src/vitnode.api.config.ts"
import { buildApiConfig } from "@vitnode/core/vitnode.config"
import { GitHubSSOApiPlugin } from "./utils/sso/github"
export const vitNodeApiConfig = buildApiConfig({
// [!code ++:8]
authorization: {
ssoAdapters: [
GitHubSSOApiPlugin({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
},
})
```
A **GitHub** login button automatically renders on `/login` and `/register`, and `/login/sso/github` routes incoming authentication requests.
***
## `SSOApiPlugin` Interface Reference [#ssoapiplugin-interface-reference]
## Built-in SSO Adapters [#built-in-sso-adapters]
# Discord
Discord is the quickest of the three to set up: no consent screen review, no app
modes, no verification. Create an application, add one redirect URL, copy two
values.
## Quick start [#quick-start]
If you already have a client ID and secret, this is the entire integration.
```bash title=".env"
DISCORD_CLIENT_ID=1234567890123456789
DISCORD_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
```ts title="src/vitnode.api.config.ts"
import { DiscordSSOApiPlugin } from '@vitnode/core/api/adapters/sso/discord' // [!code ++]
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
plugins: [],
// [!code ++:8]
authorization: {
ssoAdapters: [
DiscordSSOApiPlugin({
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
}),
],
},
})
```
Restart the API and a **Discord** button appears on `/login`.
## Set up the application [#set-up-the-application]
### Sign in to the Discord Developer Portal [#sign-in-to-the-discord-developer-portal]
Go to the [Discord Developer Portal](https://discord.com/developers/applications)
and sign in with the Discord account that should own the application.
### Create a new application [#create-a-new-application]
Press **New Application**, give it the name visitors will see on the
authorization screen, and accept the terms.
{/* Image prompt: The Discord Developer Portal applications list with the "New Application" dialog open and a name typed into the field, dark theme, 1100x600. */}
### Add the redirect URL [#add-the-redirect-url]
Open **OAuth2** in the left navigation and, under **Redirects**, press **Add
Redirect**. Paste the URI for the origin you are running and save.
VitNode builds this URI from `NEXT_PUBLIC_WEB_URL`, so it has to match exactly -
no trailing slash, no locale prefix.
| Environment | Redirect URL |
| ----------------------------------------- | ------------------------------------------- |
| Development (`NEXT_PUBLIC_WEB_URL` unset) | `http://localhost:3000/login/sso/discord` |
| Production | `https://your-domain.com/login/sso/discord` |
Add both entries so one application covers local development and your live site.
{/* Image prompt: The Discord Developer Portal OAuth2 page with the "Redirects" section showing one entry reading http://localhost:3000/login/sso/discord and the "Save Changes" bar at the bottom, dark theme, 1100x500. */}
Discord rejects the authorization request outright if the URL is not in this
list, before the visitor sees anything. If pressing the button lands you on a
Discord error page rather than an authorization prompt, this is why.
### Copy the client ID and secret [#copy-the-client-id-and-secret]
Still on the **OAuth2** page, the **Client ID** and **Client Secret** are in the
client information block at the top. Press **Reset Secret** if the secret was
never revealed or you have lost it - Discord shows it once.
{/* Image prompt: The Discord Developer Portal OAuth2 page client information block showing "Client ID" with a Copy button and "Client Secret" with a Reset Secret button, values redacted, dark theme, 1100x450. */}
### Set the environment variables [#set-the-environment-variables]
Server-side variables, so **no** `NEXT_PUBLIC_` prefix - that prefix is what
marks a value for the browser bundle, and a client secret in the browser bundle
is a client secret you have published.
```bash title=".env"
DISCORD_CLIENT_ID=1234567890123456789 # [!code ++]
DISCORD_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # [!code ++]
```
### Register the adapter [#register-the-adapter]
Add `DiscordSSOApiPlugin` to `authorization.ssoAdapters`. It is a plain factory
function, not a class - there is no `new`.
```ts title="src/vitnode.api.config.ts"
import { DiscordSSOApiPlugin } from '@vitnode/core/api/adapters/sso/discord' // [!code ++]
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
plugins: [],
// [!code ++:8]
authorization: {
ssoAdapters: [
DiscordSSOApiPlugin({
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
}),
],
},
})
```
The redirect URI is computed when this file is evaluated, so restart the API
rather than relying on a hot reload:
```bash
bun dev
```
```bash
pnpm dev
```
```bash
npm run dev
```
### Verify it works [#verify-it-works]
Ask the API what it thinks it supports:
```bash
curl http://localhost:3000/api/@vitnode/core/middleware
# {"isEmail":false,"sso":[{"id":"discord","name":"Discord"}]}
```
Then open `/login` and press **Discord**. You should see Discord's "connect to"
authorization prompt listing your username and email, get bounced back to
`/login/sso/discord?code=...&state=...`, and land on the front page signed in.
{/* Image prompt: The VitNode /login page with the email and password fields above a divider reading "Or continue With" and an outline button labelled Discord beneath it, dark theme, 900x850. */}
## What the adapter asks Discord for [#what-the-adapter-asks-discord-for]
Useful when you are debugging an authorization prompt that lists the wrong
permissions.
| Setting | Value |
| ------------- | -------------------------------------- |
| Authorize URL | `https://discord.com/oauth2/authorize` |
| Token URL | `https://discord.com/api/oauth2/token` |
| Profile URL | `https://discord.com/api/users/@me` |
| Scopes | `identify email` |
| Provider id | `discord` |
| Fields read | `id`, `email`, `username` |
Both scopes are required: `identify` for the id and username, `email` for the
address the account is created with.
## Gotchas [#gotchas]
The adapter requires `email` in Discord's profile response and answers `400`
when it is absent - which is what happens if the `email` scope is dropped, or
if the Discord account genuinely has no address on it. The visitor sees the
generic error screen.
Unlike the Google adapter, which rejects an unverified address, this one takes
Discord's `email` at face value and never reads Discord's `verified` field. If
you need that guarantee, copy the adapter into your own project and add the
check - see [Custom adapter](/docs/dev/sso/custom-adapter).
A `DiscordSSOApiPlugin` whose `clientId` is `undefined` registers happily -
the button renders and the provider is listed - and throws `Missing Discord
client ID` the moment somebody presses it. A button that only raises an error
toast is an environment problem, not a portal problem.
## Next [#next]
# Facebook
Facebook sign-in goes through a Meta app with the **Facebook Login** use case
switched on. Meta calls the two credentials an **App ID** and an **App Secret**;
VitNode calls them `clientId` and `clientSecret`, and they are the same two
strings.
## Quick start [#quick-start]
If you already have an App ID and App Secret, this is the entire integration.
```bash title=".env"
FACEBOOK_CLIENT_ID=1234567890123456
FACEBOOK_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
```ts title="src/vitnode.api.config.ts"
import { FacebookSSOApiPlugin } from '@vitnode/core/api/adapters/sso/facebook' // [!code ++]
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
plugins: [],
// [!code ++:8]
authorization: {
ssoAdapters: [
FacebookSSOApiPlugin({
clientId: process.env.FACEBOOK_CLIENT_ID,
clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
}),
],
},
})
```
Restart the API and a **Facebook** button appears on `/login`. The rest of this
page is how to get those two values.
## Set up the Meta app [#set-up-the-meta-app]
### Sign in to Meta for Developers [#sign-in-to-meta-for-developers]
Go to [developers.facebook.com](https://developers.facebook.com/) and sign in
with the Facebook account that should own the app. Meta walks you through
registering as a developer the first time, which means confirming your contact
details before you can create anything.
### Create an app [#create-an-app]
From **My Apps**, press **Create App**. The first screen wants an **App name** -
this is what visitors read on the login dialog - and an **App contact email**.
### Choose the Facebook Login use case [#choose-the-facebook-login-use-case]
On the **Use cases** step, tick **Authenticate and request data from users with
Facebook Login**. This is the one that gives the app an OAuth dialog; the others
(Ads Manager, Threads, games) do not, and Meta will not let you combine some of
them on one app.
Finish the wizard - the **Business** step lets you skip connecting a business
portfolio, and **Finalize** creates the app.
### Add the OAuth redirect URI [#add-the-oauth-redirect-uri]
In the app dashboard, open the Facebook Login use case's settings - **Use cases →
Authenticate and request data from users with Facebook Login → Customize →
Settings** on a new app, or **Products → Facebook Login → Settings** on an older
one. Paste your URI into **Valid OAuth Redirect URIs** and save.
VitNode builds this URI itself, from `NEXT_PUBLIC_WEB_URL`, so it has to match
character for character - no trailing slash, no locale prefix.
| Environment | Valid OAuth Redirect URI |
| ----------------------------------------- | -------------------------------------------- |
| Development (`NEXT_PUBLIC_WEB_URL` unset) | `http://localhost:3000/login/sso/facebook` |
| Production | `https://your-domain.com/login/sso/facebook` |
{/* Image prompt: The Meta app dashboard Facebook Login Settings panel with "Valid OAuth Redirect URIs" holding one entry reading https://your-domain.com/login/sso/facebook, the "Client OAuth login" and "Web OAuth login" toggles visible above it, light theme, 1200x700. */}
**Valid OAuth Redirect URIs** rejects a plain `http://` URL for a public
domain, so a production entry has to be `https://`. `http://localhost` is the
exception Meta makes for development, which is why the table above has two
rows rather than one.
### Copy the App ID and App Secret [#copy-the-app-id-and-app-secret]
Go to **App settings → Basic** in the left navigation. The **App ID** is in plain
text; the **App Secret** is behind a **Show** button and your password.
### Set the environment variables [#set-the-environment-variables]
Server-side variables, so **no** `NEXT_PUBLIC_` prefix - that prefix is what
marks a value for the browser bundle, and an app secret in the browser bundle is
an app secret you have published.
```bash title=".env"
FACEBOOK_CLIENT_ID=1234567890123456 # [!code ++]
FACEBOOK_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # [!code ++]
```
### Register the adapter [#register-the-adapter]
Add `FacebookSSOApiPlugin` to `authorization.ssoAdapters` in your API config. It
is a plain factory function, not a class - there is no `new`.
```ts title="src/vitnode.api.config.ts"
import { FacebookSSOApiPlugin } from '@vitnode/core/api/adapters/sso/facebook' // [!code ++]
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
plugins: [],
// [!code ++:8]
authorization: {
ssoAdapters: [
FacebookSSOApiPlugin({
clientId: process.env.FACEBOOK_CLIENT_ID,
clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
}),
],
},
})
```
The redirect URI is computed when this file is evaluated, so restart the API
rather than relying on a hot reload:
```bash
bun dev
```
```bash
pnpm dev
```
```bash
npm run dev
```
### Verify it works [#verify-it-works]
Ask the API what it thinks it supports:
```bash
curl http://localhost:3000/api/@vitnode/core/middleware
# {"isEmail":false,"sso":[{"id":"facebook","name":"Facebook"}]}
```
Then open `/login` and press **Facebook**. You should get Meta's "Continue as
..." dialog, be bounced back to `/login/sso/facebook?code=...&state=...`, and
land on the front page signed in. Your account now has a row in `core_users_sso`
with `providerId` `facebook`.
{/* Image prompt: The VitNode /login page with the password form above a divider reading "Or continue With" and two outline buttons labelled Google and Facebook side by side, dark theme, 900x800. */}
### Publish the app [#publish-the-app]
While the app is **Unpublished** (the badge next to **Publish** in the
screenshot above), only people with a role on it - admins, developers, testers -
can sign in. Everyone else is turned away at Meta's dialog before VitNode ever
sees a callback. Go to **Publish** and switch the app live once the round trip
works.
`public_profile` and `email` are both standard-access permissions, so this does
not require App Review. Ask for anything beyond them and it will.
{/* Image prompt: The Meta app dashboard Publish page showing the app status as Unpublished with the requirements checklist and the go-live button, light theme, 1100x600. */}
## What the adapter asks Facebook for [#what-the-adapter-asks-facebook-for]
Useful when you are debugging a login dialog that shows more or fewer
permissions than you expected.
| Setting | Value |
| ------------- | ---------------------------------------------------------- |
| Authorize URL | `https://www.facebook.com/v22.0/dialog/oauth` |
| Token URL | `https://graph.facebook.com/v22.0/oauth/access_token` |
| Profile URL | `https://graph.facebook.com/v22.0/me?fields=id,name,email` |
| Scopes | `public_profile,email` |
| Provider id | `facebook` |
| Fields read | `id`, `name`, `email` |
The account name VitNode creates comes from Facebook's `name` field, stripped of
characters that cannot appear in a profile URL. The Graph API version is pinned
to `v22.0` in the adapter, so a Meta version bump never changes what this
install sends.
## Gotchas [#gotchas]
The adapter requires `email` in the Graph response and answers `400` when it
is missing - which happens if the visitor unticks the email permission on the
login dialog, or if the account was created with a phone number and has no
address at all. The visitor sees the generic error screen, not an explanation.
Unlike the [Google adapter](/docs/dev/sso/google), which rejects an unverified
address, this one takes the Graph API's `email` at face value. If you need a
stronger guarantee, copy the adapter into your own project and add the check -
see [Custom adapter](/docs/dev/sso/custom-adapter).
A `FacebookSSOApiPlugin` whose `clientId` is `undefined` registers happily -
the button renders and the provider is listed - and throws `Missing Facebook
client ID` the moment somebody presses it. A button that only raises an error
toast is an environment problem, not a dashboard problem.
If someone registered with the same address using the password form, the
callback answers `409` and offers them the login page instead. There is no
link-an-existing-account screen yet - see [how the callback handles
accounts](/docs/dev/sso#what-the-callback-does-with-the-account).
## Next [#next]
# Google
Google is the provider most people want first. The whole job is: create an OAuth
client in the Google Auth Platform, give it the redirect URI VitNode already
decided on, and pass the two credentials to `GoogleSSOApiPlugin`.
## Quick start [#quick-start]
If you already have a client ID and secret, this is the entire integration.
```bash title=".env"
GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxx
```
```ts title="src/vitnode.api.config.ts"
import { GoogleSSOApiPlugin } from '@vitnode/core/api/adapters/sso/google' // [!code ++]
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
plugins: [],
// [!code ++:8]
authorization: {
ssoAdapters: [
GoogleSSOApiPlugin({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
},
})
```
Restart the API and a **Google** button appears on `/login`. The rest of this
page is how to get those two values.
## Set up the OAuth client [#set-up-the-oauth-client]
### Sign in to Google Cloud [#sign-in-to-google-cloud]
Go to the [Google Cloud Console](https://console.cloud.google.com/) and sign in
with the account that should own the OAuth client. It does not have to be the
account you sign in to your own site with.
### Pick or create a project [#pick-or-create-a-project]
An OAuth client belongs to a project, so select one from the project picker in
the top bar - or create one if this is a fresh account. Name it after your site;
the name is internal and never shown to visitors.
{/* Image prompt: The Google Cloud Console project picker dialog open, showing the "Select a project" list with a "New project" button in the top right, light theme, 1200x700. */}
### Open the Google Auth Platform [#open-the-google-auth-platform]
Type `Google Auth Platform` into the console's search bar and open the first
result. This is the section that used to be called "OAuth consent screen", and
it is where both the consent details and the clients live.
{/* Image prompt: The Google Cloud Console top search bar with "Google Auth Platform" typed in and the matching product result highlighted in the dropdown, light theme, 1200x400. */}
### Fill in the consent details [#fill-in-the-consent-details]
Google will not issue a client until it knows what to show on the consent
screen. Provide the **App name** - this is the name visitors read in "Sign in to
continue to ..." - and a **Support email**.
Then, under **Audience**, choose **External** so anyone with a Google account can
sign in. **Internal** restricts sign-in to a single Google Workspace
organisation, which is almost never what a public site wants.
{/* Image prompt: The Google Auth Platform "Audience" step with the "External" radio option selected and the "Internal" option visible above it, light theme, 1000x600. */}
### Create the client and set the redirect URI [#create-the-client-and-set-the-redirect-uri]
Go to **Clients** in the left navigation and press **Create client**.
1. Application type: **Web application**.
2. Name: anything - this one is internal too.
3. Under **Authorized redirect URIs**, add the URI for every origin you run.
VitNode builds this URI itself, from `NEXT_PUBLIC_WEB_URL`, so it has to match
character for character - no trailing slash, no locale prefix.
| Environment | Authorized redirect URI |
| ----------------------------------------- | ------------------------------------------ |
| Development (`NEXT_PUBLIC_WEB_URL` unset) | `http://localhost:3000/login/sso/google` |
| Production | `https://your-domain.com/login/sso/google` |
Add both. Google allows several redirect URIs per client, and one client for dev
and prod is fine while you are getting started.
{/* Image prompt: The Google Auth Platform "Create OAuth client" form with Application type set to "Web application" and one entry under "Authorized redirect URIs" reading http://localhost:3000/login/sso/google, light theme, 1000x700. */}
This is the error you will get if the URI differs by so much as a trailing
slash. It comes from Google, not from VitNode, and it is always this field.
### Copy the client ID and secret [#copy-the-client-id-and-secret]
Open the client you just created from the **Clients** list. The **Client ID** and
**Client secret** are on the right-hand side of the detail panel. Copy both -
Google will let you see the secret again, but you may as well paste it straight
into `.env`.
{/* Image prompt: The Google Auth Platform OAuth client detail panel with the "Client ID" and "Client secret" fields visible on the right, values redacted, light theme, 1100x600. */}
### Set the environment variables [#set-the-environment-variables]
These names are not magic - they are whatever you read in your config on the
next step. What matters is that they are server-side variables, so **no**
`NEXT_PUBLIC_` prefix: that prefix is what marks a value for the browser bundle,
and a client secret in the browser bundle is a client secret you have published.
```bash title=".env"
GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com # [!code ++]
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxx # [!code ++]
```
### Register the adapter [#register-the-adapter]
Add `GoogleSSOApiPlugin` to `authorization.ssoAdapters` in your API config.
Note that it is a plain factory function, not a class - there is no `new`.
```ts title="src/vitnode.api.config.ts"
import { GoogleSSOApiPlugin } from '@vitnode/core/api/adapters/sso/google' // [!code ++]
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
plugins: [],
// [!code ++:8]
authorization: {
ssoAdapters: [
GoogleSSOApiPlugin({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
},
})
```
The redirect URI is computed when this file is evaluated, so restart the API
rather than relying on a hot reload:
```bash
bun dev
```
```bash
pnpm dev
```
```bash
npm run dev
```
### Verify it works [#verify-it-works]
Ask the API what it thinks it supports:
```bash
curl http://localhost:3000/api/@vitnode/core/middleware
# {"isEmail":false,"sso":[{"id":"google","name":"Google"}]}
```
Then open `/login` and press **Google**. You should be sent to Google's consent
screen, bounced back to `/login/sso/google?code=...&state=...`, and land on the
front page signed in. Your account now has a row in `core_users_sso` with
`providerId` `google`.
{/* Image prompt: The VitNode /login page with the email and password fields above a divider reading "Or continue With" and an outline button labelled Google beneath it, light theme, 900x850. */}
### Publish the app [#publish-the-app]
While the client is unpublished, Google restricts sign-in to the test users you
listed and shows an "unverified app" warning. Once the round trip works, go to
**Google Auth Platform → Audience** and press **Publish app**.
{/* Image prompt: The Google Auth Platform "Audience" page showing publishing status "Testing" with the "Publish app" button highlighted, light theme, 1000x500. */}
## What the adapter asks Google for [#what-the-adapter-asks-google-for]
Useful when you are debugging a consent screen that shows more or fewer
permissions than you expected.
| Setting | Value |
| ------------- | ----------------------------------------------- |
| Authorize URL | `https://accounts.google.com/o/oauth2/auth` |
| Token URL | `https://oauth2.googleapis.com/token` |
| Profile URL | `https://www.googleapis.com/oauth2/v1/userinfo` |
| Scopes | `openid profile email` |
| Provider id | `google` |
| Fields read | `id`, `email`, `name`, `verified_email` |
The account name VitNode creates comes from Google's `name` field, stripped of
characters that cannot appear in a profile URL.
## Gotchas [#gotchas]
The adapter reads `verified_email` and throws a `400` when it is `false`, so a
Google account with an unconfirmed address cannot sign in at all. The visitor
sees the generic error screen rather than an explanation, which is the one
place this is worth knowing about in advance.
A `GoogleSSOApiPlugin` whose `clientId` is `undefined` registers happily - the
button renders and the provider is listed - and throws `Missing Google client
ID` the moment somebody presses it. If the button is there but does nothing
except raise an error toast, check the environment before you check the
console.
If someone registered with the same address using the password form, the
callback answers `409` and offers them the login page instead. There is no
link-an-existing-account screen yet - see [how the callback handles
accounts](/docs/dev/sso#what-the-callback-does-with-the-account).
## Next [#next]
# Single Sign-On (SSO)
VitNode provides built-in OAuth2 single sign-on. Register an adapter in your API configuration, and authentication buttons automatically appear on `/login` and `/register`.
## Quick start [#quick-start]
Register an SSO adapter in `apps/api/src/vitnode.api.config.ts`:
```ts title="apps/api/src/vitnode.api.config.ts"
import { GoogleSSOApiPlugin } from "@vitnode/core/api/adapters/sso/google"
import { buildApiConfig } from "@vitnode/core/vitnode.config"
export const vitNodeApiConfig = buildApiConfig({
// [!code ++:8]
authorization: {
ssoAdapters: [
GoogleSSOApiPlugin({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
},
})
```
A **Continue with Google** button automatically renders on your login and registration forms.
***
## Supported Providers [#supported-providers]
***
## Callback URLs [#callback-urls]
When configuring OAuth2 applications in provider developer consoles, use the following redirect URI pattern:
```text
https://your-domain.com/login/sso/{provider_id}
```
For example: `https://your-domain.com/login/sso/google`.
***
## How Accounts Link [#how-accounts-link]
When a user signs in via SSO:
1. **Existing Email Match**: If an existing account shares the verified email, the SSO identity is automatically linked.
2. **New Visitor**: A new user is created with their social display name, email, and avatar.
## Learn More [#learn-more]
# Custom adapter
A storage adapter is a plain factory that returns three functions. VitNode has
already validated the file, re-encoded the image and built a collision-free key
before it calls you - your job is to put bytes somewhere and be able to name
their URL afterwards.
## Quick start [#quick-start]
The whole contract is one interface, and there is nothing to install to
implement it:
```ts title="packages/vitnode/src/api/models/storage.ts"
export interface StorageApiPlugin {
delete: (key: string) => Promise
getUrl: (key: string) => string
static?: StorageStaticConfig
upload: (args: StorageUploadArgs) => Promise
}
```
Return an object of that shape from a factory, set it as `storage.adapter` in
your API config, and VitNode stores files through it. The
[working skeleton](#write-one) below is about 60 lines.
## The three methods [#the-three-methods]
| Method | Gets | Must return | Rules |
| :----------------------------------- | :----------------------------------------------------- | :------------------------------ | :--------------------------------------------------------------------------- |
| `upload({ key, body, contentType })` | A Node `Buffer`, the media type, and a pre-built `key` | `{ key, url }` | Store `body` **at that exact key**. The key is written to the database as-is |
| `delete(key)` | The key from the `core_files` row | `Promise` | An object that is already gone is a success, not an error |
| `getUrl(key)` | The same key | A public URL, **synchronously** | Build a string. No network, no `await` - it runs once per row in a listing |
`StorageUploadArgs` and `StorageUploadResult` are exported from the same module,
so you never have to restate them:
```ts title="packages/vitnode/src/api/models/storage.ts"
export interface StorageUploadArgs {
body: Buffer
contentType?: string
key: string
}
export interface StorageUploadResult {
key: string
url: string
}
```
## Write one [#write-one]
### Write the adapter [#write-the-adapter]
A complete adapter, against any object store that answers `PUT` and `DELETE` and
serves the objects back from a public base URL. Both official cloud adapters
default their arguments to `""` and check them lazily, so a missing environment
variable fails on the first upload with a sentence rather than at import time
with a stack trace.
```ts title="src/utils/storage/http-storage.ts"
import type {
StorageApiPlugin,
StorageUploadArgs,
StorageUploadResult,
} from '@vitnode/core/api/models/storage'
export const HttpStorageAdapter = ({
apiKey = '',
endpoint = '',
publicUrl = '',
}: {
apiKey: string | undefined
endpoint: string | undefined
publicUrl: string | undefined
}): StorageApiPlugin => {
const requireConfig = () => {
if (!(apiKey && endpoint && publicUrl)) {
throw new Error('Missing HTTP storage configuration')
}
return { apiKey, endpoint: endpoint.replace(/\/$/, '') }
}
const getUrl = (key: string): string =>
`${publicUrl.replace(/\/$/, '')}/${key}`
return {
getUrl,
delete: async (key: string): Promise => {
const { apiKey, endpoint } = requireConfig()
const res = await fetch(`${endpoint}/${key}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${apiKey}` },
})
if (!res.ok && res.status !== 404) {
throw new Error(`Storage delete failed with ${res.status}`)
}
},
upload: async ({
body,
contentType,
key,
}: StorageUploadArgs): Promise => {
const { apiKey, endpoint } = requireConfig()
const res = await fetch(`${endpoint}/${key}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': contentType ?? 'application/octet-stream',
},
body: new Uint8Array(body),
})
if (!res.ok) {
throw new Error(`Storage upload failed with ${res.status}`)
}
return { key, url: getUrl(key) }
},
}
}
```
Two details worth copying rather than reinventing. `delete` treats a `404` as
done, because `deleteFile` has already removed the database row by the time it
runs - throwing there turns a finished delete into a failed request. And
`upload` returns the key it was handed, never a rewritten one.
### Register it [#register-it]
An adapter needs no registry, no manifest and no plugin entry. It is a value on
the API config:
```ts title="src/vitnode.api.config.ts"
import { buildApiConfig } from '@vitnode/core/vitnode.config'
import { HttpStorageAdapter } from './utils/storage/http-storage' // [!code ++]
export const vitNodeApiConfig = buildApiConfig({
storage: {
// [!code ++:5]
adapter: HttpStorageAdapter({
apiKey: process.env.STORAGE_API_KEY,
endpoint: process.env.STORAGE_ENDPOINT,
publicUrl: process.env.STORAGE_PUBLIC_URL,
}),
},
})
```
### Verify it round-trips [#verify-it-round-trips]
Open **AdminCP → Core → System → Integrations** (`/admin/core/system/integrations`)
and click **Test storage** on the Storage card. It uploads an image through your
adapter, then the row shows up in **AdminCP → Core → System → Files** with a
working thumbnail - which is `getUrl` proving itself, since the browser loads
that URL directly.
If the thumbnail is broken but the upload succeeded, `upload` and `getUrl`
disagree about the URL. If the upload itself failed, the API log has your own
error message in it.
The framework builds `month_{month}_{year}/{folder}/.` before
calling `upload`, checks every folder segment against the traversal guard, and
stores that key on the `core_files` row. So an adapter stores `body` at the
key it was handed and never invents path logic - see [storage
keys](/docs/dev/storage#storage-keys).
## The optional static descriptor [#the-optional-static-descriptor]
Disk-backed adapters can expose a fourth field, and only they should:
```ts
export interface StorageStaticConfig {
mountPath: string
root: string
stripPrefix: string
}
```
It is not read by the storage layer at all - the app that boots the API reads it
to mount Hono's `serveStatic` for the stored files, which is how the
[Local adapter](/docs/dev/storage/local#serve-the-stored-files) serves what it
writes. A cloud adapter omits it, and the mount is then skipped.
| Field | The Local adapter's value |
| :------------ | :----------------------------------------------------- |
| `mountPath` | `publicPath` with a leading `/api` stripped, plus `/*` |
| `root` | `./public/uploads` |
| `stripPrefix` | `publicPath`, e.g. `/api/uploads` |
## Gotchas [#gotchas]
Your errors are not `HTTPException`s, so the API's error handler answers `500`
* with your message in the body during development and a bare "Internal Server
Error" in production, where only the log has the reason. That is the same deal
the official adapters get. Throw an `HTTPException` from `hono/http-exception`
instead when the person uploading can act on the cause - a quota, say, rather
than a broken token.
The AdminCP Files table calls it for every file on the page, and it is
declared synchronous, so a signed URL that needs a round trip does not fit
here. Put a CDN or a public base URL in front of the store and build a string.
Returning a different `key` than you were given stores that value on the row -
and every later read, download and delete uses it. If your provider mangles
the path (a leading slash, a normalised case), return the key **as the
provider will accept it back**, not as you wish it were.
`body.length` is what lands in `core_files.size`, and the image pipeline has
already run. An adapter that compresses further would make the database
disagree with the object, so leave the bytes alone.
## Publish it [#publish-it]
If the adapter is useful to more than one installation, publish it as a package.
Both official adapters are small enough to read in one sitting and are the best
starting template:
* [`@vitnode/s3`](https://github.com/aXenDeveloper/vitnode/tree/canary/packages/s3) -
a lazily created SDK client, a URL built from three rules
* [`@vitnode/supabase-storage`](https://github.com/aXenDeveloper/vitnode/tree/canary/packages/supabase-storage) -
the shortest possible adapter, at under 60 lines
Keep `@vitnode/core` a dev dependency (you only import types from it) and ship
your provider's SDK as a real dependency, the way both of those do.
## Next [#next]
# Storage
VitNode provides an integrated storage layer accessible via `c.get("storage")`. It handles file validation, automatic WebP image optimization, database indexing in `core_files`, and persistent storage across multiple backends.
## Quick start [#quick-start]
### 1. Register a Storage Adapter [#1-register-a-storage-adapter]
In `apps/api/src/vitnode.api.config.ts`, configure the local disk adapter:
```ts title="apps/api/src/vitnode.api.config.ts"
import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local"
import { buildApiConfig } from "@vitnode/core/vitnode.config"
export const vitNodeApiConfig = buildApiConfig({
storage: {
adapter: LocalStorageAdapter(), // [!code ++]
},
})
```
### 2. Upload Files in an API Route [#2-upload-files-in-an-api-route]
Handle uploads in a Hono route using `c.get("storage").upload()`:
```ts title="plugins/blog/src/api/modules/posts/routes/upload-cover.route.ts"
import { z } from "@hono/zod-openapi"
import { buildRoute } from "@vitnode/core/api/lib/route"
export const uploadCoverRoute = buildRoute({
pluginId: "blog",
route: {
method: "post",
path: "/cover",
request: {
body: {
content: {
"multipart/form-data": {
schema: z.object({
file: z.instanceof(File),
}),
},
},
},
},
},
handler: async (c) => {
const { file } = await c.req.parseBody()
// [!code ++:7]
const uploaded = await c.get("storage").upload({
file: file as File,
folder: "covers",
maxBytes: 5 * 1024 * 1024, // 5 MB
allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"],
})
return c.json(uploaded)
},
})
```
`upload()` saves the file, creates a record in `core_files`, and returns `{ id, url, width, height, size }`.
***
## Supported Storage Adapters [#supported-storage-adapters]
***
## Image Optimization [#image-optimization]
The storage service can automatically convert uploaded images to WebP and constrain dimensions:
```ts
const uploaded = await c.get("storage").upload({
file,
folder: "avatars",
convertImagesToWebp: true, // [!code ++]
maxDimensions: { width: 1200, height: 1200 }, // [!code ++]
})
```
***
## Deleting Files [#deleting-files]
Remove files and clean up storage using `delete()`:
```ts
await c.get("storage").delete({ fileId: 42 })
```
This removes the file from the configured storage bucket and deletes its row from `core_files`.
***
## Verifying in AdminCP [#verifying-in-admincp]
{/* Image prompt: VitNode AdminCP System -> Integrations screen at /admin/core/system/integrations. Storage integration card shows "Active" with a "Test file storage" button and modal demonstrating test file upload. Dark theme, 1440x900. */}
Test your storage adapter anytime in the AdminCP:
1. Navigate to **System → Integrations** (`/admin/core/system/integrations`).
2. On the **Storage** card, click **Test Storage** to verify bucket credentials and upload capabilities.
3. Inspect uploaded files anytime under **System → Files** (`/admin/core/system/files`).
## Learn More [#learn-more]
# Local (disk)
The Local adapter is the one that needs no account, no keys and no extra package:
it writes uploads into `public/uploads` next to your API and serves them back as
static files. Perfect for development and for a single self-hosted server, and
the wrong choice on serverless.
| Cloud | Self-hosted | Package |
| :------------- | :---------- | :--------------------------- |
| ⚠️ Not durable | ✅ Supported | Ships inside `@vitnode/core` |
## Quick start [#quick-start]
```ts title="src/vitnode.api.config.ts"
import { LocalStorageAdapter } from '@vitnode/core/api/adapters/storage/local' // [!code ++]
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
storage: {
adapter: LocalStorageAdapter(), // [!code ++]
},
})
```
That is enough to store files. Serving them back is one more step, and it depends
on which app runs the API - keep reading.
## Set it up [#set-it-up]
### Register the adapter [#register-the-adapter]
There is nothing to install - the adapter lives in `@vitnode/core`, which you
already depend on. Import it and set `storage.adapter`, exactly as in the
[Quick start](#quick-start) above.
Uploads then land under the current working directory of the API process:
### Serve the stored files [#serve-the-stored-files]
Writing a file is not the same as answering a request for it. The adapter exposes
a `static` descriptor - `mountPath`, `root` and `stripPrefix` - so the mount is
derived from your `publicPath` instead of hardcoded in two places.
```ts title="apps/api/src/index.ts"
import { serveStatic } from '@hono/node-server/serve-static'
import { mkdirSync } from 'node:fs'
// [!code ++:14]
const staticStorage = vitNodeApiConfig.storage?.adapter?.static
if (staticStorage) {
mkdirSync(staticStorage.root, { recursive: true })
app.get(
staticStorage.mountPath,
serveStatic({
root: staticStorage.root,
rewriteRequestPath: (path) =>
path.startsWith(staticStorage.stripPrefix)
? path.slice(staticStorage.stripPrefix.length)
: path,
}),
)
}
VitNodeAPI({ app, vitNodeApiConfig })
```
```ts title="src/server/vitnode-api.server.ts"
import { serveStatic } from '@hono/node-server/serve-static'
import { mkdirSync } from 'node:fs'
const createVitNodeApi = () => {
const app = new OpenAPIHono().basePath('/api')
// [!code ++:14]
const staticStorage = vitNodeApiConfig.storage?.adapter?.static
if (staticStorage) {
mkdirSync(staticStorage.root, { recursive: true })
app.get(
staticStorage.mountPath,
serveStatic({
root: staticStorage.root,
rewriteRequestPath: (path) =>
path.startsWith(staticStorage.stripPrefix)
? path.slice(staticStorage.stripPrefix.length)
: path,
}),
)
}
VitNodeAPI({ app, vitNodeApiConfig })
return app
}
```
Mount `serveStatic` **before** `VitNodeAPI` on the standalone API: the request
then skips the CORS, CSRF, rate-limiter and global middleware, which a static
image has no use for. `mkdirSync` is only there so `serveStatic` does not warn
about a missing root before the first upload.
The TanStack Start app is the same code in a different file, because `/api/*` on
that app is one catch-all route handing the request to the very same Hono
application - so the mount belongs inside it, not in the router. `serveStatic`
comes from `@hono/node-server`, which that app does not depend on yet:
```bash
bun i @hono/node-server
```
```bash
pnpm i @hono/node-server
```
```bash
npm i @hono/node-server
```
During `vite dev` the app's `public/` directory is served at the site root, so
setting `publicPath: "/uploads"` looks like it works with no mount at all.
`vite build` copies `public/` into the build output **once**, so files
uploaded at runtime are written somewhere the production server never reads
from. Keep the default `publicPath` and mount the `static` descriptor as
above, or use a cloud adapter - which is the honest answer for anything with
more than one instance.
### Point the URLs at the right origin [#point-the-urls-at-the-right-origin]
The adapter builds absolute URLs as `{baseUrl}/{publicPath}/{key}`. `baseUrl`
defaults to the configured API origin, so this env var is what decides whether
your file URLs are reachable:
| Variable | Required | What it is for |
| :-------------------- | :---------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_API_URL` | Recommended | The public origin of the API. Used as the default `baseUrl`, so a wrong value produces URLs that 404. Falls back to the browser's own origin, then `http://localhost:3000` |
```bash title=".env"
NEXT_PUBLIC_API_URL=http://localhost:8000
```
Pass `baseUrl` explicitly if the files are fronted by a different hostname than
the API itself.
### Upload something and verify [#upload-something-and-verify]
Open **AdminCP → Core → System → Integrations** and use **Test storage** on the
Storage card: it uploads an image and tells you whether the round trip worked.
Then check the file is really on disk and really served:
```bash
ls public/uploads
curl -I http://localhost:8000/api/uploads/month_9_2026/admin-storage-test/.webp
```
A `200` means the mount is right. A `404` with the file present on disk means the
`publicPath` and the mount disagree.
## Options [#options]
The default `publicPath` is `/api/uploads` because the API mounts everything under
`/api`, and `mountPath` strips that prefix back off so the route is registered
once, in the right place. Change `publicPath` and the descriptor follows it - but
keep it under `/api`: `stripPrefix` is the whole `publicPath`, so a `publicPath`
outside the API's own base path strips nothing and every file answers `404`.
## Gotchas [#gotchas]
Serverless platforms give every instance an ephemeral filesystem. Files
written by one instance are invisible to the next and gone after a deploy,
which shows up as images that worked this morning. Use [S3 or
R2](/docs/dev/storage/s3-r2) or [Supabase Storage](/docs/dev/storage/supabase)
there.
The upload path is resolved from `process.cwd()`, so starting the API from a
different directory points it at a different `public/uploads`. Old files do
not disappear - the keys on the `core_files` rows still name them - but the
new directory answers the requests.
Deleting a file from disk by hand leaves its `core_files` row behind, and the
AdminCP Files table will keep listing it with a broken preview. Delete through
the table (or `deleteFile`) so the row and the bytes go together.
`public/uploads` is state. A container that recreates its filesystem on deploy
loses every upload, and no `core_files` row can bring the bytes back. Mount a
volume, or move to a cloud adapter.
## Next [#next]
# AWS S3 / Cloudflare R2
`@vitnode/s3` puts your uploads in an object store instead of on the API's disk,
which is what you want the moment there is more than one server - or no server
you own at all. R2 is S3-compatible, so the same adapter covers both: you add an
`endpoint` and you are done.
| Cloud | Self-hosted | Package |
| :---------- | :---------- | :--------------------------------------------------------- |
| ✅ Supported | ✅ Supported | [`@vitnode/s3`](https://www.npmjs.com/package/@vitnode/s3) |
## Quick start [#quick-start]
```ts title="src/vitnode.api.config.ts"
import { S3StorageAdapter } from '@vitnode/s3' // [!code ++]
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
storage: {
// [!code ++:6]
adapter: S3StorageAdapter({
bucket: process.env.S3_BUCKET,
region: process.env.S3_REGION,
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
}),
},
})
```
## Set it up [#set-it-up]
### Install the adapter [#install-the-adapter]
```bash
bun i @vitnode/s3
```
```bash
pnpm i @vitnode/s3
```
```bash
npm i @vitnode/s3
```
It is a runtime dependency of the app that serves your API, not a dev
dependency - the API imports it on every boot. `@aws-sdk/client-s3` comes with
it, so there is nothing else to add.
### Create the bucket and an access key [#create-the-bucket-and-an-access-key]
1. In the S3 console, create a bucket and note its **name** and **region**.
2. In IAM, create a user (or role) with `s3:PutObject`, `s3:DeleteObject` and
`s3:GetObject` on `arn:aws:s3:::your-bucket/*`, then create an access key.
3. Copy the **Access key ID** and **Secret access key**. The secret is shown once.
{/* Image prompt: The AWS IAM console "Retrieve access key" screen after creating an access key, with the Access key ID visible and the Secret access key revealed but the characters replaced by asterisks, plus the Download .csv button. Light theme, 1440x900. */}
1. In the Cloudflare dashboard, open **R2** and create a bucket.
2. Open **Manage R2 API Tokens** and create a token with **Object Read & Write**
for that bucket.
3. Copy the **Access Key ID**, the **Secret Access Key** and the
**S3 API endpoint** - the last one looks like
`https://.r2.cloudflarestorage.com` and is what makes R2 work
through an S3 client.
{/* Image prompt: The Cloudflare dashboard R2 API token success screen, showing the "Use the following credentials" panel with Access Key ID, Secret Access Key (masked) and the S3 API endpoint URL https://.r2.cloudflarestorage.com clearly readable. Dark theme, 1440x900. */}
### Put the credentials in the environment [#put-the-credentials-in-the-environment]
The adapter takes plain arguments, so the names are yours to choose. These are
the ones VitNode's own `.env.example` uses, and the ones the snippets here read:
| Variable | Required | What it is for |
| :--------------------- | :------- | :------------------------------------------------------------------------------- |
| `S3_BUCKET` | Yes | Bucket name. Missing it fails the first upload with `Missing S3 configuration` |
| `S3_ACCESS_KEY_ID` | Yes | Access key ID |
| `S3_SECRET_ACCESS_KEY` | Yes | Secret access key |
| `S3_REGION` | AWS only | Bucket region, e.g. `us-east-1`. Defaults to `auto`, which is what R2 wants |
| `S3_ENDPOINT` | R2 only | The account's S3 API endpoint. Setting it also switches on path-style addressing |
| `S3_PUBLIC_URL` | No | Base URL public file URLs are built from - a CDN or custom domain |
```bash title=".env"
S3_BUCKET=your-bucket
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=your_access_key_id
S3_SECRET_ACCESS_KEY=your_secret_access_key
# Cloudflare R2 only
# S3_ENDPOINT=https://.r2.cloudflarestorage.com
# Optional CDN or custom domain in front of the bucket
# S3_PUBLIC_URL=https://cdn.example.com
```
### Register the adapter [#register-the-adapter]
For AWS S3, the four values above are all it needs:
```ts title="src/vitnode.api.config.ts"
import { S3StorageAdapter } from '@vitnode/s3' // [!code ++]
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
storage: {
// [!code ++:6]
adapter: S3StorageAdapter({
bucket: process.env.S3_BUCKET,
region: process.env.S3_REGION,
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
}),
},
})
```
For Cloudflare R2, keep the same adapter and add the endpoint - the region stays
at its `auto` default:
```ts title="src/vitnode.api.config.ts"
adapter: S3StorageAdapter({
bucket: process.env.S3_BUCKET,
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
endpoint: process.env.S3_ENDPOINT, // [!code ++:2]
publicUrl: process.env.S3_PUBLIC_URL,
}),
```
### Upload a test file [#upload-a-test-file]
Open **AdminCP → Core → System → Integrations** (`/admin/core/system/integrations`).
The Storage card now reads active; click **Test storage** and upload an image.
On success the dialog says the image is stored in your adapter, and the object
appears in the bucket under `month_{month}_{year}/admin-storage-test/…`. On
failure, check the API logs: the SDK's own error names the cause, and the two
usual suspects are a bucket in a different region than `S3_REGION` and a token
without write permission.
The file also shows up in **AdminCP → Core → System → Files** with its size and
pixel dimensions - proof that both the object and its `core_files` row were
written.
## How the public URL is built [#how-the-public-url-is-built]
`getUrl` never asks the provider - it builds a string, and the first rule that
applies wins:
| Condition | Resulting URL |
| :----------------- | :------------------------------------------------- |
| `publicUrl` is set | `{publicUrl}/{key}` |
| `endpoint` is set | `{endpoint}/{bucket}/{key}` |
| Neither | `https://{bucket}.s3.{region}.amazonaws.com/{key}` |
For R2 that middle row matters: the S3 API endpoint is **not** a public read
domain, so a bucket without a public `r2.dev` or custom domain gives URLs that
only your credentials can read. Enable public access on the bucket and put that
hostname in `publicUrl`.
{/* Image prompt: The Cloudflare R2 bucket "Settings" tab with the "Public Development URL" / custom domain section expanded, showing the r2.dev hostname enabled and a "Connect Domain" button beside it. Dark theme, 1440x900. */}
## Options [#options]
## Gotchas [#gotchas]
The S3 client is created lazily, so a missing bucket or key throws `Missing S3
configuration` the first time somebody uploads - not when the server starts.
The Integrations card only knows an adapter is *registered*. Run **Test
storage** after any credential change.
VitNode serves files by URL, and the built-in download routes fetch that URL
server-side before re-streaming it. If neither the bucket nor `publicUrl` is
publicly readable, thumbnails never load and both download routes turn the
refused fetch into a `404`. Front the bucket with CloudFront, an R2 custom
domain, or make it public.
The adapter lets the SDK's own error propagate, and it is not an
`HTTPException` - so a denied `PutObject` or a bucket in the wrong region
answers `500`, with the useful sentence in the API log rather than in the
uploader. Catch it in your route if the person uploading deserves better than
"something went wrong".
Keys contain a UUID, so a collision is not a practical concern - but a
`PutObject` to an existing key replaces it. If you enable versioning on the
bucket, remember that `deleteFile` removes the current version only; lifecycle
rules are how old versions actually go away.
## Next [#next]
# Supabase Storage
`@vitnode/supabase-storage` puts your uploads in a bucket on your Supabase
project. If Supabase is already your database, this is the shortest path off the
local disk: no IAM policy, no endpoint, three values and you are done.
| Cloud | Self-hosted | Package |
| :---------- | :---------- | :------------------------------------------------------------------------------------- |
| ✅ Supported | ✅ Supported | [`@vitnode/supabase-storage`](https://www.npmjs.com/package/@vitnode/supabase-storage) |
## Quick start [#quick-start]
```ts title="src/vitnode.api.config.ts"
import { SupabaseStorageAdapter } from '@vitnode/supabase-storage' // [!code ++]
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
storage: {
// [!code ++:5]
adapter: SupabaseStorageAdapter({
url: process.env.SUPABASE_URL,
secretKey: process.env.SUPABASE_SECRET_KEY,
bucket: process.env.SUPABASE_STORAGE_BUCKET,
}),
},
})
```
## Set it up [#set-it-up]
### Install the adapter [#install-the-adapter]
```bash
bun i @vitnode/supabase-storage
```
```bash
pnpm i @vitnode/supabase-storage
```
```bash
npm i @vitnode/supabase-storage
```
A runtime dependency of the app that serves your API, not a dev dependency - the
API imports it on every boot. `@supabase/storage-js` comes with it, so there is
nothing else to add.
### Create the bucket [#create-the-bucket]
In the Supabase dashboard, open **Storage → Buckets → New bucket**, give it a
name and turn **Public bucket** on. Public is not optional here for anything a
visitor has to see: VitNode serves files by URL, and so do the built-in download
routes - see [the private-bucket gotcha](#gotchas) below.
{/* Image prompt: The Supabase dashboard "New bucket" panel open over the Storage → Buckets list, with a bucket name typed in, the "Public bucket" toggle switched on, and the collapsed "Additional configuration" section showing file size limit and allowed MIME types. Dark theme, 1440x900. */}
Leave the bucket's own **file size limit** and **allowed MIME types** generous
and enforce your real limits in your route with `maxBytes` and
`allowedMimeTypes` - the bucket rejects a file with a Supabase error, your route
rejects it with a sentence somebody can act on.
### Copy the secret key [#copy-the-secret-key]
Open **Project Settings → API Keys** and copy a **secret key** - the value
starting `sb_secret_`. That is the modern replacement for the legacy
`service_role` key; a still-valid legacy key works too, because the adapter sends
whatever you give it verbatim as the `apikey` and `Authorization: Bearer` header.
{/* Image prompt: The Supabase dashboard Project Settings → API Keys page, showing the publishable key in full and a secret key row with its value masked behind a "Reveal" control and a copy button. Light theme, 1440x900. */}
It bypasses row level security, which is exactly why the API can write to the
bucket. Never put it in client code, a `VITE_*` variable or anything else that
reaches a browser: a secret key that ships to a visitor is a secret key you
have to rotate.
### Put the credentials in the environment [#put-the-credentials-in-the-environment]
The adapter takes plain arguments, so the names are yours. These are the ones the
API configs in this repository read, and the ones the snippets here use:
| Variable | Required | What it is for |
| :------------------------ | :------- | :----------------------------------------------------------------------------------------- |
| `SUPABASE_URL` | Yes | Project URL, e.g. `https://abcdefgh.supabase.co`. The adapter appends `/storage/v1` itself |
| `SUPABASE_SECRET_KEY` | Yes | The `sb_secret_…` key. Server-side only |
| `SUPABASE_STORAGE_BUCKET` | Yes | Bucket name, exactly as you created it |
```bash title=".env"
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SECRET_KEY=sb_secret_...
SUPABASE_STORAGE_BUCKET=your-bucket
```
Miss any one of the three and the first upload throws
`Missing Supabase Storage configuration` - the client is created lazily, so a
typo waits for a file rather than failing at boot.
### Register the adapter [#register-the-adapter]
```ts title="src/vitnode.api.config.ts"
import { SupabaseStorageAdapter } from '@vitnode/supabase-storage' // [!code ++]
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
storage: {
// [!code ++:5]
adapter: SupabaseStorageAdapter({
url: process.env.SUPABASE_URL,
secretKey: process.env.SUPABASE_SECRET_KEY,
bucket: process.env.SUPABASE_STORAGE_BUCKET,
}),
image: {
quality: 85,
},
},
})
```
That is the config `apps/web` in this repository actually runs, image pipeline
included.
### Upload a test file [#upload-a-test-file]
Open **AdminCP → Core → System → Integrations** (`/admin/core/system/integrations`).
The Storage card now reads active; click **Test storage** and upload an image.
On success the object appears in the bucket under
`month_{month}_{year}/admin-storage-test/…`, and the row appears in
**AdminCP → Core → System → Files** with its size and pixel dimensions - proof
that both the object and its `core_files` row were written. On failure the API log
carries the Supabase error, and the two usual suspects are a bucket name that does
not exist and a key pasted with its `sb_secret_` prefix trimmed.
## How the public URL is built [#how-the-public-url-is-built]
`getUrl` asks `@supabase/storage-js` for the bucket's public URL, which is a
string it builds rather than a request it makes:
```txt
{SUPABASE_URL}/storage/v1/object/public/{bucket}/{key}
```
So every file URL in the installation is that shape, and on a private bucket
Supabase's object API refuses it rather than serving the file - which is why
public matters more here than it looks.
## Options [#options]
## Gotchas [#gotchas]
`getUrl` returns the bucket's public URL, and the built-in download routes
fetch that URL server-side before re-streaming it. On a private bucket
Supabase refuses that URL, so the thumbnails in the AdminCP Files table never
load and both download routes turn the refused fetch into a `404`. Make the
bucket public, or front it with a CDN.
A bucket configured with a file size limit or an allowed-MIME-type list
rejects the upload inside Supabase, and that error is not an `HTTPException` -
so it reaches the browser as a `500` with the useful sentence in the API log.
Set `maxBytes` and `allowedMimeTypes` on your `upload()` call so the refusal
happens in VitNode, where the message is written for a person.
The adapter uploads with `upsert: true`, so writing to an existing key
replaces it instead of failing. Keys carry a UUID, so this is not a collision
risk - it is what makes a retried upload idempotent rather than a duplicate.
## Next [#next]
# Swagger
VitNode's API is built on [`@hono/zod-openapi`](https://hono.dev/examples/zod-openapi),
so the Zod schemas a route declares are its documentation. Nothing is written
twice and nothing can drift: if the schema changes, the spec changes, and the
request that no longer matches it is rejected.
## Quick start [#quick-start]
Start the dev server and open Swagger UI in a browser:
| App shape | Swagger UI |
| ---------------- | ---------------------------------------------------------------------- |
| **Single App** | [http://localhost:3000/api/swagger](http://localhost:3000/api/swagger) |
| **Monorepo App** | [http://localhost:8000/api/swagger](http://localhost:8000/api/swagger) |
| **Only API** | [http://localhost:8000/api/swagger](http://localhost:8000/api/swagger) |
Whichever origin serves `/api/*` serves the UI, because it is a route on the
same Hono app. The raw document is one path deeper, at `/api/swagger/doc` -
OpenAPI 3.0.0, titled `VitNode API`, versioned with the `@vitnode/core` release
you have installed. Point any OpenAPI tool at that URL.
{/* Image prompt: A screenshot of Swagger UI at http://localhost:3000/api/swagger for the VitNode API - the "VitNode API" title bar at the top, and below it collapsed tag groups reading "(Core) - Users", "(Core) - Admin / Users", "(Core) - Admin / Debug" and "(Example) - Content / Articles", with the first group expanded to show a GET and a POST operation. Light theme, 1440x900. */}
Every path reads `/api/{pluginId}/{module}/{route}`, which is why core's session
endpoint is `/api/@vitnode/core/users/session`. That is not a Swagger
convention - it is how the app is mounted, and
[Architecture](/docs/dev/architecture) walks the whole request path.
## Where the spec comes from [#where-the-spec-comes-from]
You never register anything with Swagger. Four steps in the API's own build
produce the document as a side effect:
| Step | What it contributes |
| ---------------- | ---------------------------------------------------------------------------------- |
| `buildRoute` | The operation: method, path, `request` and `responses` schemas, `description`. |
| `buildModule` | Registers it on the module's Hono app, and names the group it lands in. |
| `buildApiPlugin` | Mounts each module under its name and collects the group list for the document. |
| `VitNodeAPI` | Mounts each plugin under its `pluginId` and serves `/swagger` plus `/swagger/doc`. |
## Put a plugin route in the spec [#put-a-plugin-route-in-the-spec]
There is no extra work - the same three files that make a route reachable make
it documented. What you control is how good the documentation is.
### Describe the route as you build it [#describe-the-route-as-you-build-it]
`description` becomes the operation's summary line, and every schema field can
carry an `example`. This is the whole difference between a spec people use and a
spec people ignore:
```ts title="src/api/modules/orders/routes/show.route.ts"
import { z } from '@hono/zod-openapi'
import { buildRoute } from '@vitnode/core/api/lib/route'
import { CONFIG_PLUGIN } from '@/const'
export const showOrderRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
route: {
method: 'get',
path: '/{id}',
description: 'Get one order by id', // [!code ++]
request: {
params: z.object({
id: z.string().openapi({ example: '1' }), // [!code ++]
}),
},
responses: {
200: {
content: {
'application/json': {
schema: z.object({ id: z.string(), total: z.number() }),
},
},
description: 'The order',
},
},
},
handler: async (c) => c.json({ id: '1', total: 42 }),
})
```
### Register it in a module [#register-it-in-a-module]
```ts title="src/api/modules/orders/orders.module.ts"
import { buildModule } from '@vitnode/core/api/lib/module'
import { CONFIG_PLUGIN } from '@/const'
import { showOrderRoute } from './routes/show.route'
export const ordersModule = buildModule({
pluginId: CONFIG_PLUGIN.pluginId,
name: 'orders', // [!code ++]
routes: [showOrderRoute], // [!code ++]
})
```
The `name` is the second half of the group heading, so pick the word you want to
read in the sidebar.
### Mount the module in the plugin [#mount-the-module-in-the-plugin]
```ts title="src/config.api.ts"
import { buildApiPlugin } from '@vitnode/core/api/lib/plugin'
import { ordersModule } from '@/api/modules/orders/orders.module'
import { CONFIG_PLUGIN } from '@/const'
export const shopApiPlugin = () =>
buildApiPlugin({
pluginId: CONFIG_PLUGIN.pluginId,
modules: [ordersModule], // [!code ++]
})
```
### Reload the UI [#reload-the-ui]
Restart the dev server and refresh `/api/swagger`. A new group is at the bottom
of the list - **(Shop) - Orders** - with `GET /api/@acme/shop/orders/{id}` in
it, and "Try it out" sends a real request with your cookies attached.
## Grouping [#grouping]
Swagger has exactly one grouping mechanism - the tag - so the tag carries both
halves of "where does this endpoint live". The plugin comes from `pluginId` with
its scope dropped and title-cased, the module from the `name` you gave
`buildModule`:
```ts
buildModule({
pluginId: '@vitnode/core', // (Core)
name: 'users', // - Users
routes: [sessionRoute],
})
```
A nested module is named after its whole chain, so `(Core) - Admin / Users`
stays a separate group from the top-level `(Core) - Users`:
```ts
buildModule({
pluginId: '@vitnode/core',
name: 'admin',
routes: [],
modules: [usersAdminModule], // (Core) - Admin / Users
})
```
The chain matters because module names repeat across the tree - core has a
`users` module and an `admin/users` one, plus `cron`, `queue` and `files` twice
over. A leaf-only tag would merge the public and admin halves of each into one
group.
Groups appear in the order the plugins declared their modules, with core first.
A route may add `tags` of its own. They survive, after the generated one - so
the same operation can also show up under a tag you name yourself.
## Gotchas [#gotchas]
`/api/swagger` and `/api/swagger/doc` are registered unconditionally, before
the session middleware, and neither checks anything. Anyone who can reach your
API can read every route it serves, including the admin ones. Deny both paths
at your reverse proxy if that is not what you want - see
[Self-hosted](/docs/dev/deployments/self-hosted).
The [fetcher](/docs/dev/fetcher) infers its return type from the same
`responses` block Swagger reads. Declare a status with no `content` - a bare
`401: { description: 'Unauthorized' }` - and there is no response format to
infer from, so `await res.json()` resolves to `unknown` at every call site.
Give every documented response a `content` schema, or leave the status
undeclared and let `HTTPException` produce it.
The document lists the plugins **this** app has in its
`vitnode.api.config.ts`. Install a plugin and its groups appear; remove it and
they are gone. So `/api/swagger` is a truthful inventory of one deployment
rather than a catalogue of what VitNode can do.
## Next [#next]
# WebSocket
VitNode provides a unified, multiplexed WebSocket connection at `/api/ws`. Browsers share a single connection across tabs via a Web Lock leader election, authenticated by session cookies.
## Quick start [#quick-start]
### 1. Send User Notification [#1-send-user-notification]
Push real-time notifications to any signed-in user from any Hono route or service:
```ts
import { notificationsChannel } from "@vitnode/core/ws/notifications"
// [!code ++:6]
c.get("realtime").sendToUser(userId, notificationsChannel, {
title: "New Comment",
description: "Alex commented on your post.",
type: "info",
})
```
The user receives an immediate `sonner` toast in all open tabs across devices.
***
### 2. Broadcast to Everyone [#2-broadcast-to-everyone]
Broadcast live updates to all connected visitors:
```ts
c.get("realtime").broadcast(myChannel, {
count: 142,
})
```
***
## Define a Custom Channel [#define-a-custom-channel]
Create typed channels in your plugin:
```ts title="plugins/chat/src/ws/chat.channel.ts"
import { defineWebSocketChannel } from "@vitnode/core/ws"
import { z } from "zod"
export interface MessagePayload {
roomId: string
message: string
senderId: number
}
// [!code ++:6]
export const chatChannel = defineWebSocketChannel({
id: "chat_room_messages",
schema: z.object({
roomId: z.string(),
message: z.string(),
senderId: z.number(),
}),
})
```
***
## Client-Side Consumption [#client-side-consumption]
Listen for incoming channel messages with `useWebSocketChannel`:
```tsx title="plugins/chat/src/views/chat-room.tsx"
import { useWebSocketChannel } from "@vitnode/core/hooks/use-websocket-channel"
import { chatChannel } from "../ws/chat.channel"
export const ChatRoom = ({ roomId }: { roomId: string }) => {
const [messages, setMessages] = React.useState([])
// [!code ++:6]
useWebSocketChannel(chatChannel, (payload) => {
if (payload.roomId === roomId) {
setMessages((prev) => [...prev, payload.message])
}
})
return (
{messages.map((msg, i) => (
{msg}
))}
)
}
```
***
## Architectural Highlights [#architectural-highlights]
* **Single Connection**: Only one `/api/ws` socket is opened per client. All features share it via message multiplexing.
* **Tab Leader Election**: When a user opens multiple tabs, a Web Lock elects one leader tab to hold the socket, distributing messages across tabs via `BroadcastChannel`.
* **Automatic Reconnect**: Backoff retry automatically re-establishes dropped connections within seconds.
## Learn More [#learn-more]
# Roles
In VitNode, a role groups users together for display styles (e.g. colored username badges) and staff permission grants.
## Quick start [#quick-start]
Resolve a user's complete set of role IDs (primary + secondary) on the API:
```ts
import { getUserRoleIds } from "@vitnode/core/api/lib/check-staff-permission"
const user = c.get("user")
// [!code ++:1]
const roleIds = user ? await getUserRoleIds(c, user) : []
```
***
## Seeded Default Roles [#seeded-default-roles]
VitNode automatically seeds four protected system roles upon installation:
| Role | Flags | Default Color | Staff Privileges |
| :---------------- | :-------- | :--------------------------- | :----------------------------------------------- |
| **Guest** | `guest` | None | None (applies to unauthenticated visitors) |
| **Member** | `default` | None | Standard registered member role |
| **Moderator** | None | Green (`hsl(122, 80%, 45%)`) | Unrestricted moderation staff |
| **Administrator** | `root` | Red (`hsl(0, 100%, 50%)`) | Unrestricted administrative staff (`root: true`) |
The first user to register during site initialization is automatically assigned the Administrator role.
***
## Role Model Attributes [#role-model-attributes]
`core_roles` defines group styling and properties:
* `color`: CSS color string (e.g. `#ff0000`, `hsl(210, 100%, 50%)`) applied to member usernames across the UI.
* `root`: When `true`, user bypasses all staff permission checks.
* `default`: The role automatically assigned to newly registered members.
* `guest`: Reserved for unauthenticated requests.
***
## Primary vs Secondary Roles [#primary-vs-secondary-roles]
Every user has exactly one **primary role** (stored in `core_users.roleId`). Users may additionally hold multiple **secondary roles** (stored in `core_users_secondary_roles`), inheriting combined staff permissions across all assigned groups.
***
## AdminCP Role Management [#admincp-role-management]
{/* Image prompt: VitNode AdminCP Roles management page at /admin/core/roles. Table showing custom roles with colored name badges, member counts, and action buttons to reorder, edit, or delete roles. Dark theme, 1440x900. */}
Create, color-code, and organize user groups under **Core → Roles** (`/admin/core/roles`).
## Learn More [#learn-more]
# Staff Permissions
VitNode separates staff into two groups: **Moderators** and **Administrators**. Plugins declare granular permissions that restrict what staff members or roles can access.
Routes under `/admin/` are automatically gated by the admin session, but you must declare an explicit staff permission tuple to restrict specific actions.
## Quick start [#quick-start]
### 1. Declare Permissions in Plugin API Config [#1-declare-permissions-in-plugin-api-config]
```ts title="plugins/blog/src/config.api.ts"
import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"
export const blogApiPlugin = () =>
buildApiPlugin({
pluginId: "@vitnode/blog",
// [!code ++:8]
permissionStaff: {
admin: {
posts: [
"can_view",
{ permission: "can_delete", dependsOn: ["can_view"] },
],
},
},
})
```
### 2. Enforce on Hono Route [#2-enforce-on-hono-route]
Add `adminStaffPermission` to automatically return `403 Forbidden` if unauthorized:
```ts title="plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts"
export const deletePostRoute = buildRoute({
pluginId: "@vitnode/blog",
// [!code ++:1]
adminStaffPermission: { module: "posts", permission: "can_delete" },
route: {
method: "delete",
path: "/{id}",
responses: {
200: { description: "Post deleted" },
403: { description: "Forbidden" },
},
},
handler: async (c) => {
// Execution only reaches here if caller has permission
},
})
```
***
## Add Permissions Step by Step [#add-permissions-step-by-step]
### 1. Define Translation Labels [#1-define-translation-labels]
Add flat labels in `plugins/blog/src/locales/en.json`:
```json title="plugins/blog/src/locales/en.json"
{
"@vitnode/blog": { "title": "Blog" },
"@vitnode/blog:posts": "Articles",
"@vitnode/blog:posts:can_view": "View articles list",
"@vitnode/blog:posts:can_create": "Create articles",
"@vitnode/blog:posts:can_delete": "Delete articles"
}
```
### 2. Gate UI Controls [#2-gate-ui-controls]
Hide buttons or panels from unauthorized staff with `AdminStaffPermissionGate`:
```tsx title="plugins/blog/src/admin/delete-post-button.tsx"
import { AdminStaffPermissionGate } from "@vitnode/core/components/staff-permission/provider"
export const DeletePostButton = () => (
// [!code ++:5]
Delete
)
```
Or check permissions imperatively with `useStaffPermissions`:
```tsx
const { hasPermission } = useStaffPermissions()
const canDelete = hasPermission({ module: "posts", permission: "can_delete" })
```
### 3. Gate Sidebar Navigation [#3-gate-sidebar-navigation]
Attach `permission` to navigation items in `src/admin/nav.tsx`:
```tsx title="plugins/blog/src/admin/nav.tsx"
export const adminNav: PluginAdminNav = {
nav: [
{
id: "posts",
href: "/admin/blog/posts",
icon: ,
permission: { module: "posts", permission: "can_view" }, // [!code ++]
},
],
}
```
### 4. Grant Permissions in AdminCP [#4-grant-permissions-in-admincp]
{/* Image prompt: VitNode AdminCP staff permission editor at /admin/core/staff/admins. Left sidebar lists plugins (Core, Blog). Right panel displays permission toggles grouped by module ("Articles") with dependency locks and an Unrestricted / Restricted level switcher. Dark theme, 1440x900. */}
1. Go to **Staff → Administrators** (`/admin/core/staff/admins`).
2. Select a User or Role and click **Edit**.
3. Choose **Restricted**, toggle the desired permissions, and click **Save Changes**.
***
## Permission Dependencies [#permission-dependencies]
When a permission depends on another (e.g. creating posts requires viewing them), declare `dependsOn`:
```ts title="plugins/blog/src/config.api.ts"
permissionStaff: {
admin: {
posts: [
"can_view",
{ permission: "can_create", dependsOn: ["can_view"] },
{ permission: "can_delete", dependsOn: ["can_view"] },
],
},
}
```
The AdminCP permission editor automatically disables child toggles until prerequisites are checked.
## Learn More [#learn-more]
# Users & Sessions
Every incoming request in VitNode resolves the session cookie prior to route execution. The current user profile is accessible on both server handlers and frontend components.
## Quick start [#quick-start]
### 1. In Hono API Routes [#1-in-hono-api-routes]
Access the authenticated user via `c.get("user")`:
```ts title="plugins/blog/src/api/modules/posts/routes/create.route.ts"
import { HTTPException } from "hono/http-exception"
handler: async (c) => {
// [!code ++:4]
const user = c.get("user")
if (!user) {
throw new HTTPException(401, { message: "Unauthorized" })
}
const newPost = await createPost({ authorId: user.id })
return c.json(newPost)
}
```
***
### 2. In Frontend Components [#2-in-frontend-components]
Consume the user session via TanStack Query:
```tsx title="src/components/user-greeting.tsx"
import { useQuery } from "@tanstack/react-query"
import { sessionQueryOptions } from "@vitnode/core/tanstack/auth"
export const UserGreeting = () => {
// [!code ++:2]
const { data } = useQuery(sessionQueryOptions())
const user = data?.user
if (!user) return Welcome, guest!
return Welcome back, {user.name}!
}
```
***
## User Model (`core_users`) [#user-model-core_users]
The canonical user table contains:
| Column | Type | Description |
| :-------------- | :------------- | :----------------------------------------------------- |
| `id` | `serial` | Unique permanent identifier |
| `name` | `varchar(255)` | Display name |
| `nameCode` | `varchar(255)` | URL-safe slug for profile URLs (`/profile/[nameCode]`) |
| `email` | `varchar(255)` | Unique account login email |
| `roleId` | `integer` | Primary role ID (FK to `core_roles`) |
| `avatarColor` | `varchar(6)` | Hex color for letter avatars |
| `language` | `varchar(32)` | Preferred UI locale (defaults to `en`) |
| `emailVerified` | `boolean` | Verification status |
***
## AdminCP User Management [#admincp-user-management]
{/* Image prompt: VitNode AdminCP User management screen at /admin/core/users. Data table listing registered accounts with columns for Avatar, Name, Email, Primary Role badge, Status, and action dropdowns (Edit, Ban, Delete). Dark theme, 1440x900. */}
Administrators can view, edit, ban, and assign roles to users at **Core → Users** (`/admin/core/users`).
## Learn More [#learn-more]
# Install the Blog Plugin
`@vitnode/blog` is the Content Engine reference plugin. It brings articles,
categories, editorial screens, revisions, public delivery data, and search
indexing—without making your host app impersonate a blog plugin.
### Install the package [#install-the-package]
```bash
bun add @vitnode/blog@canary
```
```bash
pnpm add @vitnode/blog@canary
```
```bash
npm install @vitnode/blog@canary
```
### Enable the API plugin [#enable-the-api-plugin]
For a Single App, edit `apps/web/src/vitnode.api.config.ts`; a split deployment
uses the same change in `apps/api/src/vitnode.api.config.ts`.
```ts title="apps/web/src/vitnode.api.config.ts"
import { blogApiPlugin } from '@vitnode/blog/config.api' // [!code ++]
export const vitNodeApiConfig = buildApiConfig({
plugins: [
blogApiPlugin(), // [!code ++]
],
})
```
### Enable the web plugin and its messages [#enable-the-web-plugin-and-its-messages]
The host registers the lightweight plugin identity and static locale loaders:
```ts title="apps/web/src/locales/packages.ts"
import { CONFIG_PLUGIN as BLOG } from '@vitnode/blog/const' // [!code ++]
export const packageMessages = {
// [!code ++:3]
[BLOG.pluginId]: {
en: async () => await import('@vitnode/blog/locales/en.json'),
},
}
```
```ts title="apps/web/src/vitnode.config.ts"
import { blogPlugin } from '@vitnode/blog/config' // [!code ++]
export const vitNodeConfig = buildConfig({
plugins: [
blogPlugin(), // [!code ++]
],
})
```
### Migrate, run, and publish [#migrate-run-and-publish]
```bash
bun run db:migrate
bun dev
```
```bash
pnpm db:migrate
pnpm dev
```
```bash
npm run db:migrate
npm run dev
```
In AdminCP, create a category at **Blog → Categories**, then publish an article
at **Blog → Articles**. The plugin owns the editorial work so the host can stay
boringly reliable.
{/* Image prompt: VitNode AdminCP Blog article management screen. Show categories in a side navigation, article list with draft and published badges, and a prominent “Create article” button. Dark theme, 1440x900. */}
## Deliver it from a plugin [#deliver-it-from-a-plugin]
Build the public article page in a plugin too, then use the Content Engine
delivery guide for loaders and SEO. That keeps the data model and its public URL
together.
# Build Your First Plugin
This tutorial builds `@acme/site-notes`, a tiny plugin with a page at
`/site-notes`. Start from a VitNode workspace with Turborepo enabled; it gives
the plugin generator a shared home.
### Generate `@acme/site-notes` [#generate-acmesite-notes]
Run the generator from the workspace root and enter `@acme/site-notes` when it
asks for a name:
```bash
bun create vitnode-app@canary --plugin
```
```bash
pnpm create vitnode-app@canary --plugin
```
```bash
npm create vitnode-app@canary -- --plugin
```
The result has `routes.ts`, `pages/home-page.tsx`, `locales/en.json`, and
`config.tsx`. The CLI adds a workspace dependency but leaves activation to you,
which makes installed plugins predictable.
### Claim the page URL [#claim-the-page-url]
The route tree belongs to the plugin and is all the host needs to discover a
route. `lazy` names the page module without importing it, so the page gets a
chunk of its own:
```ts title="plugins/site-notes/src/routes.ts"
import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
export const routes = definePluginRoutes([
// [!code ++:3]
page('/site-notes', {
component: lazy(() => import('./pages/home-page')),
}),
])
```
### Render a translated page [#render-a-translated-page]
Edit the generated route module. It stays framework-neutral and is lazily
loaded by the TanStack Start host:
```tsx title="plugins/site-notes/src/pages/home-page.tsx"
import { useTranslations } from 'use-intl'
const HomePage = () => {
const t = useTranslations('@acme/site-notes') // [!code ++]
return (
{t('home.title')}
{t('home.desc')}
)
}
export default HomePage
```
```json title="plugins/site-notes/src/locales/en.json"
{
"@acme/site-notes": {
"home": {
"title": "Site notes",
"desc": "This page ships from a plugin. Neat, right?"
}
}
}
```
### Enable it in the host [#enable-it-in-the-host]
The package must be in the host's `plugins` array. This is the only composition
step; routes and pages remain inside `plugins/site-notes`:
```ts title="apps/web/src/vitnode.config.ts"
import { siteNotesPlugin } from '@acme/site-notes/config' // [!code ++]
export const vitNodeConfig = buildConfig({
plugins: [
siteNotesPlugin(), // [!code ++]
],
})
```
### Run and inspect the result [#run-and-inspect-the-result]
```bash
bun dev
```
```bash
pnpm dev
```
```bash
npm run dev
```
Visit `http://localhost:3000/site-notes`.
{/* Image prompt: Tutorial verification screenshot for a VitNode app at /site-notes. Show a simple “Site notes” page, browser address bar, and a compact visual hint that it is loaded from a plugin package. Dark theme, 1440x900. */}
## Grow the same plugin [#grow-the-same-plugin]
# Guides
Guides are the shortest trustworthy path from an empty workspace to a feature
you can click. They start in a plugin on purpose: reusable code deserves a
proper home before it acquires a spare drawer in the host app.
Already have an app? Great. If not, [Getting started](/docs/dev/setup) prepares
the database and your first AdminCP account. Need a precise option instead of a
walkthrough? The [development reference](/docs/dev) is close by, wearing its
tiny lab coat.
# Accordion
## Preview [#preview]
## Usage [#usage]
```ts
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@vitnode/core/components/ui/accordion'
```
```tsx
Product Information
Our flagship product combines cutting-edge technology with sleek design.
Built with premium materials, it offers unparalleled performance and
reliability.
Key features include advanced processing capabilities, and an intuitive
user interface designed for both beginners and experts.
Shipping Details
We offer worldwide shipping through trusted courier partners. Standard
delivery takes 3-5 business days, while express shipping ensures
delivery within 1-2 business days.
All orders are carefully packaged and fully insured. Track your shipment
in real-time through our dedicated tracking portal.
Return Policy
We stand behind our products with a comprehensive 30-day return policy.
If you're not completely satisfied, simply return the item in its
original condition.
Our hassle-free return process includes free return shipping and full
refunds processed within 48 hours of receiving the returned item.
```
## API Reference [#api-reference]
[Base UI - Accordion](https://base-ui.com/react/components/accordion)
# Alert Dialog
A modal dialog that interrupts the user with important content and expects a
response. Unlike a regular [Dialog](/docs/ui/dialog), it can only be dismissed
through one of its actions.
## Preview [#preview]
## Usage [#usage]
```ts
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@vitnode/core/components/ui/alert-dialog'
import { Button } from '@vitnode/core/components/ui/button'
```
```tsx
Show Dialog} />
Are you absolutely sure?
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
Cancel
Continue
```
## API Reference [#api-reference]
[Base UI - Alert Dialog](https://base-ui.com/react/components/alert-dialog#api-reference)
# Alert
## Preview [#preview]
## Usage [#usage]
```ts
import {
Alert,
AlertDescription,
AlertTitle,
} from '@vitnode/core/components/ui/alert'
import { TriangleAlertIcon } from 'lucide-react'
```
```tsx
Heads up
Something needs your attention.
```
An optional leading icon is picked up automatically - render any icon as the
first child and the layout aligns it with the title and description.
## Variants [#variants]
* `default` - neutral, informational messages.
* `warning` - a non-blocking caution the user should act on (amber).
* `destructive` - an error or a failed action (red).
## Dismissible [#dismissible]
Wrap a control in `AlertAction` to pin it to the top-right corner, e.g. a close
button. The alert reserves space for it automatically.
```tsx
import { AlertAction } from '@vitnode/core/components/ui/alert'
import { Button } from '@vitnode/core/components/ui/button'
import { XIcon } from 'lucide-react'
;
Heads up
Something needs your attention.
```
## Props [#props]
# Auto Form
`AutoForm` creates interactive, accessible forms automatically from a Zod schema. It handles validation, error messages, and submits without boilerplate.
## Preview [#preview]
***
## Quick start [#quick-start]
Define a Zod validation schema and pass it with field descriptors to ` `:
```tsx
import { AutoForm } from "@vitnode/core/components/form/auto-form"
import { AutoFormInput } from "@vitnode/core/components/form/fields/input"
import { AutoFormSelect } from "@vitnode/core/components/form/fields/select"
import { AutoFormTextarea } from "@vitnode/core/components/form/fields/textarea"
import { toast } from "sonner"
import { z } from "zod"
const formSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
email: z.string().email("Invalid email address"),
role: z.enum(["admin", "editor", "viewer"]).default("editor"),
bio: z.string().optional(),
})
export const UserForm = () => {
return (
// [!code ++:24]
{
await saveUser(values)
toast.success("User saved successfully!")
}}
fields={[
{
id: "username",
component: (props) => ,
},
{
id: "email",
component: (props) => ,
},
{
id: "role",
component: (props) => (
),
},
{
id: "bio",
component: (props) => ,
},
]}
/>
)
}
```
***
## Supported Field Components [#supported-field-components]
| Component | Target Zod Type | Description |
| :----------------- | :------------------------- | :----------------------------------- |
| `AutoFormInput` | `z.string()`, `z.number()` | Standard text and numeric inputs |
| `AutoFormTextarea` | `z.string()` | Multi-line text area |
| `AutoFormSelect` | `z.enum([...])` | Dropdown select menu |
| `AutoFormCheckbox` | `z.boolean()` | Single toggle checkbox |
| `AutoFormSwitch` | `z.boolean()` | Switch toggle control |
| `AutoFormEditor` | `z.string()` | Rich text Tiptap editor |
| `AutoFormArray` | `z.array(z.object(...))` | Dynamic list with add/remove actions |
***
## Common Features [#common-features]
### 1. Dynamic Lists (`AutoFormArray`) [#1-dynamic-lists-autoformarray]
Render dynamic, repeatable sets of fields:
```tsx
const formSchema = z.object({
links: z.array(
z.object({
label: z.string().min(1),
url: z.string().url(),
})
),
})
// In fields configuration:
{
id: "links",
component: (props) => (
},
{ id: "url", component: (p) => },
]}
/>
),
}
```
### 2. Labels, Descriptions & Placeholders [#2-labels-descriptions--placeholders]
Configure input helpers directly in the component:
```tsx
{
id: "username",
component: (props) => (
),
}
```
***
## `` Props [#autoform-props]
## Learn More [#learn-more]
# Badge
## Preview [#preview]
## Usage [#usage]
```ts
import { Home } from 'lucide-react'
import { Badge } from '@vitnode/core/components/ui/badge'
```
```tsx
Default
```
## Props [#props]
# Button
A [Base UI](https://base-ui.com/react/components/button) button with VitNode's
variants painted on top. It handles the two things a hand-rolled ``
always gets wrong: a pending state that does not resize the button, and being
something other than a `` when it needs to be a link.
## Preview [#preview]
## Quick start [#quick-start]
```tsx
import { Button } from '@vitnode/core/components/ui/button'
import { HomeIcon } from 'lucide-react'
;
Default
```
Icons are children, not a prop. The button spaces them, sizes them to match its
own size, and marks them `pointer-events-none` so a click always lands on the
button.
## Variants [#variants]
| `variant` | Use it for |
| ------------- | ----------------------------------------------------------- |
| `default` | The one action you want clicked on this screen |
| `secondary` | A second action of equal weight |
| `outline` | Toolbars, filters, anything sitting on a card |
| `ghost` | Icon buttons and row actions, where a border would be noise |
| `link` | A button that must look like prose |
| `destructive` | Delete, ban, revoke - tinted rather than filled, on purpose |
## Sizes [#sizes]
| `size` | Height | Notes |
| --------- | ------- | ------------------------------ |
| `sm` | 2rem | |
| `default` | 2.25rem | |
| `lg` | 2.5rem | |
| `icon-xs` | 1.5rem | Square. `aria-label` required. |
| `icon-sm` | 2rem | Square. `aria-label` required. |
| `icon` | 2.25rem | Square. `aria-label` required. |
| `icon-lg` | 2.5rem | Square. `aria-label` required. |
```tsx
```
## Loading [#loading]
Pass `isLoading` and the label fades out while a spinner springs in over it. The
button is disabled for the duration and keeps its width, so nothing around it
moves:
```tsx
const [isLoading, setIsLoading] = React.useState(false)
;
Save changes
```
While it is `true` and the button has no `aria-label` of its own, the accessible
name becomes the translated "loading" string - so a spinner in place of a label
still announces itself. An `aria-label` you pass is spread on last and keeps
winning, in both states.
## Render as a link [#render-as-a-link]
A button that navigates should be an ``, not a `` with an `onClick`.
Use `render` to swap the element and `nativeButton={false}` to tell Base UI what
it is now:
```tsx
import { Link } from '@tanstack/react-router' // [!code ++]
import { Button } from '@vitnode/core/components/ui/button'
; }>
Settings
```
Inside a shared VitNode view that takes a `LinkComponent`, pass that instead -
it is the same thing with `href` in place of `to`, and it is what lets one
component render correctly in a host that mounts VitNode under a path prefix:
```tsx
}>
Settings
```
## Props [#props]
Everything [Base UI's Button](https://base-ui.com/react/components/button#api-reference)
accepts, plus:
## Gotchas [#gotchas]
The props type is a union: choosing any of the four `icon*` sizes makes
`aria-label` **required**. That is deliberate - an icon button has no text
node, so without it the accessible name is empty - and it is `tsc` that stops
you, not a lint rule you can suppress.
`buttonVariants` defines an `xs` size, but the component's props type does not
accept it: `` is a type error. Use `sm`, or call
`buttonVariants({ size: 'xs' })` yourself if you need that geometry on
something else.
Base UI keeps applying native-button semantics - so an anchor gets attributes
a `` would want and behaves subtly wrong for keyboard users. If the
rendered element is not a ``, say so.
## Next [#next]
# Card
## Preview [#preview]
## Usage [#usage]
```tsx
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@vitnode/core/components/ui/card'
```
```tsx
Card Title
Card Description
This is the content of the card. You can put any content here.
Action Button
```
# Checkbox
## Preview [#preview]
## Usage [#usage]
```ts
import { z } from 'zod'
import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormCheckbox } from '@vitnode/core/components/form/fields/checkbox'
```
```ts
const formSchema = z.object({
acceptTerms: z.boolean().refine((val) => val, {
message: 'You must accept the terms and conditions',
}),
})
```
```tsx
(
),
},
]}
/>
```
```ts
import { Checkbox } from '@vitnode/core/components/ui/checkbox';
```
```tsx
```
## Props [#props]
## API Reference [#api-reference]
[Base UI - Checkbox](https://base-ui.com/react/components/checkbox#api-reference)
# Color
## Preview [#preview]
## Usage [#usage]
```ts
import { z } from "zod";
import { AutoForm } from "@vitnode/core/components/form/auto-form";
import { AutoFormColor } from "@vitnode/core/components/form/fields/color";
```
```ts
const formSchema = z.object({
color: z.string().default('hsl(240, 80%, 60%)'),
})
```
```tsx
,
},
]}
/>
```
```ts
import { ColorPicker } from "@vitnode/core/components/ui/color-picker";
```
The `ColorPicker` is controlled - pass the current `value` (an HSL string) and
handle `onChange`, which receives the new HSL string:
```tsx
const [color, setColor] = useState('hsl(240, 80%, 60%)')
;
```
Dragging the picker produces an HSL string such as `hsl(240, 80%, 60%)`. Below
the picker is a text field where you can paste or type a color in any CSS
format - hex (`#22c55e`), `rgb(...)`, `hsl(...)`, or a named color - so the
stored value is whatever you enter. The picker is powered by
[react-colorful](https://github.com/omgovich/react-colorful) and is
lazy-loaded, so its bundle only ships once the popover is opened.
## Props [#props]
# Colors
We're working hard to bring you the best documentation experience.
# Combobox
A combobox provides a searchable dropdown interface, ideal for long lists such as categories, users, or tags.
## Preview [#preview]
***
## 1. Static Options (AutoForm) [#1-static-options-autoform]
Use `AutoFormCombobox` inside an `AutoForm` with a predefined set of choices:
```tsx
import { AutoForm } from "@vitnode/core/components/form/auto-form"
import { AutoFormCombobox } from "@vitnode/core/components/form/fields/combobox"
import { z } from "zod"
const formSchema = z.object({
category: z.enum(["tech", "news", "design"]),
})
export const CategorySelect = () => (
// [!code ++:16]
(
),
},
]}
/>
)
```
***
## 2. Async API Search [#2-async-api-search]
For large datasets, query the API dynamically as the user types:
```tsx
import { AutoFormCombobox } from "@vitnode/core/components/form/fields/combobox"
import { fetcherClient } from "@vitnode/core/lib/fetcher-client"
const searchUsers = async ({ search }: { search: string }) => {
const res = await fetcherClient(usersModule, {
method: "get",
module: "users",
path: "/search",
args: { query: { search } },
})
const json = await res.json()
return json.map((u) => ({ value: String(u.id), label: u.name }))
}
// In field configuration:
{
id: "author",
component: (props) => (
// [!code ++:7]
),
}
```
***
## 3. Multiple Selection [#3-multiple-selection]
Allow picking multiple options by specifying `multiple: true`:
```tsx
{
id: "tags",
component: (props) => (
),
}
```
***
## `AutoFormCombobox` Props [#autoformcombobox-props]
## Learn More [#learn-more]
# Confirm Action Alert Dialog
The Confirm Action Alert Dialog is a specialized dialog component built on top of the [Alert Dialog](/docs/ui/alert-dialog) that provides a consistent way to confirm potentially destructive or irreversible actions before they're executed.
It's particularly useful for:
* Deleting items (e.g., categories, posts, users)
* Performing actions that cannot be undone (e.g., clearing data, resetting settings)
* Confirming critical operations (e.g., account deletion, data migration)
* Any action where user confirmation is necessary to prevent accidental execution
## Preview [#preview]
## Usage [#usage]
```tsx
import { ConfirmActionAlertDialog } from '@vitnode/core/components/confirm-action/confirm-action-alert-dialog'
import { Button } from '@vitnode/core/components/ui/button'
```
```tsx
{
const result = await deleteCategoryApi(id)
if (result?.error) {
toast.error(tGlobal('errors.title'), {
description: tGlobal('errors.internal_server_error'),
})
return // Keep dialog open on error
}
toast.success(t('success'), {
description: title,
})
onClose() // Close dialog on success
}}
>
Delete Category
```
## Props [#props]
# Data Table
`ContentDataTable` is the core data table component powering VitNode AdminCP lists. Every user interaction - page navigation, column sorting, search queries, and filters - writes directly to the URL query string, making table state fully bookmarkable and shareable.
## Preview [#preview]
***
## Quick start [#quick-start]
Render the table inside a `DataTableNavigationProvider`:
```tsx title="plugins/members/src/views/members-table.tsx"
import type { ColumnDef } from '@vitnode/core/components/table/data-table-content'
import { ContentDataTable } from '@vitnode/core/components/table/content'
import { DataTableNavigationProvider } from '@vitnode/core/components/table/provider'
interface Member {
id: number
name: string
email: string
role: string
}
const columns: ColumnDef[] = [
{ accessorKey: 'name', header: 'Name', enableSorting: true },
{ accessorKey: 'email', header: 'Email' },
{ accessorKey: 'role', header: 'Role' },
]
interface MembersSearch {
cursor?: string
}
interface MembersTableProps {
data: Member[]
navigate: (options: { search: MembersSearch }) => Promise
search: MembersSearch
}
export const MembersTable = ({ data, navigate, search }: MembersTableProps) => {
return (
// [!code ++:13]
)
}
```
***
## Plugin Route Integration [#plugin-route-integration]
Give an AdminCP route to the plugin, then pass its typed `search` and `navigate`
props into the table. The table remains reusable and the host stays out of it.
```tsx title="plugins/members/src/pages/admin-members-page.tsx"
import type { PluginRoutePageProps } from '@vitnode/core/routing'
import { definePluginRoute } from '@vitnode/core/routing'
import { MembersTable } from '../views/members-table'
interface MembersSearch {
cursor?: string
}
export const route = definePluginRoute({
parseSearch: (input) => {
const search = input as Record
return {
cursor: typeof search.cursor === 'string' ? search.cursor : undefined,
}
},
load: async ({ search }) => await fetchMembers(search),
})
const AdminMembersPage = ({
loaderData,
navigate,
search,
}: PluginRoutePageProps) => (
)
export default AdminMembersPage
```
***
## Filters [#filters]
Add static or asynchronous dropdown filters to the table toolbar:
```tsx
```
***
## Bulk Selection & Actions [#bulk-selection--actions]
Enable row checkboxes and execute bulk operations with `useDataTableSelection`:
```tsx
import { useDataTableSelection } from '@vitnode/core/components/table/hooks/use-data-table-selection'
export const BulkActionsToolbar = () => {
const { selectedRows, resetSelection } = useDataTableSelection()
if (!selectedRows.length) return null
return (
{selectedRows.length} selected
deleteSelected(selectedRows)}>Delete
)
}
```
***
## `ContentDataTable` Props [#contentdatatable-props]
## Learn More [#learn-more]
# Dialog
## Preview [#preview]
## Usage [#usage]
```ts
import { Button } from '@vitnode/core/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@vitnode/core/components/ui/dialog'
```
```tsx
Open } />
Are you absolutely sure?
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
Cancel} />
Yes, delete account
```
## API Reference [#api-reference]
[Base UI - Dialog](https://base-ui.com/react/components/dialog#api-reference)
# Drawer
## Preview [#preview]
## Usage [#usage]
```ts
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@vitnode/core/components/ui/drawer'
```
```tsx
Open
Are you absolutely sure?
This action cannot be undone.
Submit
Cancel
```
## Documentation [#documentation]
[Vaul - Documentation](https://vaul.emilkowal.ski/getting-started)
# Dropdown Menu
## Preview [#preview]
## Usage [#usage]
```ts
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@vitnode/core/components/ui/dropdown-menu'
```
```tsx
}>
Open
My Account
Profile
⇧⌘P
Billing
⌘B
Settings
⌘S
Keyboard shortcuts
⌘K
Team
Invite users
Email
Message
More...
New Team
⌘+T
GitHub
Support
API
Log out
⇧⌘Q
```
## API Reference [#api-reference]
[Base UI - Menu](https://base-ui.com/react/components/menu#api-reference)
# Editor
## Preview [#preview]
## Usage [#usage]
```ts
import { z } from 'zod'
import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormEditor } from '@vitnode/core/components/form/fields/editor'
```
```ts
const formSchema = z.object({
content: z
.string()
.min(1, 'Content is required')
.default('Write your content here...
'),
})
```
```tsx
(
),
},
]}
/>
```
```ts
import { Editor } from '@vitnode/core/components/ui/editor'
```
The `Editor` is uncontrolled by default - pass `value` as the initial HTML and
listen for changes with `onChange`, which receives the current HTML string:
```tsx
const [content, setContent] = useState('Hello World! 🌎️
')
;
```
## Multi-language [#multi-language]
Set the `multiLang` prop to edit the content in every enabled language. A
language **select** (shown only when more than one language is enabled) switches
which language you are editing - it does **not** change the app-wide locale,
only the content inside this editor.
The value becomes an array matching the `core_languages_words` table:
```ts
;[
{ languageCode: 'en', value: 'Hello
' },
{ languageCode: 'pl', value: 'Cześć
' },
]
```
Declare the field with the `multiLangValueSchema` helper and enable `multiLang`:
```ts
import { multiLangValueSchema } from '@vitnode/core/lib/helpers/multi-lang'
const formSchema = z.object({
content: multiLangValueSchema({ minLength: 1 }).min(1),
})
```
```tsx
{
id: "content",
component: props => ,
}
```
Persist the array on the backend with `saveLanguageWords`. See
[Auto Form - Multi-language fields](/docs/ui/auto-form#multi-language-fields).
## Rendering Content [#rendering-content]
Use `EditorContent` to render the stored HTML as read-only content outside of
the editor (e.g. on a public page):
```ts
import { EditorContent } from '@vitnode/core/components/ui/editor-content'
```
```tsx
```
## Props [#props]
# useBeforeUnload
## Example [#example]
```tsx
import { useBeforeUnload } from '@vitnode/core/hooks/use-before-unload'
```
```tsx
export const CustomMessageExample = () => {
const [isEditing, setIsEditing] = useState(false)
// [!code ++:4]
useBeforeUnload(
isEditing,
'You have unsaved changes. Are you sure you want to leave?',
)
return (
setIsEditing(true)}>Start editing
Note: Modern browsers may not show the custom message
)
}
```
# useMobile
## Example [#example]
```tsx
import { useIsMobile } from '@vitnode/core/hooks/use-mobile'
```
```tsx
import { useIsMobile } from '@vitnode/core/hooks/use-mobile'
export const ExampleComponent = () => {
const isMobile = useIsMobile() // [!code ++]
return (
{isMobile ? (
You are using a mobile device.
) : (
You are using a desktop device.
)}
)
}
```
# Hover Card
## Preview [#preview]
## Usage [#usage]
```ts
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@vitnode/core/components/ui/hover-card'
```
```tsx
Hover
The React Framework - created and maintained by @vercel.
```
## API Reference [#api-reference]
[Base UI - Preview Card](https://base-ui.com/react/components/preview-card#api-reference)
# UI Components Overview
VitNode provides an accessible component suite built on [Base UI](https://base-ui.com) and styled with Tailwind CSS in the shadcn `base-vega` design language.
## Quick start [#quick-start]
Import components directly from `@vitnode/core/components/ui/*`:
```tsx title="src/components/action-button.tsx"
import { Button } from "@vitnode/core/components/ui/button"
import { ArrowRightIcon } from "lucide-react"
export const ActionButton = () => (
// [!code ++:4]
Get Started
)
```
No copy-pasting is required: all primitives and form components are exported ready-to-use from `@vitnode/core`.
***
## Component Layers [#component-layers]
| Layer | Import Path | Examples |
| :---------------- | :--------------------------------------- | :---------------------------------------------------- |
| **Primitives** | `@vitnode/core/components/ui/*` | `Button`, `Dialog`, `DropdownMenu`, `Card`, `Badge` |
| **Form Inputs** | `@vitnode/core/components/form/fields/*` | `AutoFormInput`, `AutoFormSelect`, `AutoFormCombobox` |
| **Complex Views** | `@vitnode/core/components/*` | `AutoForm`, `ContentDataTable` |
***
## Design Tokens & Semantic Styling [#design-tokens--semantic-styling]
VitNode adheres strictly to semantic Tailwind tokens for dark/light mode parity:
* **Backgrounds**: `bg-background`, `bg-card`, `bg-muted`, `bg-popover`
* **Text & Foreground**: `text-foreground`, `text-muted-foreground`, `text-primary`
* **Borders & Rings**: `border-border`, `ring-ring`
VitNode uses exactly 3-5 colors across the UI, avoiding prominent purple/violet styling in favor of modern, high-contrast semantic palettes.
## Key Components [#key-components]
# Input Group
## Preview [#preview]
## Usage [#usage]
```ts
import { z } from 'zod';
import { AutoForm } from '@vitnode/core/components/form/auto-form';
import { AutoFormInput } from '@vitnode/core/components/form/fields/input';
import { AutoFormTextarea } from '@vitnode/core/components/form/fields/textarea';
import { InputGroupAddon, InputGroupText } from "@vitnode/core/components/ui/input-group";
```
```ts
const formSchema = z.object({
search: z.string().min(1, 'Search is required'),
description: z
.string()
.max(500, 'Description must be less than 500 characters'),
})
```
```tsx
(
12 results
),
},
{
id: 'description',
component: (props) => (
{props.field.value?.toString().length ?? 0} of 500 characters
),
},
]}
/>
```
```ts
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@vitnode/core/components/ui/input-group";
import { Search } from "lucide-react";
```
```tsx
12 results
0 of 500 characters
```
## API Reference [#api-reference]
[Shadcn UI - Input Group](https://ui.shadcn.com/docs/components/radix/input-group#api-reference)
# Input
## Preview [#preview]
## Usage [#usage]
```ts
import { z } from 'zod';
import { AutoForm } from '@vitnode/core/components/form/auto-form';
import { AutoFormInput } from '@vitnode/core/components/form/fields/input';
```
```ts
const formSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
email: z.email('Please enter a valid email address'),
})
```
```tsx
(
),
},
{
id: 'email',
component: (props) => (
),
},
]}
/>
```
```ts
import { Input } from '@vitnode/core/components/ui/input';
```
```tsx
```
## Multi-language [#multi-language]
Set the `multiLang` prop to collect the value in every enabled language. The
field renders a language **select** (shown only when more than one language is
enabled) that switches which language you are editing - it does **not** change
the app-wide locale, only the value inside this input.
The value becomes an array matching the `core_languages_words` table, one entry
per language you edited:
```ts
;[
{ languageCode: 'en', value: 'Category' },
{ languageCode: 'pl', value: 'Kategoria' },
]
```
Declare the field with the `multiLangValueSchema` helper so the schema matches
the array shape (its `minLength` / `maxLength` apply to each language's value):
```ts
import { multiLangValueSchema } from '@vitnode/core/lib/helpers/multi-lang'
const formSchema = z.object({
name: multiLangValueSchema({ minLength: 1, maxLength: 255 }).min(1),
})
```
```tsx
{
id: "name",
component: props => ,
}
```
On the backend, persist the array with `saveLanguageWords` - it fills the
remaining `core_languages_words` columns. See
[Auto Form - Multi-language fields](/docs/ui/auto-form#multi-language-fields).
## Props [#props]
# Nullable Number
## Preview [#preview]
## Usage [#usage]
```ts
import { z } from 'zod'
import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormNullableNumber } from '@vitnode/core/components/form/fields/nullable-number'
```
```ts
const formSchema = z.object({
max_members: z.number().int().min(1).nullable().default(10),
})
```
```tsx
(
),
},
]}
/>
```
The field value is `number | null`. A number is whatever is typed in the
input; `null` means the checkbox is checked and the input is disabled. Back it
with a `z.number().nullable()` schema, and keep it optional or give it a
`default` when the field can be
[hidden](/docs/ui/auto-form#conditional-fields) so it never blocks submission.
Unchecking the box restores the last number you entered.
## Adapting the labels [#adapting-the-labels]
The three label props are plain text, so the same field works for any domain -
an unlimited storage cap, a session that never expires, an uncapped rate limit,
and so on:
* `unitLabel` - shown right after the input (e.g. `kB`, `minutes`, `%`).
* `orLabel` - an optional connector rendered before the checkbox (e.g. `or`).
* `toggleLabel` - the checkbox label; checking it sets the value to `null`.
```tsx
```
Any other props (`min`, `max`, `step`, `placeholder`, …) are forwarded to the
underlying number input; validation constraints come from the Zod schema.
## Props [#props]
# Popover
## Preview [#preview]
## Usage [#usage]
```ts
import { Button } from '@vitnode/core/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@vitnode/core/components/ui/popover'
```
```tsx
Open} />
Place content for the popover here.
```
## API Reference [#api-reference]
[Base UI - Popover](https://base-ui.com/react/components/popover#api-reference)
# Progress
## Preview [#preview]
## Usage [#usage]
```ts
import { Progress } from '@vitnode/core/components/ui/progress'
```
```tsx
```
## API Reference [#api-reference]
[Base UI - Progress](https://base-ui.com/react/components/progress#api-reference)
# Radio Group
## Preview [#preview]
## Usage [#usage]
```ts
import { z } from 'zod';
import { AutoForm } from '@vitnode/core/components/form/auto-form';
import { AutoFormRadioGroup } from '@vitnode/core/components/form/fields/radio-group';
```
```ts
const formSchema = z.object({
options: z.enum(['option1', 'option2', 'option3']),
})
```
```tsx
(
),
},
]}
/>
```
```ts
import {
RadioGroup,
RadioGroupItem,
} from '@vitnode/core/components/ui/radio-group';
import { Field, FieldLabel } from "@vitnode/core/components/ui/field"
```
```tsx
Option One
This is the description for option one. It provides more information
about the option.
Option Two
Option Three
This is the description for option three. It provides more information
about the option.
```
### variant="block" [#variantblock]
You can also use the `block` variant to display the radio buttons in a block style.
```tsx
(
),
},
]}
/>
```
## Props [#props]
## API Reference [#api-reference]
[Base UI - Radio Group](https://base-ui.com/react/components/radio#api-reference)
# Roles
## Preview [#preview]
## Usage [#usage]
```ts
import { z } from 'zod'
import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormRoles } from '@vitnode/core/components/form/fields/input-roles'
```
One component covers both shapes, because the difference is the value and
nothing else - the search, the colour, the language resolution and the empty
state are identical, and two copies is how they drift.
```ts
const formSchema = z.object({
roleId: z.number(),
})
```
```tsx
(
),
},
]}
/>
```
The value is a single id, and picking again replaces it.
```ts
const formSchema = z.object({
roleIds: z.array(z.number()).min(1),
})
```
```tsx
(
),
},
]}
/>
```
The value is an array of ids. Chosen roles appear above the picker as removable
chips, and the picker **appends** rather than replaces - picking one that is
already chosen removes it, which is what the tick beside it in the list means.
Out of the box it searches the AdminCP roles list. The **guest** role is never
offered: it is the role a request has when it has no account, so it is not
something to assign to anybody.
## Editing an existing record [#editing-an-existing-record]
Same rule as [User](/docs/ui/user): the picker can only name ids it has seen, so
an edit form passes the roles it already knows about.
```tsx
```
## Keeping a role out of the list [#keeping-a-role-out-of-the-list]
`excludeIds` drops options another field already owns - a primary-role picker
and a secondary-role picker should not both offer the same one:
```tsx
```
## Names are resolved per reader [#names-are-resolved-per-reader]
A role carries one name per language. The field renders the active locale's, and
falls back to the first translation rather than to the id - a role with no
translation in your language is still a role somebody named:
```ts
import { roleOptionName } from '@vitnode/core/components/form/fields/input-roles'
roleOptionName(role, 'pl') // "Administrator PL", or the first name it has
```
## Props [#props]
| Prop | Type | Default | What it does |
| ------------------- | ------------------------------------------ | ------------------ | -------------------------------------------- |
| `multiple` | `boolean` | `false` | `number[]` instead of `number`, with chips |
| `label` | `ReactNode` | - | Field label |
| `description` | `ReactNode` | - | Help text under the control |
| `placeholder` | `string` | `Select an option` | Shown while nothing is chosen |
| `searchPlaceholder` | `string` | `Search...` | Placeholder inside the search box |
| `selected` | `RoleOption[]` | `[]` | Roles the field opens on |
| `excludeIds` | `number[]` | `[]` | Roles the picker must not offer |
| `search` | `(value: string) => Promise` | AdminCP roles list | Replaces the lookup |
| `disabled` | `boolean` | `false` | Blocks opening the picker and removing chips |
`RoleOption` is `{ id, color, name }`, where `name` is the raw
`{ languageCode, name }[]` - the server has no business deciding which language
the person clicking reads in.
## See also [#see-also]
* [User](/docs/ui/user) - the same idea for people.
* [Combobox](/docs/ui/combobox) - when the options are strings rather than records.
# Scroll Area
## Preview [#preview]
## Usage [#usage]
```ts
import { ScrollArea } from '@vitnode/core/components/ui/scroll-area'
import { Separator } from '@vitnode/core/components/ui/separator'
import React from 'react'
```
```tsx
const tags = Array.from({ length: 50 }).map(
(_, i, a) => `v1.2.0-beta.${a.length - i}`,
)
export function ScrollAreaDemo() {
return (
Tags
{tags.map((tag) => (
{tag}
))}
)
}
```
## API Reference [#api-reference]
[Base UI - Scroll Area](https://base-ui.com/react/components/scroll-area#api-reference)
# Select
## Preview [#preview]
## Usage [#usage]
```ts
import { z } from 'zod'
import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormSelect } from '@vitnode/core/components/form/fields/select'
```
```ts
const formSchema = z.object({
options: z.enum(['option1', 'option2', 'option3']).default('option1'),
})
```
```tsx
(
),
},
]}
/>
```
```ts
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@vitnode/core/components/ui/select';
```
```tsx
const items = [
{ label: 'Option One', value: 'option-one' },
{ label: 'Option Two', value: 'option-two' },
]
;
{items.map((item) => (
{item.label}
))}
```
> Pass an `items` array to `Select` so `SelectValue` can render the selected
> option's label. Without it, the raw `value` is displayed instead.
## Props [#props]
## API Reference [#api-reference]
[Base UI - Select](https://base-ui.com/react/components/select#api-reference)
# Separator
## Preview [#preview]
## Usage [#usage]
```ts
import { Separator } from '@vitnode/core/components/ui/separator'
```
```tsx
Base UI
An open-source UI component library.
```
## API Reference [#api-reference]
[Base UI - Separator](https://base-ui.com/react/components/separator#api-reference)
# Sheet
## Preview [#preview]
## Usage [#usage]
```ts
import { Button } from '@vitnode/core/components/ui/button'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@vitnode/core/components/ui/sheet'
```
```tsx
Open} />
Are you absolutely sure?
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
Yes, delete account
Cancel} />
```
## API Reference [#api-reference]
[Base UI - Dialog](https://base-ui.com/react/components/dialog#api-reference)
# Skeleton
## Preview [#preview]
## Usage [#usage]
```ts
import { Skeleton } from '@vitnode/core/components/ui/skeleton'
```
```tsx
```
# Sonner
## Preview [#preview]
## Usage [#usage]
```ts
import { toast } from 'sonner'
```
```ts
toast('Event has been created', {
description: 'Sunday, December 03, 2023 at 9:00 AM',
action: {
label: 'Undo',
onClick: () => console.log('Undo'),
},
})
```
```ts
toast.error('An error occurred.')
```
```ts
toast.success('Operation was successful.')
```
```ts
toast.info('Here is some information.')
```
```ts
toast.warning('This is a warning.')
```
## Documentation [#documentation]
[Sonner Documentation](https://sonner.emilkowal.ski/)
# Switch
## Preview [#preview]
## Usage [#usage]
```ts
import { z } from 'zod'
import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormSwitch } from '@vitnode/core/components/form/fields/switch'
```
```ts
const formSchema = z.object({
acceptTerms: z.boolean().refine((val) => val, {
message: 'You must accept the terms and conditions',
}),
})
```
```tsx
(
),
},
]}
/>
```
```ts
import { Switch } from '@vitnode/core/components/ui/switch';
```
```tsx
```
## API Reference [#api-reference]
[Base UI - Switch](https://base-ui.com/react/components/switch#api-reference)
# Textarea
## Preview [#preview]
## Usage [#usage]
```ts
import { z } from 'zod'
import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormTextarea } from '@vitnode/core/components/form/fields/textarea'
```
```ts
const formSchema = z.object({
desc: z.string().min(10, 'Description must be at least 10 characters'),
})
```
```tsx
(
),
},
]}
/>
```
```ts
import { Textarea } from '@vitnode/core/components/ui/textarea';
```
```tsx
```
## Props [#props]
# Toggle Group
## Preview [#preview]
## Usage [#usage]
```ts
import { Bold, Italic, Underline } from 'lucide-react'
import {
ToggleGroup,
ToggleGroupItem,
} from '@vitnode/core/components/ui/toggle-group'
```
```tsx
```
## API Reference [#api-reference]
[Base UI - Toggle Group](https://base-ui.com/react/components/toggle-group)
# Toggle
## Preview [#preview]
## Usage [#usage]
```ts
import { Bold } from 'lucide-react'
import { Toggle } from '@vitnode/core/components/ui/toggle'
```
```tsx
```
## API Reference [#api-reference]
[Base UI - Toggle](https://base-ui.com/react/components/toggle)
# Tooltip
## Preview [#preview]
## Usage [#usage]
```ts
import { Button } from '@vitnode/core/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@vitnode/core/components/ui/tooltip'
```
```tsx
Hover} />
Add to library
```
### Faster Tooltips [#faster-tooltips]
If you don't need custom props for components like `TooltipTrigger` or `TooltipContent`, you can use the `TooltipWithContent` component to enable faster tooltips.
```ts
import { Button } from '@vitnode/core/components/ui/button'
import { TooltipWithContent } from '@vitnode/core/components/ui/tooltip'
import { PlayIcon } from 'lucide-react'
```
```tsx
```
## API Reference [#api-reference]
[Base UI - Tooltip](https://base-ui.com/react/components/tooltip)
# Typography
We're working hard to bring you the best documentation experience.
| Style | Example |
| --------- | --------------------------------------------------------------------- |
| Heading 1 | Heading 1 |
| Heading 2 | Heading 2 |
| Heading 3 | Heading 3 |
| Paragraph | This is a paragraph with base text size.
|
# User
## Preview [#preview]
## Usage [#usage]
```ts
import { z } from 'zod'
import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormUser } from '@vitnode/core/components/form/fields/input-users'
```
The value is the **user id**, so the schema is a plain number and the payload
needs no unwrapping:
```ts
const formSchema = z.object({
authorId: z.number(),
})
```
```tsx
(
),
},
]}
/>
```
Out of the box it searches the AdminCP users list, which means it answers with
whatever that route lets the signed-in admin see - the permission check lives
there and is not repeated in the component.
## Editing an existing record [#editing-an-existing-record]
A picker cannot show a name it has never fetched. An edit form starts holding an
id, so pass the person it already knows about as `selected`:
```tsx
```
Without it the field opens on the placeholder, as though nobody were chosen.
Whatever the search returns afterwards is remembered on top of that, so a person
picked a moment ago still reads as their name.
A nullable author is `z.number().nullable()`. The field renders the
placeholder for `null` and never invents a value - clearing one is up to your
own control, because "no author" and "author not chosen yet" are the same
state here.
## Searching somewhere else [#searching-somewhere-else]
`search` replaces the lookup entirely - a plugin scoping to its own members, a
different endpoint, or fixtures in a test:
```tsx
await searchReviewers(value)}
/>
```
It runs on every open with an empty string, and again - debounced - as the
person types. That is deliberate: the list is a live view of who exists, and a
cached one offers somebody who was deleted since.
## Props [#props]
| Prop | Type | Default | What it does |
| ------------------- | ------------------------------------------ | ------------------ | ------------------------------------------------ |
| `label` | `ReactNode` | - | Field label |
| `description` | `ReactNode` | - | Help text under the control |
| `placeholder` | `string` | `Select an option` | Shown while nothing is chosen |
| `searchPlaceholder` | `string` | `Search...` | Placeholder inside the search box |
| `selected` | `PartialUserOption \| null` | - | The person the field opens on |
| `search` | `(value: string) => Promise` | AdminCP users list | Replaces the lookup |
| `clearable` | `boolean` | `false` | Adds a button that sets the field back to nobody |
| `disabled` | `boolean` | `false` | Blocks opening the picker |
`UserOption` is `{ id, name, nameCode, avatarColor }` - the columns it takes to
recognise a person on sight. `selected` accepts a **partial** one, because the
caller often knows only an id and a name.
A generated avatar needs a colour, and that is the column a caller who resolved
only a *name* does not have. Rather than invent one - the wrong colour reads as a
different person - the field draws a neutral placeholder in the same box, so the
name stays where it is when a search fills the real avatar in.
## In the Content Engine [#in-the-content-engine]
A [`field.user()`](/docs/dev/content-engine/fields) renders this field
automatically - the author picker on a blog post is this component. Its options
come from the content type's own picker route rather than from the users list,
so an editor who may write articles can choose an author without also being
trusted to browse the member list.
## See also [#see-also]
* [Roles](/docs/ui/roles) - the same idea for roles, single or multiple.
* [Combobox](/docs/ui/combobox) - when the options are strings rather than people.