---
url: "https://vueda.dev/v3/tutorials/start-building.md"
title: "Start Building"
---

# Start Building

This guide walks through the **supported, opinionated starting point** for a VUEDA-based project, focusing on the minimum steps needed to get a VUEDA API server and a VUEDA client application talking to each other.

If you already have an existing Django or Vue project, this guide is still useful as a reference for how VUEDA expects projects to be structured, but it does not attempt to provide conversion steps.

By the end of this guide, you will have a running Django API and Vue client connected via VUEDA, exposing a simple inventory model end-to-end.

For a more complete example, try [Widget Warehouse](https://www.widgetwarehouse.com/), a VUEDA application with inventory, purchasing workflows, and custom dashboards. Its [repository](https://github.com/arrai-innovations/widget-warehouse) includes the source and a guided walkthrough.

## Prerequisites

- [Python 3.12+](https://www.python.org/downloads/): for running the VUEDA Server
- [Node.js 22+](https://nodejs.org/en/download/): for running the VUEDA Client
- A [PostgreSQL](https://www.postgresql.org/) database: for hosting your application data
- A [Redis](https://redis.io/) server: for the cache that holds sign-in sessions (or use the database cache instead; see [Configure Local Settings](/v3/tutorials/start-building.md#configure-local-settings))
- [Git](https://git-scm.com/): for version control
- [Copier](https://copier.readthedocs.io/): for scaffolding from the project templates
- [uv](https://docs.astral.sh/uv/): for Python dependency management
- [pnpm](https://pnpm.io/): for Node.js dependency management

### Optional

- [just](https://just.systems/man/en/introduction.html): for common developer CLI tooling (included in the DX template)

::: tip
Run the server under an ASGI server instead of Django's `runserver`. The DX template's `just serve` runs gunicorn with uvicorn workers, the same setup as production. VUEDA's planned websocket support will also need ASGI. Both templates already depend on `gunicorn` and `uvicorn`. With the minimal template, run this from `server/`: `uv run gunicorn config.asgi -k uvicorn.workers.UvicornWorker --reload --bind localhost:8000`.
:::

## Environment Setup

This guide assumes access to a [bash](https://www.gnu.org/software/bash/)-like shell (Linux, macOS, WSL2, etc.) for running commands. Adjust accordingly for other environments (PowerShell, cmd.exe, etc.).

### Package Registry Access

VUEDA is available from [public PyPI](https://pypi.org/project/vueda/) and [public npm](https://www.npmjs.com/package/@arrai-innovations/vueda). No registry credentials are needed.

The v3 series is a prerelease, and the templates select it with version ranges. To add VUEDA to an existing project, use the same ranges. For the server, run `uv add "vueda[redis]>=3.0.0b1,<4"`. For the client, run `pnpm add "@arrai-innovations/vueda@^3.0.0-beta.1"`. A range names the release series directly, so it does not depend on which version a registry tag such as `latest` points to.

## Scaffold a New Project

VUEDA provides two [Copier](https://copier.readthedocs.io/) templates for scaffolding a new [Integrator](/v3/reference/glossary.md#integrator) project:

- **`integrator-monorepo`**: minimal setup with direct `uv`/`pnpm` workflows.
- **`integrator-monorepo-dx`**: DX-focused setup with repository automation via `just` (includes linting, formatting, git hooks, and `just serve` for running both servers concurrently).

Clone the VUEDA repository, then copy one template from the clone:

```console
git clone https://github.com/arrai-innovations/vueda.git

# DX template (recommended)
uvx copier copy vueda/templates/integrator-monorepo-dx ./your-project

# or: minimal template
uvx copier copy vueda/templates/integrator-monorepo ./your-project
```

Copier will prompt you for a project name, slug, ports, and other options. The defaults are sensible for most setups.

The template generates:

- A Django server under `server/` with VUEDA wired into settings, URLs, and config
- A custom `users` app under `server/<your_package>/users/` with a project-specific `User` model extending VUEDA's base user (required by `AUTH_USER_MODEL`)
- Two TOML config files: `server/config.toml` (shared, safe to commit) and `server/config.local.toml` (secrets, do not commit)
- A Vue client under `client/` with VUEDA's router and action system bootstrapped
- A `uv` workspace root and `pnpm` workspace definition
- (DX template only) A `Justfile`, `lefthook` config, `ruff`, `eslint`, and `prettier` setup

## Initialize the Repository

The DX template includes `lefthook` for git hooks, which runs automatically during `pnpm install`. Initialize a git repository before installing dependencies:

```console
cd your-project
git init
```

## Install Dependencies

### DX template

```console
just bootstrap
```

### Minimal template

```console
uv sync --all-packages
pnpm install
```

## Configure Local Settings

VUEDA projects use a two-file TOML configuration system, both under `server/`:

- **`config.toml`**: shared settings safe to commit (allowed hosts, frontend URL, app registry, CORS origins, etc.). The template ships placeholder public-deployment defaults; replace them with your real deployment domain before production use.
- **`config.local.toml`**: local-only overrides and secrets (**do not commit**). This is where machine-specific values like database credentials, local frontend origins, and `DEBUG` belong.

Settings in `config.local.toml` override those in `config.toml`. Both files are loaded by [TomlEnv](/v3/reference/api/py/vueda.core.config/TomlEnv.md) in `server/config/settings/base.py` and consumed by VUEDA's [core.default\_settings.get\_defaults](/v3/reference/api/py/vueda.core.default_settings.md#get_defaults), which sets up Django settings (`INSTALLED_APPS`, `DATABASES`, `CACHES`, middleware, auth, etc.) from these keys.

Before starting the server, open `server/config.local.toml` and set real values:

```toml
SECRET_KEY = "a-real-secret-key"
DEBUG = true
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
FRONTEND_DOMAIN = "http://localhost:5173"
CSRF_TRUSTED_ORIGINS = ["http://localhost:5173"]
CORS_ALLOWED_ORIGINS = ["http://localhost:5173"]
DATABASE_URL = "postgres://postgres:postgres@localhost:5432/your-project"
CACHE_URL = "redis://localhost:6379/0?key_prefix=your-project-"
```

The template pre-populates the local host/origin values from the bind IP and client port you chose during scaffolding, and pre-populates `DATABASE_URL` with a reasonable guess based on your project slug. Update those values if your local network or Postgres connection details differ. `SECRET_KEY` should be changed from the placeholder for any non-trivial use.

Sign-in sessions live in the cache that `CACHE_URL` names. To skip Redis, set `CACHE_URL = "db://your_project_cache"` and run `manage.py createcachetable` after `migrate` in the next step. See [Configure the Cache and Sessions](/v3/guides/configure-cache-and-sessions.md) for the options.

::: tip
The template's `config.toml` also registers the scaffolded `users` app via `LOCAL_APPS` and sets `AUTH_USER_MODEL = "users.User"`. These are required for VUEDA's user system to work. You can add your own apps to `LOCAL_APPS` or append to `INSTALLED_APPS` directly in `base.py` (the guide uses the latter approach below).
:::

## First Contact

At this point we have a minimal VUEDA server and client setup. Let's verify that everything is wired up correctly.

::: tip
The remainder of this guide uses `localhost` for simplicity. If you are working in WSL2, Docker containers, or other networked environments, you may need to adjust hostnames or use `0.0.0.0` for binding.
:::

First, apply database migrations:

```console
# DX template
just manage migrate

# Minimal template
cd server
uv run python manage.py migrate
```

Now start the server and client. With the DX template, run both concurrently:

```console
just serve
```

With the minimal template, start each in its own terminal:

```console
# Terminal 1: server
cd server
uv run gunicorn config.asgi -k uvicorn.workers.UvicornWorker --reload --bind localhost:8000
```

```console
# Terminal 2: client
cd client
pnpm dev
```

The client dev server defaults to port `5173` (configurable via the copier options).

Now verify the server is responding:

```console
curl -i http://localhost:8000/routes/vueda.user/who-is/
```

If the server is running and you are not signed in, you get a 200 response with the body `{"auth_pending_flow":null}`.

For the client, open your browser and navigate to `http://localhost:5173`. The template's router redirects `/` to the `welcome` route. That route requires a signed-in user, so it sends you to the Sign In form at `/sign-in/`. The browser console should show no errors. To create a user to sign in with, follow [Verify the New API Endpoints](/v3/tutorials/start-building.md#verify-the-new-api-endpoints) later in this tutorial.

::: tip
If you want your local environment to match production security settings (secure session and CSRF cookies, HTTPS-only), see [Local HTTPS Development](/v3/guides/local-https-setup.md).
:::

::: warning
If you are having issues from here, consult [Django](https://docs.djangoproject.com/) or [Vite](https://vite.dev/) documentation for troubleshooting tips, or a system administrator for networking problems, as the issues are likely outside the scope of this guide.
:::

## VUEDA Server

With the boilerplate in place, we can now start building on the VUEDA Server. For this guide, we will add several simple models in the same Django app to demonstrate VUEDA's capabilities.

### Create a Django App

Add a new Django app under the project namespace. In `server/your_project/`, create a new folder called `inventory` with the following files:

- `__init__.py`
- `apps.py`
- `models.py`
- `serializers.py`
- `viewsets.py`
- `filtersets.py`
- `routers.py`
- `urls.py`

### Models

VUEDA provides its own extensions of Django's `Model` class:

- **[VuedaModel](/v3/reference/api/py/vueda.core.models/VuedaModel.md)**: adds an expected [VuedaModel.formatted\_name](/v3/reference/api/py/vueda.core.models/VuedaModel.md#formatted_name) `GeneratedField` (by default based on a model's `name` field) and a custom [BaseModelMeta](/v3/reference/api/py/vueda.core.models/BaseModelMeta.md) class that sets up default permissions in VUEDA's expected way.
- **[Lookup](/v3/reference/api/py/vueda.core.models/Lookup.md)**: extends [VuedaModel](/v3/reference/api/py/vueda.core.models/VuedaModel.md) with a unique `code` field, intended for lightweight, potentially user-defined, reference data tables.

::: info IMPORTANT
VUEDA uses create, read, update, delete, and list permissions, which aligns better with `djangorestframework`'s viewset actions than Django's default add, change, delete, and view permissions. All VUEDA models must therefore inherit from [VuedaModel](/v3/reference/api/py/vueda.core.models/VuedaModel.md) to ensure proper permission handling, and must have a `class Meta(VuedaModel.Meta)` (or equivalently, `class Meta({@api py:class:vueda.core.models.BaseModelMeta})`) by default.
:::

`server/your_project/inventory/models.py`:

```python
from django.db import models

from vueda.core.models import BaseModelMeta, Lookup, VuedaModel


class Product(VuedaModel):
    name = models.CharField(max_length=255)
    sku = models.CharField(max_length=64, unique=True)
    description = models.TextField(blank=True)

    class Meta(BaseModelMeta):
        ordering = ["name", "id"]


class OptionType(Lookup):
    class Meta(BaseModelMeta):
        ordering = ["name", "id"]


class ProductOption(VuedaModel):
    product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="options")
    option_type = models.ForeignKey(OptionType, on_delete=models.PROTECT, related_name="product_options")
    name = models.CharField(max_length=255)
    value = models.CharField(max_length=255)
    sort_order = models.PositiveIntegerField(default=0)

    class Meta(BaseModelMeta):
        ordering = ["sort_order", "id"]
```

### Serializers

VUEDA provides [VuedaSerializer](/v3/reference/api/py/vueda.core.serializers/VuedaSerializer.md) and [VuedaLookupSerializer](/v3/reference/api/py/vueda.core.serializers/VuedaLookupSerializer.md) base classes for DRF serializers. [VuedaLookupSerializer](/v3/reference/api/py/vueda.core.serializers/VuedaLookupSerializer.md) handles the boilerplate around the `code` field for [Lookup](/v3/reference/api/py/vueda.core.models/Lookup.md) models.

::: info IMPORTANT
As with models, all VUEDA serializers should have a `class Meta({@api py:class:vueda.core.serializers.VuedaSerializer}.Meta)` or `class Meta({@api py:class:vueda.core.serializers.VuedaLookupSerializer}.Meta)` to ensure proper default behavior.
:::

`server/your_project/inventory/serializers.py`:

```python
from vueda.core.serializers import VuedaLookupSerializer, VuedaSerializer

from your_project.inventory.models import OptionType, Product, ProductOption


class ProductSerializer(VuedaSerializer):
    class Meta(VuedaSerializer.Meta):
        model = Product
        fields = [
            "id",
            "name",
            "sku",
            "description",
            "formatted_name",
            "available_actions",
        ]


class OptionTypeSerializer(VuedaLookupSerializer):
    class Meta(VuedaLookupSerializer.Meta):
        model = OptionType
        fields = VuedaLookupSerializer.Meta.fields


class ProductOptionSerializer(VuedaSerializer):
    class Meta(VuedaSerializer.Meta):
        model = ProductOption
        fields = [
            "id",
            "product",
            "option_type",
            "name",
            "value",
            "sort_order",
            "formatted_name",
            "available_actions",
        ]
```

### Viewsets

VUEDA provides a [VuedaViewSet](/v3/reference/api/py/vueda.core.viewsets/VuedaViewSet.md) base class which:

- sets up default behavior for [CRUDL](/v3/reference/glossary.md#crudl) actions
- integrates with VUEDA's permission system
- extends DRF's `ModelViewSet` to cause more intentional errors when passing extra query parameters or fields (rather than silently ignoring them)
- provides row-level filtering hooks
- integrates and extends [drf-flex-fields2](https://github.com/openbook-education/drf-flex-fields2), adding `permit_{action}_expands` for every action. The library supports only `permit_list_expands`, which defaults to an empty list, so a `list` request expands nothing until the viewset names its expands

`server/your_project/inventory/viewsets.py`:

```python
from vueda.core.viewsets import VuedaViewSet

from your_project.inventory.filtersets import (
    OptionTypeFilterSet,
    ProductFilterSet,
    ProductOptionFilterSet,
)
from your_project.inventory.models import OptionType, Product, ProductOption
from your_project.inventory.serializers import (
    OptionTypeSerializer,
    ProductOptionSerializer,
    ProductSerializer,
)


class ProductViewSet(VuedaViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    filterset_class = ProductFilterSet


class OptionTypeViewSet(VuedaViewSet):
    queryset = OptionType.objects.all()
    serializer_class = OptionTypeSerializer
    filterset_class = OptionTypeFilterSet


class ProductOptionViewSet(VuedaViewSet):
    queryset = ProductOption.objects.all()
    serializer_class = ProductOptionSerializer
    filterset_class = ProductOptionFilterSet
```

### Filtersets

VUEDA provides [VuedaFilterSet](/v3/reference/api/py/vueda.core.filters/VuedaFilterSet.md) as a base for DRF filtersets.

`server/your_project/inventory/filtersets.py`:

```python
from vueda.core.filters import VuedaFilterSet

from your_project.inventory.models import OptionType, Product, ProductOption


class ProductFilterSet(VuedaFilterSet):
    class Meta:
        model = Product
        fields = ["id", "name", "sku"]


class OptionTypeFilterSet(VuedaFilterSet):
    class Meta:
        model = OptionType
        fields = ["id", "code", "name"]


class ProductOptionFilterSet(VuedaFilterSet):
    class Meta:
        model = ProductOption
        fields = ["id", "product", "option_type", "name"]
```

### Router and URLs

VUEDA provides [VuedaRouter](/v3/reference/api/py/vueda.core.routers/VuedaRouter.md), which builds on DRF's `SimpleRouter` to generate standard CRUDL routes, namespaces route names with the app label, and supports [Bulk Action](/v3/reference/glossary.md#bulk-action)s via `@action(bulk=True)`.

`server/your_project/inventory/routers.py`:

```python
from vueda.core.routers import VuedaRouter

from your_project.inventory.viewsets import (
    OptionTypeViewSet,
    ProductOptionViewSet,
    ProductViewSet,
)

router = VuedaRouter()
router.register(r"product", ProductViewSet)
router.register(r"optiontype", OptionTypeViewSet)
router.register(r"productoption", ProductOptionViewSet)
urlpatterns = router.urls
```

`server/your_project/inventory/urls.py`:

```python
from django.urls import include, path

from your_project.inventory.routers import urlpatterns

urlpatterns = [
    path("", include(urlpatterns)),
]
```

Finally, wire the inventory URLs into your project's namespace URL file. The copier template generates `server/your_project/urls.py` with an empty `urlpatterns`. Add the inventory app:

```python
from django.urls import include, path

urlpatterns = [
    path("inventory/", include("your_project.inventory.urls")),
]
```

The template's `server/config/urls.py` already includes your project namespace under the `routes/` prefix, so the inventory endpoints will be available at `/routes/inventory/`. The client requests each model at `/routes/<app_label>/<model_name>/`, so each router prefix is the model name. [Router and URL Wiring](/v3/guides/create-crudl-surface.md#router-and-url-wiring) describes this rule.

### App Configuration and Model-Info Registration

VUEDA's client discovers models through a metadata API. For your models to appear in this API (and therefore be usable by the client), you need to register them with VUEDA's [info.registration.register](/v3/reference/api/py/vueda.info.registration.md#register) function in the app's `AppConfig.ready()` method.

`server/your_project/inventory/apps.py`:

```python
from django.apps import AppConfig


class InventoryConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "your_project.inventory"

    def ready(self):
        from vueda.info import register

        from .serializers import (
            OptionTypeSerializer,
            ProductOptionSerializer,
            ProductSerializer,
        )
        from .viewsets import (
            OptionTypeViewSet,
            ProductOptionViewSet,
            ProductViewSet,
        )

        register(ProductSerializer, ProductViewSet)
        register(OptionTypeSerializer, OptionTypeViewSet)
        register(ProductOptionSerializer, ProductOptionViewSet)
```

The imports are inside `ready()` deliberately. Registration resolves [Content Type](/v3/reference/glossary.md#content-type)s internally, which requires the Django app registry to be fully initialized first.

::: info IMPORTANT
Without [info.registration.register](/v3/reference/api/py/vueda.info.registration.md#register), the model's API endpoints will work (you can still curl them), but the client will not be able to discover the model's fields, actions, or permissions. This is the most common cause of "model doesn't show up in the client."
:::

### Register the App

Add the new app to `INSTALLED_APPS`. The copier template's settings use [core.default\_settings.get\_defaults](/v3/reference/api/py/vueda.core.default_settings.md#get_defaults) from VUEDA, which assembles `INSTALLED_APPS`. That list holds Django's own apps, the default `VUEDA_APPS` list, third-party apps, and any apps named in `LOCAL_APPS` in `config.toml`. The scaffolded `users` app is already there. You need to add your new app as well.

The default `VUEDA_APPS` list installs every VUEDA app, and two of them are optional. [Django App Boundaries](/v3/core-concepts/architecture-overview.md#django-app-boundaries) separates required infrastructure from optional feature apps and gives the combinations VUEDA tests.

In `server/config/settings/base.py`, after the `locals().update(get_defaults(env))` line, add:

```python
INSTALLED_APPS += ["your_project.inventory"]
```

Alternatively, you could add the app to `LOCAL_APPS` in `config.toml`. Either approach works; `INSTALLED_APPS +=` in `base.py` keeps the registration close to the code, while `LOCAL_APPS` keeps it in config.

Then run migrations:

```console
cd server
uv run python manage.py makemigrations inventory
uv run python manage.py migrate
```

### Verify the New API Endpoints

VUEDA enforces [CRUDL](/v3/reference/glossary.md#crudl) permissions by default. Each model gets five permissions: `inventory.create_product`, `inventory.read_product`, `inventory.update_product`, `inventory.delete_product`, and `inventory.list_product`, and the same five for `optiontype` and `productoption`.

The `inventory` app has more permissions than these 15. VUEDA records each model's history in an event model (`productevent`, `optiontypeevent`, and `productoptionevent`), and each event model gets its own five permissions. Reading a product's history needs only `inventory.read_product`, so the group below leaves the event model permissions out.

The group also needs `contenttypes.read_contenttype` and `contenttypes.list_contenttype`. The client reads each model's metadata ([Model Info](/v3/reference/glossary.md#model-info)) before it opens the model, and those two permissions gate that metadata. [Model Info Access](/v3/core-concepts/permission-model.md#model-info-access) explains this gate.

The script below creates a group with the 15 model permissions and the two model info permissions, `contenttypes.read_contenttype` and `contenttypes.list_contenttype`. It then creates a user in that group:

```console
uv run python manage.py shell -c '
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission

model_permissions = Permission.objects.filter(
    content_type__app_label="inventory",
    content_type__model__in=["product", "optiontype", "productoption"],
)
model_info_permissions = Permission.objects.filter(
    content_type__app_label="contenttypes",
    codename__in=["read_contenttype", "list_contenttype"],
)
group, _ = Group.objects.get_or_create(name="Inventory Editors")
group.permissions.set([*model_permissions, *model_info_permissions])
user = get_user_model().objects.create_user(email="you@example.com", password="your-password", name="You")
user.groups.add(group)
'
```

Then, in a new terminal, log in via curl and store the session cookie. The login endpoint sets a CSRF cookie in its response, which you will need for subsequent mutating requests.

::: tip
The CSRF cookie name is project-specific (`<project-slug>-csrf-token` by default, configured via `CSRF_COOKIE_NAME` in `local.py`). The examples below use `your-project-csrf-token` as a placeholder; substitute your actual project slug.
:::

```console
COOKIE_JAR=/tmp/vueda-cookies.txt
CSRF_COOKIE=your-project-csrf-token

# Log in (uses email + password; sets CSRF cookie in the response)
curl -c $COOKIE_JAR \
  -H "Content-Type: application/json" \
  -X POST http://localhost:8000/routes/vueda.user/login/ \
  -d '{"email":"you@example.com","password":"your-password"}'

# Extract the CSRF token for subsequent requests
CSRF_TOKEN=$(awk -v name="$CSRF_COOKIE" '$6 == name {print $7}' $COOKIE_JAR)
```

If the login succeeded, `who-is` should now return your user info:

```console
curl -b $COOKIE_JAR http://localhost:8000/routes/vueda.user/who-is/
```

Now test [CRUDL](/v3/reference/glossary.md#crudl) on the inventory endpoints:

```console
# Create
curl -b $COOKIE_JAR -c $COOKIE_JAR \
  -H "Content-Type: application/json" \
  -H "X-CSRFToken: $CSRF_TOKEN" \
  -X POST http://localhost:8000/routes/inventory/product/ \
  -d '{"name":"Starter Kit","sku":"STARTER-001","description":"Demo product"}'
# Expect: 201 with the created object

# List
curl -b $COOKIE_JAR http://localhost:8000/routes/inventory/product/
# Expect: 200 with a list including the created object

# Retrieve
curl -b $COOKIE_JAR http://localhost:8000/routes/inventory/product/1/
# Expect: 200 with the created object

# Partial update
curl -b $COOKIE_JAR -c $COOKIE_JAR \
  -H "Content-Type: application/json" \
  -H "X-CSRFToken: $CSRF_TOKEN" \
  -X PATCH http://localhost:8000/routes/inventory/product/1/ \
  -d '{"description":"Updated description"}'
# Expect: 200 with the updated object

# Delete: create a second product, then delete it
curl -b $COOKIE_JAR -c $COOKIE_JAR \
  -H "Content-Type: application/json" \
  -H "X-CSRFToken: $CSRF_TOKEN" \
  -X POST http://localhost:8000/routes/inventory/product/ \
  -d '{"name":"Spare Kit","sku":"SPARE-001"}'
curl -b $COOKIE_JAR -c $COOKIE_JAR \
  -H "X-CSRFToken: $CSRF_TOKEN" \
  -X DELETE http://localhost:8000/routes/inventory/product/2/
# Expect: 204 with no content; Starter Kit remains
```

## VUEDA Client

The scaffolded client has Vue, Pinia, vue-router, and VUEDA's action router wired up. Next, check the server connection and client setup, then fill in [CRUDL View Resolution](/v3/reference/glossary.md#crudl-view-resolution).

### Connect to the Server

During local development the client dev server and Django run on different ports. The scaffolded `client/.env.development` already contains `VITE_DJANGO_CONNECTION_PORT` set to the port you chose during scaffolding, so VUEDA knows where to reach the Django server. No Vite proxy is needed; the template's `config.local.toml` already includes the local client origin in `CORS_ALLOWED_ORIGINS`.

### Check the Client Setup

The scaffolded `client/src/index.css` and `client/src/main.js` already set up the client, so keep both files. `index.css` loads Tailwind, the theme's `base.css` tokens, and the fonts. `main.js` registers the Tailwind theme, the icons, and the [CRUDL](/v3/reference/glossary.md#crudl) data adapters. See [Client Plugin Prerequisites](/v3/guides/client-plugin-prerequisites.md) for what each call does.

::: tip
For per-family or per-component loading and the order required for project patches, see [How the theme is registered](/v3/core-concepts/theming-and-customization.md#how-the-theme-is-registered).
:::

### Check the Sign-In and Welcome Routes

The scaffolded `client/src/router/index.js` already routes `sign-in` to [ViewSignIn](/v3/reference/api/vue/ViewSignIn.md), VUEDA's ready-made sign-in form. After sign-in, the form sends the user to the `welcome` route, which shows the scaffolded `client/src/views/ViewWelcome.vue`. Replace that view with your application's own home page when you have one. The router also has routes for the password reset, two-factor, and reauthentication pages. To build your own forms, see [Build Auth Views](/v3/guides/build-auth-views.md).

### Configure CRUDL View Resolution

The scaffolded router passes an empty `crudComponents` object to [setCrudComponents](/v3/reference/api/js/router/routerComponent/functions/setCrudComponents.md), so no [CRUDL](/v3/reference/glossary.md#crudl) route has a view yet. In `client/src/router/index.js`, replace this line:

```javascript
const crudComponents = {};
```

with this map:

```javascript
const crudComponents = {
    list: async () => (await import("@vueda/views/ViewList.vue")).default,
    create: async () => (await import("@vueda/views/ViewCreate.vue")).default,
    read: async () => (await import("@vueda/views/ViewRead.vue")).default,
    update: async () => (await import("@vueda/views/ViewUpdate.vue")).default,
    destroy: async () => (await import("@vueda/views/ViewDestroy.vue")).default,
};
```

Keep the rest of the router as it is.

`crudComponents` maps each [CRUDL](/v3/reference/glossary.md#crudl) action to a built-in view ([ViewList](/v3/reference/api/vue/ViewList.md), [ViewCreate](/v3/reference/api/vue/ViewCreate.md), [ViewRead](/v3/reference/api/vue/ViewRead.md), [ViewUpdate](/v3/reference/api/vue/ViewUpdate.md), [ViewDestroy](/v3/reference/api/vue/ViewDestroy.md)). [ViewActionRouter](/v3/reference/api/vue/ViewActionRouter.md) uses this map to resolve which component to render. These views auto-discover fields, filters, and permissions from [Model Info](/v3/reference/glossary.md#model-info), so no per-model client code is needed for a working baseline. See [Routing and View Resolution](/v3/core-concepts/routing-and-view-resolution-model.md) for the full resolution chain.

::: tip Per-model view overrides
In a real project you may want a custom view for a specific model. The common pattern is a dynamic import with a fallback:

```javascript
list: async ({ app, model }) => {
    try {
        return (await import(`@/views/ViewList${pascal(app)}${pascal(model)}.vue`)).default;
    } catch {
        return (await import("@vueda/views/ViewList.vue")).default;
    }
},
```

This lets you drop in a `ViewListInventoryProduct.vue` for one model while every other model keeps the default. See [Creating a CRUDL Surface](/v3/guides/create-crudl-surface.md) for details.
:::

### Verify in the Browser

Start both servers if they are not already running:

```console
# DX template
just serve

# Minimal template (two terminals)
cd server && uv run gunicorn config.asgi -k uvicorn.workers.UvicornWorker --reload --bind localhost:8000
cd client && pnpm dev
```

Open `http://localhost:5173/sign-in/` in your browser.

1. You should see the Sign In form.
2. Sign in as `you@example.com` with the password you set earlier.
3. After login you should land on `/welcome/`.
4. Navigate to `http://localhost:5173/inventory/product/list/`. The Starter Kit product you created with curl appears here. To reach other models, the client URL pattern is `/{app_label}/{model}/list/`, where `{model}` comes from model-info and follows Django's `model_name` convention: the class name lowercased with no separators. For example, `ProductOption` becomes `productoption`, so its list URL is `/inventory/productoption/list/`. The server router registers each model under the same name (see [Router and URLs](/v3/tutorials/start-building.md#router-and-urls)).
5. Use the "Create" action to add a product and verify it appears in the list.
6. Open `http://localhost:5173/inventory/product/read/1` to see the Starter Kit read view, then try update and destroy from there. The next section links list rows to this view.

### Customize with Model Config

The built-in views render every field the serializer exposes. To adjust which fields appear, set sort defaults, or reorder columns without building custom views, use [storeModelConfig](/v3/reference/api/js/stores/storeModelConfig/functions/storeModelConfig.md).

Create `client/src/setupModelConfig.js`:

```javascript
import { storeModelConfig } from "@vueda/stores/storeModelConfig.js";

export function setupModelConfig() {
    const modelConfig = storeModelConfig();

    modelConfig.setConfig(
        { app: "inventory", model: "product" },
        {
            displayFields: ["name", "sku", "description"],
            sorted: ["name"],
            detailLinkField: "name",
        },
        {
            create: {
                fields: ["name", "sku", "description"],
            },
        },
    );
}
```

Then call it from `main.js` after `app.use(pinia)`:

```javascript
import { setupModelConfig } from "./setupModelConfig.js";

// ... after app.use(pinia)
setupModelConfig();
```

`detailLinkField` turns each row's `name` into a link to that product's read or update view; see [Link List Rows to Detail Views](/v3/guides/link-list-rows-to-detail-views.md). The `fields` shorthand sets `displayFields`, `fetchFields`, and `submitFields` together. Per-view configs (keyed by action name) merge on top of the generic config. See [Configure CRUDL Views](/v3/guides/configure-crud-views.md) for all available options.
