# Add a frontend page

> Add a page to the AppointMe React SPA with a route, a sidebar entry, data from a generated TanStack Query hook, the shared DataTable, permission gating, and loading and error states.

Source page: https://appointme.dev/docs/frontend/add-a-page/

* **You'll build:** A Service providers page with a table
* **Time:** About 15 minutes
* **Touches:** `app/service-providers/`, `router.tsx`, `nav-data.ts`
* **Before you start:** Stack running, generated client up to date

## Build the page

**The example lists the company’s service providers, an endpoint the API already has but no page lists yet (only the appointment dialogs call it). Every step copies a pattern from the Customers or Dashboard pages.**

1. **Find the hook.** The endpoint is `GET /api/v1/booking/service-providers`, and the generated client already has `useGetServiceProviders()`, which returns `ServiceProviderDto[]`. For a new endpoint, run `npm run generate:api` first; see [Typed API client](/docs/frontend/api-client/).

   src/AppointMe.Frontend/src/api/client.ts

   ```ts
   export const getServiceProviders = (signal?: AbortSignal) => {
       return apiClient<ServiceProviderDto[]>({ url: `/api/v1/booking/service-providers`, method: 'GET', signal });
   };
   ```

   Note the permission the handler demands, because the page has to match it:

   src/Booking/AppointMe.Booking/ServiceProviders/GetServiceProviders/GetServiceProvidersQueryHandler.cs

   ```cs
       public async Task<IReadOnlyList<ServiceProviderDto>> HandleAsync(GetServiceProvidersQuery query,
           CompanyId companyId, IPrincipal principal, CancellationToken cancellationToken)
       {
           principal.Require(AppointmentPermissions.View);
   ```

   `AppointmentPermissions.View` is `appointments:view` on the wire.

2. **Define the columns.** Create the feature folder `src/app/service-providers/` (kebab-case, like every folder) and put the column definitions in `columns.tsx`. Type them against `DataTableFeatures`, and name the export `Columns`, as `customers/columns.tsx` does.

   src/AppointMe.Frontend/src/app/service-providers/columns.tsx (new)

   ```tsx
   import { ServiceProviderDto } from '@/api/client.schemas';
   import { type DataTableFeatures } from '@/components/ui';
   import { ColumnDef } from '@tanstack/react-table';


   export const Columns: ColumnDef<DataTableFeatures, ServiceProviderDto>[] = [
       { accessorKey: 'fullName', header: 'Name', enableHiding: false },
       { accessorKey: 'firstName', header: 'First name', meta: { className: 'hidden md:table-cell' } },
       { accessorKey: 'lastName', header: 'Last name', meta: { className: 'hidden md:table-cell' } },
   ];
   ```

   `DataTable` applies `meta.className` to every cell of the column, so `hidden md:table-cell` hides it on narrow screens. Use a `cell` renderer for anything richer than text; `customers/columns.tsx` renders an avatar and a `FormattedDate`.

3. **Gate the page.** In `service-providers.tsx`, wrap the content in `Can` with a `fallback`, the way `Dashboard` does. The sidebar hides the entry from users without the permission, but nothing stops them typing the URL.

   src/AppointMe.Frontend/src/app/service-providers/service-providers.tsx (new)

   ```tsx
   import { Columns } from './columns';
   import { useGetServiceProviders } from '@/api/client';
   import { Can } from '@/components/auth';
   import { ErrorWidget } from '@/components/error/error-widget';
   import { DataTable, dataTableFeatures, Empty, EmptyHeader, EmptyTitle } from '@/components/ui';
   import { useTable } from '@tanstack/react-table';


   export const ServiceProviders = () => (
       <Can permission='appointments:view' fallback={<NoAccess />}>
           <ServiceProvidersContent />
       </Can>
   );


   const NoAccess = () => (
       <Empty>
           <EmptyHeader>
               <EmptyTitle>You don't have access to service providers</EmptyTitle>
           </EmptyHeader>
       </Empty>
   );
   ```

   The `permission` prop is typed as the generated `Permission` union, so a misspelled key fails the type-check.

4. **Load the data and render the table.** In the same file, call the hook, build the table with `useTable` and the shared `dataTableFeatures`, and hand both states to `DataTable`.

   src/AppointMe.Frontend/src/app/service-providers/service-providers.tsx (new)

   ```tsx
   const ServiceProvidersContent = () => {
       const { data: providers, isLoading, isFetching, isError, refetch } = useGetServiceProviders();


       const table = useTable({
           features: dataTableFeatures,
           data: providers ?? [],
           columns: Columns,
           getRowId: row => row.id,
       });


       if (isError) {
           return <ErrorWidget title='Failed to load service providers' onRetry={() => refetch()} />;
       }


       return (
           <DataTable
               table={table}
               isLoading={isLoading}
               isFetching={isFetching}
               emptyMessage='No service providers yet.'
           />
       );
   };
   ```

   | State                             | What renders                                                                                          |
   | --------------------------------- | ----------------------------------------------------------------------------------------------------- |
   | `isLoading`                       | Skeleton rows from `DataTableLoading` (5 by default, `skeletonRows` to change)                        |
   | `isFetching` after the first load | The existing rows, dimmed                                                                             |
   | No rows                           | `emptyMessage`, or “No results.”                                                                      |
   | `isError`                         | `ErrorWidget` with a Retry button. Network errors and `5xx` also raise the global toast; `4xx` don’t. |

   No paginated row model is registered, so the table shows every row. For server-side paging, copy `customers/customers.tsx`: `manualPagination: true`, `rowCount` from the response, page and limit passed to the hook, and `DataTablePagination` under the table.

