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
Build the page
Section titled “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.
-
Find the hook. The endpoint is
GET /api/v1/booking/service-providers, and the generated client already hasuseGetServiceProviders(), which returnsServiceProviderDto[]. For a new endpoint, runnpm run generate:apifirst; 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.Viewisappointments:viewon the wire. -
Define the columns. Create the feature folder
src/app/service-providers/(kebab-case, like every folder) and put the column definitions incolumns.tsx. Type them againstDataTableFeatures, and name the exportColumns, ascustomers/columns.tsxdoes.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' } },];DataTableappliesmeta.classNameto every cell of the column, sohidden md:table-cellhides it on narrow screens. Use acellrenderer for anything richer than text;customers/columns.tsxrenders an avatar and aFormattedDate. -
Gate the page. In
service-providers.tsx, wrap the content inCanwith afallback, the wayDashboarddoes. 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
permissionprop is typed as the generatedPermissionunion, so a misspelled key fails the type-check. -
Load the data and render the table. In the same file, call the hook, build the table with
useTableand the shareddataTableFeatures, and hand both states toDataTable.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 (<DataTabletable={table}isLoading={isLoading}isFetching={isFetching}emptyMessage='No service providers yet.'/>);};State What renders isLoadingSkeleton rows from DataTableLoading(5 by default,skeletonRowsto change)isFetchingafter the first loadThe existing rows, dimmed No rows emptyMessage, or “No results.”isErrorErrorWidgetwith a Retry button. Network errors and5xxalso raise the global toast;4xxdon’t.No paginated row model is registered, so the table shows every row. For server-side paging, copy
customers/customers.tsx:manualPagination: true,rowCountfrom the response, page and limit passed to the hook, andDataTablePaginationunder the table. -
Export the page and add the route. Every feature folder has an
index.tsbarrel, 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 thechildrenof thepath: '/'route, next tocustomers. That places it insideLayout, 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' },},breadcrumbis the header’s crumb, andnavIdmarks the sidebar entry as active. -
Add the sidebar entry. In
nav-data.ts, add an item tonavMainand addContactIconto thelucide-reactimport. ThenavIdmust match the route’shandle.navId, andpermissionhides 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',}, -
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: azodschema in the same file,useFormwithzodResolver, 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
Controllerinto the shadcnFieldcomponents:src/AppointMe.Frontend/src/app/customers/create-customer.tsxView source ↗ <Controllername='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
SubmitButtonfrom@/components/formfor the spinner whileformState.isSubmitting. For a form in a dialog, copyedit-customer-dialog.tsx, whichuseModalDialog().open(…)opens from code.
Verify it works
Section titled “Verify it works”Run the check CI runs, then start the stack, sign in and open the page from the sidebar.
cd src/AppointMe.Frontendnpm run checkIt 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
Section titled “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. |