# Frontend

> Frontend integration for the scaffolded React + TanStack Start (SPA) app on Supabase — client setup, RPC calls, TanStack Query and TanStack Router, TanStack Form + Zod forms, shadcn/ui primitives, the PageShell/PageHeader page anatomy, route guards and permission gating, workspace switching, the account section, SSR-later config, and client-side auth. Activate whenever the task touches the fronte…

- **Type:** Skill
- **Install:** `agentstack add skill-tomaspozo-agentlink-frontend`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [tomaspozo](https://agentstack.voostack.com/s/tomaspozo)
- **Installs:** 0
- **Category:** [Databases](https://agentstack.voostack.com/c/databases)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [tomaspozo](https://github.com/tomaspozo)
- **Source:** https://github.com/tomaspozo/agentlink/tree/main/skills/frontend
- **Website:** https://agentlink.sh

## Install

```sh
agentstack add skill-tomaspozo-agentlink-frontend
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# Frontend — Supabase Client Integration

Connecting frontend applications to the Supabase backend. Client initialization, RPC calls, auth state, routing, data fetching, forms, and type safety.

The CLI scaffolds a single frontend: **React + TanStack Start in SPA mode** (Vite under the hood, file-based routing via TanStack Router, TanStack Query for data). It ships as a fully client-rendered static SPA — no server. Because it's built on TanStack Start, server-side rendering is a config switch away later, with no route rewrites (see [Rendering](#rendering--spa-now-ssr-later)).

## Client Initialization

Scaffolded by the CLI in `src/lib/supabase.ts`. Uses `@supabase/supabase-js` directly — the app is client-rendered, so there's no server client to configure:

```typescript
import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
  { db: { schema: "api" } }
);
```

The data API schema is always `api` (`{ db: { schema: 'api' } }`). Env vars use Vite's `VITE_` prefix and are read via `import.meta.env` — this holds even in TanStack Start SPA mode, since the build is Vite-based.

The real scaffolded `lib/supabase.ts` adds two things the snippet above omits, and you should not strip them: a lazy `Proxy` so importing the module has no side effects (the SPA prerenders the route graph on the server), and a **global `fetch` wrapper that injects the `x-workspace-id` header** on every request from the active-workspace store. That header is the whole 2.0 workspace model — see [Auth on the Client](#auth-on-the-client). It also sets `auth: { flowType: "pkce", detectSessionInUrl: true }` for the email-confirm callbacks.

---

## Environment Variables

The scaffold uses Vite's `VITE_` prefix, read via `import.meta.env`:

| Variable | Purpose |
|----------|---------|
| `VITE_SUPABASE_URL` | Supabase API URL (client-safe) |
| `VITE_SUPABASE_PUBLISHABLE_KEY` | Publishable key (client-safe) |

There's no client-exposed secret key — the SPA only ever uses the publishable key, and RLS + the `api` schema are the security boundary. (Server-only secrets like `SUPABASE_SECRET_KEY` belong to edge functions, never the frontend bundle.)

### What's safe to expose

- **Client-safe:** Supabase URL and publishable key. These are embedded in the browser bundle. They only grant access through RLS policies — the `api` schema + RLS is the security boundary, not the key.
- **Server-only:** Secret key (service role key). Bypasses RLS entirely. Never expose to the client. Use only in server-side code, edge functions, or API routes.

### Finding connection values

**Local:** Run `npx supabase status` — prints the local API URL, publishable key, and secret key. Use these in your `.env.local` for development.

**Cloud:** Read from `.env.local` — values are pre-configured by the CLI scaffold. Do not use `npx supabase status`.

---

## Calling RPCs

All data access goes through `.rpc()` — never `.from()`. The `public` schema is not exposed via the Data API, so `.from()` cannot reach tables. This is a universal rule across all code (frontend, edge functions, webhooks, etc.), not just the client. For type-safe calls with real return types (instead of `Json`), use `typedRpc()` — see the next section.

### Basic pattern

The SQL function name maps directly to the RPC call. Parameters use the same names with the `p_` prefix:

```sql
-- SQL: api.chart_create(p_name text, p_description text)
```

```typescript
// Client call
const { data, error } = await supabase.rpc("chart_create", {
  p_name: "My Chart",
  p_description: "A description",
});
```

### Error handling

```typescript
const { data, error } = await supabase.rpc("chart_get_by_id", {
  p_chart_id: chartId,
});

if (error) {
  // error.message contains the RAISE EXCEPTION message from SQL
  // error.code is the Postgres error code (e.g., "P0001")
  console.error("RPC failed:", error.message);
  return;
}

// data is the jsonb return value from the function
```

### Calling RPCs that return arrays

```typescript
const { data, error } = await supabase.rpc("chart_list");

// data is already parsed — it's the jsonb array from the function
// { items: [...], total_count: 42, has_more: true }
```

### Don't use `.from()` — ever

`public` is not exposed via the Data API; `api` has no tables, only
functions. `supabase.from("charts").select()` fails with "permission
denied" or returns nothing regardless of which key you use —
publishable or secret. The rule is universal (frontend, edge
functions, webhooks, cron handlers, Node scripts): every data access
goes through `.rpc()`. If you're tempted to `.from()` for "quick
reads", add the RPC instead — it's a six-line SQL function with RLS
already carrying the weight.

---

## Type Safety

Generate TypeScript types from your database schema:

```bash
pnpm exec agentlink db types
```

This works in both local and cloud mode. Types are written to
`src/types/database.ts`. The scaffolded Supabase client already imports
from this path — just run `db types` (or `db apply`, which runs it for
you) to populate them.

```typescript
import { createClient } from "@supabase/supabase-js";
import type { Database } from "@/types/database";

const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
  { db: { schema: "api" } }
);

