Skip to content

Documentation · ARDOR

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:

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:

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:

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:

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:

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:

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:

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.)

ts
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):

bash
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.ts

Start the Vite development server in another terminal:

bash
bun run dev

Open 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_PROVIDER unbound, omit loginPage on ArdorApplication, and configure noAuthPathRegex: /./ on REST_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.signIn in AUTH_PROVIDER_OPTIONS and add the route to noAuthPaths in REST_DATA_PROVIDER_OPTIONS. Subclass DefaultAuthProvider and override buildSignInBody if 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 ​