Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 69 additions & 65 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,97 +3,101 @@
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/VitNode/vitnode/canary/assets/logo/vitnode_logo_dark.svg">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/VitNode/vitnode/canary/assets/logo/vitnode_logo_light.svg">
<img alt="VitNode Logo" src="https://raw.githubusercontent.com/VitNode/vitnode/canary/assets/logo/vitnode_logo_light.svg" width="250">
<img alt="VitNode" src="https://raw.githubusercontent.com/VitNode/vitnode/canary/assets/logo/vitnode_logo_light.svg" width="250">
</picture>
</a>
</p>

# 🚀 VitNode
# VitNode

**VitNode** is an extendable framework for building modern applications with TanStack Start and Hono.js. It provides a structured, plugin-based architecture that makes development faster and less complex.
VitNode is a plugin-first framework for community applications. It combines a
TanStack Start front end, Hono API, Postgres, and AdminCP so features can ship as
installable plugins instead of becoming permanent residents of one giant app.

> [!NOTE]
> 🚧 You're viewing the `canary` branch (VitNode 2.0), which is under active development and may contain unstable code. For the stable version, check the `v1` branch.
> This is the VitNode 2.0 `canary` branch. It is actively developed, so use the
> docs and source together while it keeps getting sharper.

## 🏁 Getting Started
## Start here

### Supported Package Managers
You need Node.js 22+ and Postgres (or Docker). Create an app with the package
manager you use every day:

