---
title: Admin
description: The back office, where you look at your users, ban one, or sign in as one to see what they see.
---

`apps/admin` is the back office. It is the app you use to run the product, not
the app your users see. You run it on `admin.<domain>`.

It has one page that matters: the list of your users. From there you can search
them, ban one, or sign in as one.

The app is built with TanStack Start and runs on Cloudflare Workers by default. Vercel is supported too; see
[Deployment](/guides/deployment). A Worker
is a small server that Cloudflare runs close to you. It is smaller than the
other two apps on purpose. It has no languages, no blog and no billing.

## It has its own sign-in

This is the part to understand first.

The admin app does not share a sign-in with the web app. It runs on its own
address, and it holds its own session. A session is the proof that someone has
signed in. So you sign in here a second time, even if you are already signed in
on `app.<domain>`.

The app has its own sign-in page at `/sign-in`. It also has its own handler at
`src/routes/api.auth.$.ts`, which answers the sign-in requests of this app.

The users are the same users. Both apps read the same database. Only the
session is separate.

## The guard

A session is not enough. The user must also be an admin. The file
`src/routes/_protected.tsx` checks both:

```tsx
if (!(session && isAdmin(session.user.role))) {
  throw redirect({ to: "/sign-in" });
}
```

So there are three answers. An admin gets in. A normal user is sent back to
`/sign-in`. A visitor with no session is sent back too.

Those three cases are tested in `src/routes/_protected.node.test.tsx`, against
a real database.

The `role` of a user is a column in the `user` table. To make yourself an
admin, set your `role` to `admin` there. You can do that with
`pnpm run db:studio`, which opens the database in your browser.

## The users page

The page is at `/users`. The page at `/` sends you there.

The table shows the email, the name, the role and the status of each user.
Status is `Active` or `Banned`.

You can:

- Search the users by email.
- Sort by email, name, role or status.
- Show the details of one user.
- Select several users, and ban them all at once.
- Export what you see as a CSV file.

Each row has two buttons.

**Ban** stops a user from signing in. The button turns into **Unban**
afterwards, so the same button undoes it.

**Impersonate** signs you in as that user. You then see the app the way they
see it. It is the fastest way to understand a bug someone reports.

The table reads 25 users at a time. The search, the sort and the page are all
kept in the address. So you can copy a link and get the same view back.

## The frame of a page

Every page is wrapped in `AdminShell`, from `src/components/admin-shell.tsx`.
It draws the bar at the top, with the name of the app and the sign-out button.
Under the bar it draws the title of the page and the text below it.

```tsx
<AdminShell description="Every user of the app" title="Users">
  <DataTable {...props} />
</AdminShell>
```

Use it for any page you add, so your page looks like the ones that ship.

## Running it

```bash
pnpm run dev:admin
```

This starts the app on port 3003. Open `http://localhost:3003`.

```bash
pnpm run env:admin
```

This prints the environment the app loads. An environment variable is a value
you set outside the code, like a password.

The app needs a Postgres database running. Postgres is the database that stores
your users. See [Local development](/guides/local-development) for the
`docker compose up` that starts it.

The app declares no variables of its own. Its `.env.schema` takes the whole
root `.env.schema` as it is.

## Where the database address comes from

One file decides: `src/lib/database-url.ts`. On Cloudflare it reads the
Hyperdrive binding. Hyperdrive is a Cloudflare service that sits in front of
Postgres and makes queries faster.

```ts
export const databaseUrl = (): string => cloudflareEnv.HYPERDRIVE.connectionString;
```

The binding is set in `wrangler.jsonc`. There is a `TODO` comment next to the
`id` that ships. Replace it with the `id` of your own database before you
deploy.

## What it uses from the project

- [`@zap-ts/authentication`](/packages/authentication) — the sign-in, the session, the admin check
- [`@zap-ts/ui`](/packages/ui) — the table, the buttons, the layout
- [`@zap-ts/tanstack-start`](/packages/tanstack-start) — the sign-in form and shared page pieces
- [`@zap-ts/observability`](/packages/observability) — errors you need to see
- [`@zap-ts/database`](/packages/database) — used by the test only

There is no billing package here, and no mail package.

## Keeping it private

Search engines are told to stay away. Every page carries `noindex, nofollow`.

That is not a lock. It is a request, and a crawler can ignore it. Real safety
comes from the guard and from the `role` check. Keep the number of admins
small.

## What to change first

1. The Hyperdrive `id` in `wrangler.jsonc`.
2. The name in `src/components/admin-shell.tsx` and in `src/routes/__root.tsx`.
3. The labels on the sign-in page, in `src/routes/sign-in.tsx`. They are written in the file, not translated.

## Deploying

```bash
pnpm run deploy:admin:cloudflare
pnpm run deploy:admin:vercel
```

The host is part of the name, so a deploy never goes somewhere you did not
name. There is no `deploy` on its own.

The Cloudflare one runs `varlock-wrangler deploy`. It sends your environment
variables up with the app. You do not set them by hand in the Cloudflare
dashboard. The Vercel one runs `varlock run -- vercel deploy --prod`, and there
you set the variables yourself. See [Deployment](/guides/deployment).

Point `admin.<domain>` at this Worker, and nothing else at it.

## Related

- [Web](/apps/web) — the app your users sign in to
- [Marketing](/apps/marketing) — the public site
- [Authentication](/packages/authentication) — the session and the admin check
- [Database](/packages/database) — the `user` table and its `role` column
- [Going to production](/guides/going-to-production)