// RPC calls are now typed — parameters and return types are inferred
const { data } = await supabase.rpc("chart_get_by_id", { p_chart_id: id });
```

`db apply` regenerates types automatically (non-fatal on failure). To
regenerate manually: `pnpm exec agentlink db types`.

---

## typedRpc() Helper

Database-generated types return `Json` for every `jsonb` column, which
loses the shape of RPC return values. The scaffold ships a `typedRpc()`
helper that casts each RPC's return type using an `RpcReturnMap`
interface you maintain by hand.

### Where things live (scaffolded)

- **`typedRpc` function** → `src/lib/supabase.ts` (alongside the client).
- **`RpcReturnMap` interface** → `src/types/models.ts`.

So you always `import { typedRpc } from "@/lib/supabase"`, and extend
the map by editing `src/types/models.ts`.

### Extending the map

`src/types/models.ts` already imports the generated `Database` type
and exports helper types. Add each RPC's real return shape to
`RpcReturnMap`:

```typescript
// src/types/models.ts
import type { Database } from "./database";

export interface RpcReturnMap {
  chart_get_by_id: { id: string; name: string; created_at: string };
  chart_list: {
    items: Array;
    total_count: number;
    has_more: boolean;
  };
  chart_create: { id: string; name: string; created_at: string };
}
```

### Usage

```typescript
import { typedRpc } from "@/lib/supabase";

// Fully typed — return type is { id: string; name: string; created_at: string }
const chart = await typedRpc("chart_get_by_id", { p_chart_id: id });
```

`typedRpc` derives argument types from `Database["api"]["Functions"]`,
so the *parameters* are typed automatically once `db types` has run.
Only the *return* shapes need to live in `RpcReturnMap`.

> **Load [Data Fetching Patterns](./references/data_fetching.md) for the full `typedRpc()` implementation, `RpcReturnMap` conventions, and error handling patterns.**

---

## Data Fetching with TanStack Query

TanStack Query handles caching, background refetching, and loading/error states. All data fetching goes through query and mutation functions that call `typedRpc()` under the hood.

### Query options factory

Define query options in `src/queries/` — one file per entity:

```typescript
// src/queries/chart.ts
import { queryOptions } from "@tanstack/react-query";
import { typedRpc } from "@/lib/supabase";