- [bun](https://bun.com/) (min: v1.1, recommended: v1.3)
- [pnpm](https://pnpm.io/) (min: v10, recommended: v11)
- [node.js](https://nodejs.org/) (min: v22, recommended: v24)
### Bun

### Quick Setup
```bash
bun create vitnode-app@canary
```

1. **Install dependencies**
### pnpm

```bash
pnpm create vitnode-app@canary
// or
bun create vitnode-app@canary
// or
npx create-vitnode-app@canary
```
```bash
pnpm create vitnode-app@canary
```

2. **Start database container**
### npm

```bash
pnpm docker:dev
// or
bun docker:dev
// or
npm run docker:dev
```
```bash
npm create vitnode-app@canary
```

3. **Launch development server**
```bash
pnpm dev
// or
bun dev
// or
npm run dev
```
When prompted, choose **Turborepo** if you plan to build plugins. VitNode puts
product pages, APIs, data, translations, and AdminCP extensions in plugins first.

## 📝 Available Scripts
Start local services, migrate, and run the app:

- `pnpm dev` - Start development server with auto-reload
- `pnpm build` - Build for production
- `pnpm start` - Start production server
- `pnpm lint` - Check code quality
- `pnpm lint:fix` - Fix code quality issues
- `pnpm db:migrate` - Run database migrations by hand (`pnpm dev` already does this for you)
- `pnpm dev:email` - Start email development server
### Bun

## ✨ What's New in VitNode 2.0
```bash
bun run docker:dev
bun run db:migrate
bun dev
```

- **Simplified Architecture**: Single-repo application structure (no monorepo)
- **Modern Backend**: Hono.js replaces NestJS for better performance
- **ESM-Only**: Full support for ECMAScript Modules
- **AI Integration**: New AI Rules and Multi-Cloud Provider support
- **Enhanced Plugin System**: Improved CLI tools for plugins
- **Better Documentation**: Completely rewritten docs and website
- **Streamlined Configuration**: Single config file for all settings
- **Zod 4**: Upgraded to the latest version for schema validation
### pnpm

## 🔍 Project Scope
```bash
pnpm docker:dev
pnpm db:migrate
pnpm dev
```

VitNode provides:
### npm

- **Plugin Architecture**: Extend core functionality with custom plugins
- **Admin Control Panel**: Built-in management interface
- **Authentication System**: Support for credentials and SSO providers
- **Role-Based Access Control**: Comprehensive permission management
- **Internationalization**: Multi-language support out of the box
- **Theme System**: Light/dark mode with customizable components
- **API Documentation**: Auto-generated OpenAPI documentation
```bash
npm run docker:dev
npm run db:migrate
npm run dev
```

## 📊 Project Status
Open `http://localhost:3000`, then sign in at `/admin`.

VitNode 2.0 is currently in **active development** (canary branch). While many features are functional, expect changes and improvements as we work toward a stable release.
## Build features as plugins

> [!NOTE]
> 📚 Documentation is still in progress. Our website is under construction!
1. [Create a plugin](https://vitnode.com/docs/dev/plugins/create).
2. Give it a route, API module, data model, or AdminCP screen.
3. Register the package in the host app’s configuration.
4. Deploy from the [Start here](https://vitnode.com/docs/dev/deployments/self-hosted) documentation.

The host app owns composition and global infrastructure. The plugin owns the
feature. That boundary pays rent surprisingly quickly.

## Documentation

- [Getting started](https://vitnode.com/docs/dev/setup)
- [Build your first plugin](https://vitnode.com/docs/guides/first-plugin)
- [Plugin routes](https://vitnode.com/docs/dev/routing)
- [Admin Control Panel](https://vitnode.com/docs/dev/plugins/admin)
- [Content delivery and SEO](https://vitnode.com/docs/dev/content-engine/content-delivery-and-seo)
- [Write documentation](https://vitnode.com/docs/dev/documentation)

## Project scope

- Plugin architecture with TanStack Start routes and typed Hono API modules
- Postgres data models, migrations, search, uploads, and content delivery
- Built-in authentication, roles, staff permissions, i18n, and AdminCP
- Self-hosted and cloud deployment guidance

## 📄 License
## License

MIT License
35 changes: 20 additions & 15 deletions apps/web/content/docs/dev/cache.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,23 @@ cache: a route's `loader` warms an entry, the component reads the same one back,
and a mutation invalidates exactly what it changed. One request per navigation
instead of one per component.

### Warm it in the loader, read it in the screen
### Load it from the plugin route

```ts title="src/routes/announcements.tsx"
export const Route = createFileRoute('/announcements')({
loader: async ({ context }) =>
await context.queryClient.ensureQueryData(announcementsQueryOptions()),
```ts title="plugins/announcements/src/routes/announcements-page.tsx"
import { definePluginRoute } from '@vitnode/core/routing'

export const route = definePluginRoute({
load: async () => await fetchAnnouncements(), // [!code ++]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Seed the query that the screen reads

When this example is followed, the loader's response is stored only as plugin loaderData, while the screen ignores that value and starts a separate useSuspenseQuery; the loader therefore does not warm the advertised query key and SSR or navigation can fetch the announcements twice. Seed the same query entry before rendering, or pass the loader result to the query as initial data/read loaderData directly.

AGENTS.md reference: AGENTS.md:L44-L44

Useful? React with 👍 / 👎.

})
```

```tsx title="announcements-screen.tsx"
const { data } = useSuspenseQuery(announcementsQueryOptions())
```

The `queryOptions` object is what the two halves share - the same key, the same
fetcher, the same `staleTime` - so the screen can never ask for something the
loader did not warm. See [Data loading](/docs/dev/data-loading) for the
isomorphic fetcher that sits behind it, and [Loading
states](/docs/dev/routing/loading-states) for what the screen shows while an
entry is still cold.
Keep the fetcher and any `queryOptions` helper inside the plugin too. The plugin
route is the SSR boundary; its screen can reuse the same query key for client
updates. See [Data loading](/docs/dev/data-loading) for the isomorphic fetcher.

### Pick a lifetime that matches the data

Expand All @@ -55,12 +53,17 @@ can sit for minutes; anything per-visitor should be short or zero:
```ts
export const announcementsQueryOptions = () =>
queryOptions({
queryKey: ['announcements'],
queryKey: ['@acme/announcements', 'announcements'], // [!code ++]
queryFn: fetchAnnouncements,
staleTime: 5 * 60 * 1000,
})
```

<Callout type="warn" title="Start every key with your plugin ID">
`invalidateQueries` matches prefixes. Put the plugin ID first so one plugin
cannot read from or invalidate another plugin's similarly named key.
</Callout>

<Callout type="warn" title="Per-visitor data is per-visitor">
A session, a permission set or a personal file list must not share a cache
entry with anybody else. Key it by the identity it belongs to, and keep the
Expand All @@ -81,7 +84,9 @@ moved:
const queryClient = useQueryClient()

await publishAnnouncement(id)
await queryClient.invalidateQueries({ queryKey: ['announcements'] })
await queryClient.invalidateQueries({
queryKey: ['@acme/announcements', 'announcements'], // [!code ++]
})
```

A row that could be on any page under any sort means invalidating the list's
Expand Down Expand Up @@ -112,7 +117,7 @@ with another plugin's.
`remember` takes a key, a TTL in seconds, and the loader to run on a miss. It
returns the value either way, so the call site never branches:

```ts title="src/api/modules/stats/routes/overview.route.ts"
```ts title="plugins/announcements/src/api/modules/stats/routes/overview.route.ts"
handler: async c => {
// [!code ++:5]
const stats = await c.get('cache').remember(
Expand All @@ -137,7 +142,7 @@ that is a miss you cannot afford.
The write that changes the answer is the write that expires it. There is no TTL
short enough to substitute for this:

```ts title="src/api/modules/stats/routes/update.route.ts"
```ts title="plugins/announcements/src/api/modules/stats/routes/update.route.ts"
await c.get('cache').delete(`stats:${containerId}`) // [!code ++]
```

Expand Down
35 changes: 10 additions & 25 deletions apps/web/content/docs/dev/captcha/custom-adapter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { TypeTable } from 'fumadocs-ui/components/type-table'

Three values, and a `<div>` for the widget to land in.

```tsx title="src/site/contact/contact-form.tsx"
```tsx title="plugins/contact/src/views/contact/contact-form.tsx"
import type React from 'react'

import { useCaptcha } from '@vitnode/core/hooks/use-captcha' // [!code ++]
Expand Down Expand Up @@ -129,31 +129,16 @@ export const createContactRoute = buildRoute({
</Step>
<Step>

### Read the deployment's config
### Read the deployment's config in the plugin screen

The site key is public, and `middlewareConfigQueryOptions()` is the read as a
TanStack Query definition. Warm it in the route's `loader` so the widget is not
waiting on a request after hydration.
The site key is public. A plugin page can read it with the shared TanStack Query
definition before rendering its form:
Comment on lines +134 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Warm the captcha configuration before rendering

Keep a loader-backed read for middlewareConfigQueryOptions() instead of reading it cold only from this plugin component. On the first visit, this useSuspenseQuery has no warmed cache entry, so the entire plugin page suspends while making an extra render-time request; the previous example explicitly used ensureQueryData in the route loader to avoid that behavior.

AGENTS.md reference: AGENTS.md:L44-L44

Useful? React with 👍 / 👎.


```tsx title="src/routes/_main/contact.tsx"
import { createFileRoute } from '@tanstack/react-router'
import { middlewareConfigQueryOptions } from '@vitnode/core/tanstack/auth' // [!code ++]

import { ContactScreen } from '#/site/contact/contact-screen'

export const Route = createFileRoute('/_main/contact')({
// [!code ++:2]
loader: async ({ context }) =>
await context.queryClient.ensureQueryData(middlewareConfigQueryOptions()),
component: ContactScreen,
})
```

```tsx title="src/site/contact/contact-screen.tsx"
```tsx title="plugins/contact/src/routes/contact-page.tsx"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Default-export the converted captcha plugin page

In this newly converted plugin-route snippet, contact-page.tsx only exports the named ContactScreen. A manifest entry loading this module will be rejected because plugin page modules require their component as the default export, so readers following the example cannot mount the captcha page.

Useful? React with 👍 / 👎.

import { useSuspenseQuery } from '@tanstack/react-query'
import { middlewareConfigQueryOptions } from '@vitnode/core/tanstack/auth'

import { ContactForm } from './contact-form'
import { ContactForm } from '../views/contact-form'

export const ContactScreen = () => {
const { data: config } = useSuspenseQuery(middlewareConfigQueryOptions())
Expand Down Expand Up @@ -243,11 +228,11 @@ A minimal captcha-gated feature is four files, and only one of them knows the
hook exists.

<Files>
<Folder defaultOpen name="src">
<Folder defaultOpen name="routes/_main">
<File name="contact.tsx" />
<Folder defaultOpen name="plugins/contact/src">
<Folder defaultOpen name="routes">
<File name="contact-page.tsx" />
</Folder>
<Folder defaultOpen name="site/contact">
<Folder defaultOpen name="views/contact">
<File name="contact-screen.tsx" />
<File name="contact-form.tsx" />
<File name="send-message.ts" />
Expand Down
Loading
Loading