> ## Documentation Index
> Fetch the complete documentation index at: https://docs.attio.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Building app pages

> Give your app a dashboard page, a record tab, and the links between them

[Pages](../pages/overview) and [record tabs](../extensions/record-tab) give your app surfaces of its own. This guide builds a deal health app with both, then links them together.

We'll build:

1. A **Dashboard** page under your app's own URL, laid out with [`Grid`](../components/grid), [`Card`](../components/card) and [`Stack`](../components/stack).
2. An [`EmptyState`](../components/empty-state) for workspaces with no data yet.
3. A **Health** record tab on deal pages.
4. Links between the page, the tab and records with [`AttioLink`](../navigation/attio-link) and [`navigate`](../navigation/navigate).

Everything here is client code. Where the examples call a server function, swap in your own; see [Querying Attio data](./querying-attio-data) for how to fetch records.

## 1. Scaffold a page

A page is a folder under `src/app/pages/` with a `page.tsx` that default-exports [`Extensions.definePage`](../pages/define-page). The folder name is the URL slug.

```tsx src/app/pages/dashboard/page.tsx theme={"system"}
import React from "react"
import {Extensions, Typography} from "attio/client"

export default Extensions.definePage({
  name: "Dashboard",
  Page: () => <Typography.Body>Hello from the dashboard.</Typography.Body>,
})
```

Run `attio dev`, install the app in your development workspace, and the app appears in the Apps section of the sidebar. Click it and you're on `/{workspaceSlug}/apps/{appSlug}/dashboard`.

## 2. Lay out the dashboard

A dashboard is a grid of cards. `Grid` handles the columns and collapses them as the window narrows. `Card` gives each metric a bordered box. `Stack` arranges what's inside.

```tsx src/app/pages/dashboard/page.tsx theme={"system"}
import React from "react"
import {Extensions, Grid, Card, Stack, Typography, Badge} from "attio/client"

import {useDealHealth} from "./use-deal-health"
import {AtRiskDeals} from "./at-risk-deals"

export default Extensions.definePage({
  name: "Dashboard",
  Page: () => {
    const health = useDealHealth() // may suspend!

    return (
      <Stack gap="large">
        <Grid maxColumns={3} minColumnWidth="small">
          <Card title="Open deals">
            <Stack direction="row" gap="small" align="center">
              <Typography.Title>{health.open}</Typography.Title>
              <Badge color="green">+{health.openedThisWeek} this week</Badge>
            </Stack>
          </Card>
          <Card title="Won this quarter">
            <Typography.Title>{health.wonThisQuarter}</Typography.Title>
          </Card>
          <Card title="At risk">
            <Typography.Title>{health.atRisk.length}</Typography.Title>
          </Card>
        </Grid>
        <Card title="At risk">
          <AtRiskDeals deals={health.atRisk} />
        </Card>
      </Stack>
    )
  },
})
```

Two things to notice. The metric tiles use `minColumnWidth="small"` because each holds a single value; the default `"medium"` would give them more room than they need. And the table card sits outside the grid, as a sibling, because there are no column spans: anything full-width goes next to the grid, not in it.

`Page` suspends while `useDealHealth` loads and Attio shows the page skeleton in the meantime. You don't need a loading state of your own.

## 3. Handle the empty case

A fresh install has no deals to report on. Return an `EmptyState` as the only child of the page and it fills the surface with a centred title, description and actions. Anywhere else in the tree it doesn't render, so it has to be the whole page.

```tsx src/app/pages/dashboard/page.tsx theme={"system"}
import React from "react"
import {Extensions, EmptyState, Button, navigate, Destinations} from "attio/client"

import {useDealHealth} from "./use-deal-health"
import {Dashboard} from "./dashboard"

export default Extensions.definePage({
  name: "Dashboard",
  Page: () => {
    const health = useDealHealth()

    if (health.open === 0) {
      return (
        <EmptyState
          title="No open deals"
          description="Create a deal and its health will show up here within a minute."
        >
          <Button
            label="Configure scoring"
            variant="primary"
            onClick={() => navigate(Destinations.appSettings())}
          />
        </EmptyState>
      )
    }

    return <Dashboard health={health} />
  },
})
```

## 4. Add a record tab

