Navigated to /docs/getting-started/first-app

Build your first Tavo.js app

Build a Project Dashboard while learning routes, loaders, local component state, controllers, Tavo.js UI, client navigation, CSR, and SSR.

Before you begin

Quickstart · Runs in Browser, Server, Build

You will learn

  • Create a Tavo.js project and identify what the generated starter demonstrates.

  • Add a file route with browser-safe loader data.

  • Add local interactive state and named behavior with createTavo and TavoController.

  • Verify the same route in client and server development modes.

Starting point

  • You can read HTML, CSS, TypeScript, and TSX; no Tavo.js experience is required.

  • Node.js 20.19+ or 22.12+, npm, a terminal, and a code editor are available.

  • Ports 5173 and 4174 are available, or you will use the URLs printed by the development servers.

Start with the application model

A Tavo.js application is a TypeScript program with a file-based route tree. A page file owns one URL, its loader prepares route data, and its TSX component renders that data. createTavo adds local reactive state when a view becomes interactive, while a TavoController gives that interaction named behavior.

You will build one feature in small working increments. Run the checkpoint after each increment before continuing; when something breaks, you then know which edit introduced it.

1. Create and inspect the starter

Create a project named project-dashboard, install its declared dependencies, and start the client development server.

Run in Terminal

BASH
bashnode --version
npx @tavojs/cli create app project-dashboard
cd project-dashboard
npm install
npm run dev

The starter already demonstrates Tavo.js local state: src/pages/index.tsx defines a model and updates it with model.patch(). Keep that page and src/styles.css; the next steps add a second route without discarding the working starter. After trying the controls, stop the server with Ctrl+C before installing the next dependency.

2. Add Tavo.js UI through its plugin

Install the component package and ask its bundled tavo-ui command to create the project theme configuration. @tavojs/ui-cli is already a dependency of @tavojs/ui; do not install or version it separately.

Run in Terminal

BASH
bashnpm install @tavojs/ui
npx tavo-ui web init

Merge tavoUi() into the configuration generated by the Tavo.js CLI. Keep src/styles.css, diagnostics, build settings, and every existing plugin. The UI plugin generates and injects theme variables during development and production builds, so this path does not add a generated theme file to cssEntries.

Merge into tavo.config.ts

TS
tsimport { defineConfig } from "@tavojs/core/config";
import { tavoUi } from "@tavojs/ui/plugin";

export default defineConfig({
  pagesDir: "src/pages",
  cssEntries: ["src/styles.css"],
  diagnostics: {
    devOverlay: true,
    traces: false,
  },
  plugins: [tavoUi()],
});

3. Create the projects route

Create src/pages/projects/index.tsx. The path below is special: every non-underscore TypeScript or TSX module inside src/pages is treated as a route module. This file therefore creates /projects.

Start with a plain TSX function. A page does not need a model, controller, store, or loader until its behavior requires one.

Create src/pages/projects/index.tsx

TSX
tsximport { Card, Page, Stack, Text } from "@tavojs/ui";

export default function ProjectsPage() {
  return (
    <Page>
      <Stack gap="lg">
        <Text as="h1" variant="h1">
          Project dashboard
        </Text>
        <Card title="Documentation">
          <Text>Make the learning path clear for a new Tavo.js developer.</Text>
        </Card>
      </Stack>
    </Page>
  );
}

4. Load browser-safe route data

Create a normal source module for data that is safe to include in a browser bundle. This example contains public sample records—no database client, secret, session, or private API key.

Create src/data/projects.ts

TS
tsexport type Project = {
  id: string;
  name: string;
  summary: string;
  active: boolean;
};

export const projects: Project[] = [
  {
    id: "docs",
    name: "Documentation",
    summary: "Make the learning path clear for a new Tavo.js developer.",
    active: true,
  },
  {
    id: "website",
    name: "Website",
    summary: "Ship the next public release.",
    active: true,
  },
  {
    id: "mobile",
    name: "Mobile",
    summary: "Plan a later product experiment.",
    active: false,
  },
];

Add a named load export to the functional route module. Its result becomes data for the default page component. Because this loader imports browser-safe data, it can run during both client navigation and SSR.

Replace src/pages/projects/index.tsx

TSX
tsximport type { PageProps } from "@tavojs/core/router";
import { Card, Grid, Page, Stack, Text } from "@tavojs/ui";
import { projects, type Project } from "../../data/projects";

type ProjectsData = { projects: Project[] };

export function load(): ProjectsData {
  return { projects };
}

export default function ProjectsPage({ data }: PageProps<ProjectsData>) {
  return (
    <Page>
      <Stack gap="lg">
        <Text as="h1" variant="h1">
          Project dashboard
        </Text>
        <Grid columns={{ base: 1, md: 2 }} spacing="md">
          {data?.projects.map((project) => (
            <Card title={project.name}>
              <Text>{project.summary}</Text>
            </Card>
          ))}
        </Grid>
      </Stack>
    </Page>
  );
}

5. Add local state and a controller

The filter belongs only to the dashboard currently on screen, so local model state is the narrowest owner. The controller gives each transition a clear name: showAll() and showActive() patch the model, while the view reads state.filter and delegates button events to those methods.