export const chartQueries = {
  all: () => queryOptions({
    queryKey: ["charts"],
    queryFn: () => typedRpc("chart_list"),
  }),
  detail: (id: string) => queryOptions({
    queryKey: ["charts", id],
    queryFn: () => typedRpc("chart_get_by_id", { p_chart_id: id }),
  }),
};
```

### Mutations with cache invalidation

Define mutations in `src/mutations/` — one file per entity:

```typescript
// src/mutations/chart.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { typedRpc } from "@/lib/supabase";

export function useChartCreate() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (params: { p_name: string }) => typedRpc("chart_create", params),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["charts"] }),
  });
}
```

### Directory structure

```
src/
├── queries/          # queryOptions factories (read operations)
│   ├── chart.ts
│   └── tenant.ts
├── mutations/        # useMutation hooks (write operations)
│   ├── chart.ts
│   └── tenant.ts
```

> **Load [Data Fetching Patterns](./references/data_fetching.md) for full query key factories, cache invalidation strategies, optimistic updates, and prefetching in route loaders.**

---

## Forms with TanStack Form + Zod

Forms use TanStack Form (`@tanstack/react-form`) for state management and Zod for validation — the same family as this scaffold's TanStack Router + TanStack Query, and shadcn's own currently-documented forms stack. The pattern is: define a Zod schema, call `useForm` with `validators: { onSubmit: schema }`, and author each field with `form.Field`'s render prop wrapped in shadcn's `Field`/`FieldLabel`/`FieldError`.

### Basic pattern

```typescript
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";

const chartSchema = z.object({
  name: z.string().min(1, "Name is required"),
  description: z.string().optional(),
});

type ChartForm = z.infer;

function ChartCreateForm() {
  const chartCreate = useChartCreate();

  const form = useForm({
    defaultValues: { name: "", description: "" } as ChartForm,
    validators: { onSubmit: chartSchema },
    onSubmit: async ({ value }) =>
      chartCreate.mutate({ p_name: value.name, p_description: value.description }),
  });

  return (
     {
        e.preventDefault();
        e.stopPropagation();
        void form.handleSubmit();
      }}
    >
      
         {
            const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
            return (
              
                Name
                 field.handleChange(e.target.value)}
                  aria-invalid={isInvalid}
                />
                {isInvalid && }
              
            );
          }}
        />
        Create
      
    
  );
}
```

`Field`/`FieldGroup`/`FieldLabel`/`FieldError` (shadcn's `Field` component) wrap every field for consistent label, spacing, and error layout — never a raw `div` with `space-y-*`.

> **Load [Form Patterns](./references/forms.md) for full patterns including form modals, Select/Checkbox wiring, conditional validation, and cross-field `.refine()` checks.**

---

## Route Architecture

TanStack Router with file-based routing. Route files in `src/routes/` map directly to URL paths. The router is type-safe — route params, search params, and loader data are all typed.

### Key conventions

```
src/routes/
├── __root.tsx                # Root shell + providers — QueryClientProvider, AuthProvider, Toaster
├── index.tsx                 # PUBLIC /  (landing page)
├── _anon.tsx                 # Pathless layout — anon-only (redirects signed-in users to /dashboard)
├── _anon/                    # Logged-out-only pages
│   ├── sign-in.tsx           # /sign-in
│   ├── sign-up.tsx           # /sign-up
│   ├── forgot-password.tsx   # /forgot-password
│   └── check-inbox.tsx       # /check-inbox
├── _auth.tsx                 # Pathless gate — beforeLoad redirects to /sign-in when no session
└── _auth/                    # Everything here is gated
    ├── dashboard.tsx         # /dashboard
    ├── animals/
    │   ├── index.tsx         # /animals
    │   ├── $animalId.tsx     # /animals/:animalId
    │   └── -components/      # Route-scoped components (ignored by router)
    │       └── AnimalCard.tsx
    └── settings/members.tsx  # /settings/members
