Quickstart
Build a small ARDOR admin by hand: one datasource, one repository, an application configuration, a resource page, and a login page. You write eight files and run them against a stub API.
TIP
Want a running admin without writing these files? Create a project generates one with a single command. This page is the manual route, to show what each piece does.
NOTE
Start from the Vite react-ts project of Setup: ARDOR installed, TypeScript set up for decorators, and the @/* path alias that the imports below use. Step 7 uses build.rolldownOptions, which needs Vite 8 or newer (bun create vite installs the latest), and Setup installs @venizia/ignis-connectors, which @venizia/ardor/repository needs.
Step 7 changes how the project builds, so do it first if you want the files to compile as you add them. You also need a REST API for the data provider: the examples talk to the runnable stub API at examples/5-mins-qs/api/server.ts.
1. The datasource
Create src/datasources/api.datasource.ts:
import { datasource, DefaultNetworkRequestService } from '@venizia/ardor';
import { HttpDataSource } from '@venizia/ardor/repository';
// Declared, never listed: the build finds every decorated class under src/ and the application binds
// it. A relative baseUrl resolves against the page, and Vite proxies `/api` to the stub API.
@datasource()
export class ApiDataSource extends HttpDataSource {
constructor() {
super({
baseUrl: '/api',
authTokenResolver: DefaultNetworkRequestService.readAuthTokenFromStorage,
});
}
}src/datasources/api.datasource.ts defines the transport for API requests. @datasource() registers the class with the container, and baseUrl: '/api' sets a relative path that Vite proxies to the backend. It uses DefaultNetworkRequestService.readAuthTokenFromStorage to attach stored authentication tokens to outgoing requests. To send the same headers as the data provider and share its 401 refresh, spread getDataSourceAuth() instead: see Resources and pages.
2. The repository
Create src/repositories/product.repository.ts:
import { repository, RepositoryTypes } from '@venizia/ardor';
import { HttpRepository } from '@venizia/ardor/repository';
import { ApiDataSource } from '@/datasources/api.datasource';
export interface IProduct {
id: number;
name: string;
price: number;
}
// One class per resource, in the IGNIS filter vocabulary. `@repository` injects the datasource, and
// `count` reads the total from `Content-Range`.
@repository({ type: RepositoryTypes.REMOTE, dataSource: ApiDataSource })
export class ProductRepository extends HttpRepository<IProduct> {
constructor(dataSource: ApiDataSource) {
super({ dataSource, resource: 'products' });
}
countExpensive(opts: { minimumPrice: number }) {
return this.count({ where: { price: { gte: opts.minimumPrice } } });
}
}src/repositories/product.repository.ts provides typed data access for the products resource. @repository({ type: RepositoryTypes.REMOTE, dataSource: ApiDataSource }) injects ApiDataSource into the constructor, and RepositoryTypes.REMOTE specifies that the repository operates without an in-memory model. countExpensive queries the API with a filter and reads the total count from the Content-Range response header.
3. The configuration
Create src/configurations/admin.configuration.ts:
import {
type BaseArdorApplication as TArdorApplication,
configuration,
CoreBindings,
DefaultAuthProvider,
DefaultAuthService,
DefaultI18nProvider,
DefaultRestDataProvider,
englishMessages,
inject,
provide,
} from '@venizia/ardor';
// The message keys this application adds, made known to `useTranslate`.
declare module '@venizia/ardor' {
interface IUseTranslateKeysOverrides {
'quickstart.title': unknown;
'quickstart.expensive': unknown;
}
}
// Every framework binding, declared. start() binds each @provide method under its key, SINGLETON,
// built on first use: react-admin, the auth provider and every hook share one data provider - its
// token, headers and single 401 refresh.
@configuration()
export class AdminConfiguration {
constructor(
@inject({ key: CoreBindings.APPLICATION_INSTANCE })
private readonly application: TArdorApplication,
) {}
@provide({ key: CoreBindings.REST_DATA_PROVIDER_OPTIONS })
restDataProviderOptions() {
return { url: '/api', noAuthPaths: ['/auth/sign-in'] };
}
@provide({ key: CoreBindings.AUTH_PROVIDER_OPTIONS })
authProviderOptions() {
// The sign-in path and body default to IGNIS's auth controller: POST /auth/sign-in.
return { paths: { afterLogin: '/products' } };
}
@provide({ key: CoreBindings.I18N_PROVIDER_OPTIONS })
i18nProviderOptions() {
return {
i18nSources: {
en: {
...englishMessages,
quickstart: { title: 'Products', expensive: 'Expensive products (>= 5,000)' },
},
},
languages: [{ locale: 'en', name: 'English' }],
};
}
@provide({ key: CoreBindings.DEFAULT_REST_DATA_PROVIDER })
restDataProvider() {
return this.application.instantiate(DefaultRestDataProvider).value(this.application);
}
@provide({ key: CoreBindings.DEFAULT_AUTH_SERVICE })
authService() {
return this.application.instantiate(DefaultAuthService);
}
@provide({ key: CoreBindings.DEFAULT_AUTH_PROVIDER })
authProvider() {
return this.application.instantiate(DefaultAuthProvider).value(this.application);
}
@provide({ key: CoreBindings.DEFAULT_I18N_PROVIDER })
i18nProvider() {
return this.application.instantiate(DefaultI18nProvider).value(this.application);
}
}src/configurations/admin.configuration.ts declares the framework providers and options. Each @provide() method binds a service or provider as a singleton on first access, sharing a single data provider across react-admin, the authentication provider, and hooks. See Configuration for how configuration classes and @provide work.
4. The application
Create src/application.ts:
import 'reflect-metadata';
import AutoArtifacts from '@venizia/ignis-boot/auto';
import { BaseArdorApplication, type IApplicationInfo, type IArtifactIndex } from '@venizia/ardor';
// The decorated classes live in their own files and this module imports none of them: the build lists
// every one under src/ in `AutoArtifacts`, and importing it here keeps the list out of any shared kit.
export class Application extends BaseArdorApplication {
getAppInfo(): IApplicationInfo {
return { name: 'quickstart', version: '0.0.0', description: 'ARDOR 5-minute quickstart' };
}
override artifacts(): Array<IArtifactIndex> {
return [AutoArtifacts];
}
bindContext(): void {}
}src/application.ts creates the application container by extending BaseArdorApplication. Its artifacts() method returns AutoArtifacts, which supplies the list of decorated classes discovered at build time.
WARNING
The module that imports AutoArtifacts must not define decorated classes or be imported by them. Violating this rule creates an import cycle that leads to [artifacts] '<kind>' lists an entry that is not a class (position N) during startup. Keep each decorated class in its own file and keep application.ts free of decorated classes.
5. Pages
Create src/pages/product-list.tsx:
import { useRepository, useTranslate } from '@venizia/ardor';
import { useEffect, useState } from 'react';
import { useListContext, useLogout } from 'ra-core';
import { type IProduct, ProductRepository } from '@/repositories/product.repository';
// react-admin's list context holds the page the data provider fetched; the repository answers a
// question the page alone cannot.
export const ProductList = () => {
const { data, total, isPending } = useListContext<IProduct>();
const translate = useTranslate();
const logout = useLogout();
const products = useRepository({ target: ProductRepository });
const [expensive, setExpensive] = useState(0);
useEffect(() => {
products
.countExpensive({ minimumPrice: 5000 })
.then(({ count }) => setExpensive(count))
.catch((error: unknown) => {
console.error('[ProductList] countExpensive failed | error: %s', error);
});
}, [products]);
if (isPending) {
return <p>Loading</p>;
}
return (
<main>
<h1>
{translate('quickstart.title')} <small data-testid="total">({total} total)</small>
</h1>
<button type="button" onClick={() => logout()}>
Log out
</button>
<ul data-testid="products">
{(data ?? []).map((product) => (
<li key={product.id}>
{product.name} - {product.price}
</li>
))}
</ul>
<h2>{translate('quickstart.expensive')}</h2>
<p data-testid="expensive">{expensive}</p>
</main>
);
};src/pages/product-list.tsx renders the product listing from the page of products that useListContext holds (data, total, isPending), and logs out with useLogout. It resolves ProductRepository by class with useRepository() to fetch the expensive products count independently. useTranslate() localizes labels using keys registered in the configuration.
Create src/pages/login.tsx:
import { useState, type FormEvent } from 'react';
import { useLogin, useNotify } from 'ra-core';
export const LoginPage = () => {
const login = useLogin();
const notify = useNotify();
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('admin');
const submit = (event: FormEvent) => {
event.preventDefault();
login({ username, password }).catch(() => notify('ra.auth.sign_in_error', { type: 'error' }));
};
return (
<form onSubmit={submit}>
<h1>Sign in</h1>
<label>
Username{' '}
<input
name="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label>
Password{' '}
<input
name="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<button type="submit">Sign in</button>
</form>
);
};src/pages/login.tsx provides the sign-in form rendered when unauthenticated users access protected routes. It uses useLogin() from ra-core to submit credentials, which DefaultAuthProvider converts into the backend authentication request.
6. Mount the application
Create src/main.tsx:
import 'reflect-metadata';
import { configureStore } from '@reduxjs/toolkit';
import { ArdorApplication } from '@venizia/ardor';
import { createRoot } from 'react-dom/client';
import { ListBase } from 'ra-core';
import { Application } from '@/application';
import { LoginPage } from '@/pages/login';
import { ProductList } from '@/pages/product-list';
const application = new Application();
const store = configureStore({
reducer: { quickstart: (state: { ready: boolean } = { ready: true }) => state },
});
// `resource.list` is what react-admin renders for `/products`; `ListBase` runs the data provider.
const ProductsPage = () => (
<ListBase resource="products" perPage={10}>
<ProductList />
</ListBase>
);
await application.start();
createRoot(document.getElementById('root')!).render(
<ArdorApplication
container={application}
reduxStore={store}
suspense={<p>Loading</p>}
loginPage={LoginPage}
resources={[{ name: 'products', list: ProductsPage }]}
/>,
);src/main.tsx starts the application container and mounts the React root. reduxStore is optional in IApplication; passed, it goes to the Redux provider. await application.start() binds the application, its info, the discovered classes and their @provide methods, then your bindContext() (the full order is in Application). ArdorApplication receives the started container, wraps the tree in providers, and renders the products resource.
7. Configure Vite
Replace the template's vite.config.ts at the project root with this one. bun run dev and bun run build read that file, so a config kept anywhere else never runs, and Vite cannot resolve the decorated classes. (The example repository keeps its copy at .configs/builders/vite.config.ts and passes it with --config.)
import { IgnisArtifactsVitePlugin } from '@venizia/ignis-boot/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
// Builds the class list of src/ into the bundle, so a decorated class is bound without being imported.
plugins: [IgnisArtifactsVitePlugin.create(), react()],
resolve: { alias: { '@': '/src' } },
server: { proxy: { '/api': 'http://127.0.0.1:3100' } },
preview: { proxy: { '/api': 'http://127.0.0.1:3100' } },
build: {
rolldownOptions: {
output: {
// React, react-admin and the router weigh about 500 KB before any application code. Two
// vendor chunks keep the application's own chunk small and cacheable across a deploy.
codeSplitting: {
groups: [
{ name: 'react', test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/ },
{ name: 'vendor', test: /node_modules/ },
],
},
},
},
},
});This file configures Vite with IgnisArtifactsVitePlugin to discover decorated classes under src/ at build time. It establishes the @ path alias, sets up proxy forwarding for /api to the stub API on port 3100, and configures vendor code splitting. Point the proxy at your own backend when you have one.
8. Run it
The quickstart talks to a stub API that lives in the ARDOR repository. Download it into your project and start it in one terminal (it listens on port 3100):
mkdir api
curl -fsSL https://raw.githubusercontent.com/VENIZIA-AI/ardor/main/examples/5-mins-qs/api/server.ts -o api/server.ts
bun run api/server.tsStart the Vite development server in another terminal:
bun run devOpen http://localhost:5173 in your browser. Sign in with username admin and password admin to see the product list with its total and the count of expensive products.
If you have your own IGNIS API, skip the stub and point the proxy in vite.config.ts at that API.
Your API is not this one
- No sign-in. If your API requires no authentication, leave
DEFAULT_AUTH_PROVIDERunbound, omitloginPageonArdorApplication, and configurenoAuthPathRegex: /./onREST_DATA_PROVIDER_OPTIONS. The admin runs with all pages public. - A different sign-in path. If your API signs in at a custom path or expects different credentials, set
paths.signIninAUTH_PROVIDER_OPTIONSand add the route tonoAuthPathsinREST_DATA_PROVIDER_OPTIONS. SubclassDefaultAuthProviderand overridebuildSignInBodyif you need custom request formatting.
For complete authentication options and contracts, see Auth provider.
What you built
An ARDOR admin with a datasource, a repository, an application configuration that wires data and authentication providers, and a localized resource page. The application discovered its decorated classes through AutoArtifacts, and ArdorApplication mounted the UI with dependency injection intact. The project that Create a project generates is built the same way, plus Tailwind, an nginx image and runtime configuration.
Next steps
- Learn the container lifecycle and application options: Application
- Understand providers and configuration classes: Configuration
- Build a full admin with create and edit forms: Build an Admin