Create the component below. Pass ProjectDashboardController as the third createTavo type argument and register it with the controller option. Tavo.js creates the controller with access to the component model and disposes it with the mounted component.

Create src/components/ProjectDashboard.tsx

TSX
tsximport { createTavo, TavoController } from "@tavojs/core";
import { Button, Card, Grid, Inline, Stack, Text } from "@tavojs/ui";
import type { Project } from "../data/projects";

type ProjectFilter = "all" | "active";
type ProjectDashboardProps = { projects: Project[] };
type ProjectDashboardState = { filter: ProjectFilter };

class ProjectDashboardController extends TavoController {
  showAll() {
    this.model.patch({ filter: "all" });
  }

  showActive() {
    this.model.patch({ filter: "active" });
  }
}

export const ProjectDashboard = createTavo<
  ProjectDashboardProps,
  ProjectDashboardState,
  ProjectDashboardController
>({
  model: () => ({ filter: "all" }),
  controller: ProjectDashboardController,
  view: ({ props, state, controller }) => {
    const visibleProjects = props.projects.filter(
      (project) => state.filter === "all" || project.active,
    );

    return (
      <Stack gap="lg">
        <Inline gap="sm" aria-label="Filter projects">
          <Button
            aria-pressed={state.filter === "all" ? "true" : "false"}
            onClick={() => controller?.showAll()}
          >
            All projects
          </Button>
          <Button
            aria-pressed={state.filter === "active" ? "true" : "false"}
            onClick={() => controller?.showActive()}
          >
            Active projects
          </Button>
        </Inline>
        <Text aria-live="polite">
          Showing {visibleProjects.length} of {props.projects.length} projects
        </Text>
        <Grid columns={{ base: 1, md: 2 }} spacing="md">
          {visibleProjects.map((project) => (
            <Card title={project.name}>
              <Stack gap="sm">
                <Text>{project.summary}</Text>
                <Text color="muted">
                  {project.active ? "Active" : "Planned"}
                </Text>
              </Stack>
            </Card>
          ))}
        </Grid>
      </Stack>
    );
  },
});

Replace the route one final time so the loader still owns project data, the dashboard model owns the current filter, and the controller owns the filter-changing behavior. This separation prevents request data from being copied into a global client store.

Replace src/pages/projects/index.tsx

TSX
tsximport type { PageProps } from "@tavojs/core/router";
import { Page, Stack, Text } from "@tavojs/ui";
import { ProjectDashboard } from "../../components/ProjectDashboard";
import { projects, type Project } from "../../data/projects";

type ProjectsData = { projects: Project[] };

export function load(): ProjectsData {
  return { projects };
}

export default function ProjectsPage({ data }: PageProps<ProjectsData>) {
  return (
    <Page>
      <Stack gap="lg">
        <Text as="h1" variant="h1">
          Project dashboard
        </Text>
        <Text color="muted">
          Route data supplies the projects. Local model state controls the
          visible filter.
        </Text>
        <ProjectDashboard projects={data?.projects ?? []} />
      </Stack>
    </Page>
  );
}

6. Link the starter to the dashboard

Create a small navigation component with the Tavo.js router Link. A link represents a destination and lets the pages runtime coordinate history, focus, scroll, loaders, and cancellation.

Create src/components/ProjectDashboardLink.tsx

TSX
tsximport { Link } from "@tavojs/core/router";

export function ProjectDashboardLink() {
  return <Link to="/projects">Open the project dashboard</Link>;
}

Update the generated home page in two small places while keeping the counter and its original styles intact. First, add this import beside the existing imports at the top of the file.

Update src/pages/index.tsx

TSX
tsximport { ProjectDashboardLink } from "../components/ProjectDashboardLink";

Next, replace the existing one-line app-footer with this expanded footer.

Update src/pages/index.tsx

TSX
tsx<footer className="app-footer">
  <ProjectDashboardLink />
  <span>Built with Tavo.js</span>
  <code>src/pages/index.tsx</code>
</footer>

7. Verify client and server rendering

You have been using client-side rendering (CSR): Vite serves the document shell and the browser resolves the route. Stop that server, then start Tavo.js's server-side rendering (SSR) development mode.

Run in Terminal

BASH
bash# Stop the CSR server with Ctrl+C, then run:
npm run dev:ssr

# In another terminal, inspect the server-rendered HTML:
curl http://localhost:4174/projects

What you built

Inspect Final project tree

TEXT
textproject-dashboard/
├── src/
│   ├── components/
│   │   ├── ProjectDashboard.tsx       local filter model, controller, and UI
│   │   └── ProjectDashboardLink.tsx   client navigation
│   ├── data/
│   │   └── projects.ts                browser-safe sample data
│   ├── pages/
│   │   ├── index.tsx                  generated starter plus dashboard link
│   │   └── projects/
│   │       └── index.tsx              /projects route and loader
│   └── styles.css                     preserved starter styles
├── tavo-ui.config.ts                  project theme input
└── tavo.config.ts                     framework and UI plugin configuration

The route owns URL-level data and composition. The dashboard model owns state used only by that mounted view, and its controller owns named state transitions. The data module is ordinary browser-safe TypeScript. The UI plugin owns theme generation while the existing stylesheet remains part of the application.

Next steps