Skip to content
StarLive demo →
GuideChecked against main · 31 Oct 20267 min read

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.

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

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.

    src/AppointMe.Frontend/src/api/client.tsView source ↗
    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.csView source ↗
    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)
    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)
    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)
    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)
    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
    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
    {
    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.tsxView source ↗
    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.tsxView source ↗
    <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. 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.

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

Terminal window
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
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.