```

**Per-section gating is the whole API.** A file in `src/routes/*` is
public; a file in `src/routes/_auth/*` is gated. Drop a file in the
right folder and you're done — no wrappers, no hooks, no state
machines. Specifically, do not:

- build a `` / `` wrapper — the pathless
  `_auth` layout already gates at the route level before the tree
  mounts;
- hand-gate with `useState` / `useEffect` inside individual pages —
  you'll introduce flicker and a client-only race;
- put the app's only page under `_auth/` unless the app is genuinely
  fully gated end to end (no public landing, no public marketing,
  no public anything). Customer portals, SaaS apps, and most
  products want a public `/` and gated `/dashboard`.

- `__root.tsx` — root shell (`shellComponent`) + app providers (QueryClient, Auth, Toaster) in `component`. TanStack Start owns the entry point; there is no `main.tsx`/`index.html`.
- `_auth.tsx` — pathless layout with `beforeLoad` `throw redirect({ to: "/sign-in" })`. In SPA mode this guard is client-only (UX, not security).
- `$param` — dynamic route segments.
- `-components/` — folders prefixed with `-` are ignored by the router.

> **Load [Routing Patterns](./references/routing.md) for full patterns including navigation, search params, route loaders, and pending UI.**

---

## Shared Components

Reusable components that provide consistent UI patterns across the app. Check `src/components/` before building new ones.

| Component | Purpose | When to use |
|-----------|---------|-------------|
| `PageShell` | Page **wrapper** — centers content, caps column width, applies page padding | Wraps every gated page; `PageHeader` is its first child |
| `PageHeader` | Page **hero** — eyebrow + title + description + right-aligned `actions` slot | Top of every page; pass page-level buttons/filters to `actions` |
| `ListSkeleton` | Loading placeholder for list views | While query data is loading in list pages |
| `EmptyState` | Icon + message + action for empty collections | When a list query returns zero items |
| `ErrorBoundary` | Catches render errors, shows recovery UI | Wrap route components or complex sections |
| `Field`/`FieldGroup` (shadcn) | Label + input + error message wrapper | Every form field — keeps forms visually consistent |

### Page anatomy

Every gated page follows the same shape — compose the shipped primitives, don't re-inline headers or invent a new visual dialect:

```tsx

  Invite} />
  {isLoading ?  : items.length === 0 ?  : …}

```

- **Lists → shadcn `Table`** (`@/components/ui/table`). `routes/_auth/settings/members.tsx` is the canonical reference (Table + Select + Badge + PageHeader).
- **Pickers → shadcn `Select`** (`@/components/ui/select`) via `Controller`. **Never a native ``.**
- **Loading → `ListSkeleton`**; **empty → `EmptyState`**; **headers → `PageHeader`**.

### Need a primitive that isn't shipped?

The scaffold ships a curated shadcn set (`button`, `card`, `input`, `label`, `dialog`, `alert-dialog`, `dropdown-menu`, `tooltip`, `switch`, `badge`, `table`, `skeleton`, `select`, `separator`, `tabs`, `popover`, `sheet`, `command`, `checkbox`, `radio-group`, `textarea`, `accordion`, `avatar`, `scroll-area`, `alert`, `empty`, `field`) on the **Base UI** primitive library (shadcn's current default — `components.json`'s `style: "base-nova"`). For anything else, **add it on demand — `components.json` is pre-wired:**

```bash
npx shadcn@latest add  --yes   # writes the component + installs its deps
```

**Never hand-roll a primitive or fall back to a native element** when shadcn ships one — run the command above first.

Base UI's API differs from Radix in a few places (`asChild` → `render`, `Select` needs an `items` array, `Accordion` uses `multiple` + array `def

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [tomaspozo](https://github.com/tomaspozo)
- **Source:** [tomaspozo/agentlink](https://github.com/tomaspozo/agentlink)
- **License:** MIT
- **Homepage:** https://agentlink.sh

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** yes
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-tomaspozo-agentlink-frontend
- Seller: https://agentstack.voostack.com/s/tomaspozo
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