5. **Export the page and add the route.** Every feature folder has an `index.ts` barrel, and the router imports from it.

   src/AppointMe.Frontend/src/app/service-providers/index.ts (new)

   ```ts
   export * from './service-providers';
   ```

   In `router.tsx`, add the route to the `children` of the `path: '/'` route, next to `customers`. That places it inside `Layout`, with the sidebar and header, and under the company, permission and modal-dialog providers.

   src/AppointMe.Frontend/src/app/router/router.tsx

   ```tsx
   import { ServiceProviders } from '@/app/service-providers';
   // …
                                       {
                                           path: 'service-providers',
                                           element: <ServiceProviders />,
                                           handle: { breadcrumb: 'Service providers', navId: 'service-providers' },
                                       },
   ```

   `breadcrumb` is the header’s crumb, and `navId` marks the sidebar entry as active.

6. **Add the sidebar entry.** In `nav-data.ts`, add an item to `navMain` and add `ContactIcon` to the `lucide-react` import. The `navId` must match the route’s `handle.navId`, and `permission` hides the entry from users who lack it.

   src/AppointMe.Frontend/src/app/layouts/sidebar/nav-data.ts

   ```ts
           {
               title: 'Service providers',
               url: '/service-providers',
               navId: 'service-providers',
               icon: ContactIcon,
               permission: 'appointments:view',
           },
   ```

7. **Add a form when the endpoint takes input.** Service providers have no create endpoint (the Booking module derives them from employees), so this page stops at the table. For a page that writes, follow `create-customer.tsx`: a `zod` schema in the same file, `useForm` with `zodResolver`, and a generated mutation hook.

   src/AppointMe.Frontend/src/app/customers/create-customer.tsx

   ```tsx
   const formSchema = z.object({
       firstName: z.string().min(1, 'First name is required').max(100),
       lastName: z.string().max(100).optional(),
       email: z.email('Invalid email address').max(200, 'Email has to be 200 characters at max'),
       dateOfBirth: z.string().optional(),
       gender: z.enum([Gender.Male, Gender.Female, Gender.Other]).optional(),
   });


   type CreateCustomerFormValues = z.infer<typeof formSchema>;
   // …
       const { control, formState, handleSubmit } = useForm<CreateCustomerFormValues>({
           resolver: zodResolver(formSchema),
           mode: 'onTouched',
           defaultValues: {
               firstName: '',
               lastName: '',
               email: '',
               dateOfBirth: '',
               gender: undefined,
           },
       });
   ```

   Each input renders through a `Controller` into the shadcn `Field` components:

   src/AppointMe.Frontend/src/app/customers/create-customer.tsx

   ```tsx
                           <Controller
                               name='firstName'
                               control={control}
                               render={({ field, fieldState }) => (
                                   <Field data-invalid={fieldState.invalid}>
                                       <FieldLabel htmlFor='firstName'>
                                           First name <span className='text-destructive'>*</span>
                                       </FieldLabel>
                                       <Input {...field} id='firstName' placeholder='Jane' autoComplete='given-name' />
                                       {fieldState.error && <FieldError errors={[fieldState.error]} />}
                                   </Field>
                               )}
                           />
   ```

   The submit handler calls the mutation and invalidates the list’s query key; it’s shown on [Typed API client](/docs/frontend/api-client/). Use `SubmitButton` from `@/components/form` for the spinner while `formState.isSubmitting`. For a form in a dialog, copy `edit-customer-dialog.tsx`, which `useModalDialog().open(…)` opens from code.

## Verify it works

**Run the check CI runs, then start the stack, sign in and open the page from the sidebar.**

```bash
cd src/AppointMe.Frontend
npm run check
```

It format-checks, lints and type-checks in one pass. If it reports formatting or import order, `npm run format` fixes both.

| Check                                      | Expected                                                                                                                        |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `npm run check`                            | `pass: Found no warnings, lint errors, or type errors in …`                                                                     |
| The sidebar                                | A “Service providers” entry after “Customers”, highlighted while you’re on the page                                             |
| The header                                 | The breadcrumb reads “Service providers”                                                                                        |
| `https://localhost:5173/service-providers` | One row per employee with the `Staff` role. The user who created the company has it, so a new company shows at least that user. |
| A user without `appointments:view`         | No sidebar entry, and the URL shows “You don’t have access to service providers”                                                |
| Switching company in the header            | The table refetches for the new company                                                                                         |

## Troubleshooting

| Symptom                                                | Cause                                                                     | Fix                                                          |
| ------------------------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `useGetServiceProviders` (or your hook) isn’t exported | The generated client is older than the endpoint                           | Run `npm run generate:api`.                                  |
| The page renders without the sidebar or header         | The route sits outside the `children` of the `path: '/'` route            | Move it into those `children`.                               |
| The page opens but the sidebar entry never highlights  | The item’s `navId` doesn’t match the route’s `handle.navId`               | Use the same string in both.                                 |
| The sidebar entry doesn’t appear                       | The signed-in user lacks the item’s `permission`                          | Check `GET /api/v1/me/access` in the network tab.            |
| Oxlint warns `react/only-export-components`            | A `.tsx` file also exports a hook, a function or a non-primitive constant | Move it to a sibling `.ts` file.                             |
| `npm run check` fails on formatting                    | Import order, quotes or line width differ from Oxfmt’s rules              | Run `npm run format`.                                        |
| `ErrorWidget` shows and the network tab has a `403`    | The handler’s `principal.Require` rejected the user                       | Gate the page with the same permission the handler requires. |

## Next

* [Typed API client](/docs/frontend/api-client/) (Concept)
* [Auth & tenancy in the SPA](/docs/frontend/auth-and-tenancy/) (Concept)
* [Add an endpoint](/docs/guides/add-an-endpoint/) (Guide)
