AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified MIT Self-run

Frontend

skill-tomaspozo-agentlink-frontend · by tomaspozo

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…

No reviews yet
0 installs
28 views
0.0% view→install

Install

$ agentstack add skill-tomaspozo-agentlink-frontend

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets Used
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-tomaspozo-agentlink-frontend)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
2mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Frontend? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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:

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: api.chart_create(p_name text, p_description text)
// Client call
const { data, error } = await supabase.rpc("chart_create", {
  p_name: "My Chart",
  p_description: "A description",
});

Error handling

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

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:

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.

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 functionsrc/lib/supabase.ts (alongside the client).
  • RpcReturnMap interfacesrc/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:

// 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

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:

// 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:

// 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

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:


  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:

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 (asChildrender, 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.

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

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.