The dashboard shows health across all deals. For one deal, a record tab is the right surface: it appears on every deal page after install, gets the full width below the tab bar, and admins can reorder it with "Configure page" like any built-in tab.

```tsx src/app/extensions/health/extension.tsx theme={"system"}
import React from "react"
import {Extensions} from "attio/client"

import {HealthTab} from "./health-tab"

export default Extensions.defineExtension({
  type: "record-tab",
  id: "health",
  label: "Health",
  objects: "deals",
  Tab: ({recordId}) => <HealthTab recordId={recordId} />,
})
```

```tsx src/app/extensions/health/health-tab.tsx theme={"system"}
import React from "react"
import {Grid, Card, DescriptionList, Badge} from "attio/client"

import {useDealScore} from "./use-deal-score"

export function HealthTab({recordId}: {recordId: string}) {
  const score = useDealScore(recordId) // may suspend!

  return (
    <Grid maxColumns={2}>
      <Card title="Score">
        <Badge color={score.value < 50 ? "red" : "green"}>{score.value}</Badge>
      </Card>
      <Card title="Signals">
        <DescriptionList>
          <DescriptionList.Item label="Last contact">{score.lastContact}</DescriptionList.Item>
          <DescriptionList.Item label="Stakeholders">{score.stakeholders}</DescriptionList.Item>
        </DescriptionList>
      </Card>
    </Grid>
  )
}
```

The same `Grid` and `Card` work here as on the page: the grid collapses on the width of its container, so it lays out correctly whether the record page is wide or the sidebar is open.

The tab's URL is `/{workspaceSlug}/deals/{recordId}/{appSlug}/health`. Pick `id` carefully: it's part of that URL, and it can't be one of Attio's built-in tab slugs (`overview`, `activity`, `notes` and the rest; the [reference](../extensions/record-tab#arguments) lists them all).

## 5. Link the surfaces together

The dashboard's at-risk table should link to each deal, and ideally straight to its Health tab. `AttioLink` renders a real link, so cmd-click and copy link work.

```tsx src/app/pages/dashboard/at-risk-deals.tsx theme={"system"}
import React from "react"
import {Table, AttioLink, Destinations} from "attio/client"

import type {Deal} from "./use-deal-health"

export function AtRiskDeals({deals}: {deals: Array<Deal>}) {
  return (
    <Table label="At risk">
      <Table.Header>
        <Table.HeaderCell>Deal</Table.HeaderCell>
        <Table.HeaderCell>Owner</Table.HeaderCell>
      </Table.Header>
      <Table.Body>
        {deals.map((deal) => (
          <Table.Row key={deal.recordId}>
            <Table.Cell>
              <AttioLink
                to={Destinations.record(
                  {recordId: deal.recordId, object: "deals"},
                  {tab: "health"},
                )}
              >
                {deal.name}
              </AttioLink>
            </Table.Cell>
            <Table.Cell>{deal.owner}</Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  )
}
```

`tab: "health"` is your record tab id. The same option takes built-in slugs too (`"activity"`, `"notes"`). Both `appPage` slugs and `tab` autocomplete against the pages and tabs the CLI found in your app, with no setup; see [typed slugs](../navigation/overview#typed-slugs).

Going the other way, a link from the tab back to the dashboard is one line:

```tsx theme={"system"}
<AttioLink to={Destinations.appPage("dashboard")}>See all deals</AttioLink>
```

If a destination can't be resolved (a deleted record, a slug you renamed), nothing breaks: `AttioLink` shows plain text and `navigate` logs to the console. A record that no longer exists lands on Attio's normal "not found" page, same as a stale bookmark.

## What you've built

* `src/app/pages/dashboard/page.tsx`, a page in the sidebar at `/apps/{appSlug}/dashboard`, laid out with `Grid`, `Card` and `Stack`, with an `EmptyState` for new installs.
* `src/app/extensions/health/extension.tsx`, a record tab on deal pages.
* Links between them with `AttioLink`, and a `navigate` call to settings.

From here:

* Add a second page and the app row in the sidebar expands to list both. See [Pages](../pages/overview).
* Use `variant: "centered"` on [`definePage`](../pages/define-page) for reading-heavy pages.
* Fetch real data with [`useQuery`](../graphql/use-query) and [server functions](../server/server-functions).
