Install
$ agentstack add skill-tomaspozo-agentlink-frontend ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo 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.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
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 →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
apischema + 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)
typedRpcfunction →src/lib/supabase.ts(alongside the client).RpcReturnMapinterface →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:
// 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/useEffectinside 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) incomponent. TanStack Start owns the entry point; there is nomain.tsx/index.html._auth.tsx— pathless layout withbeforeLoadthrow 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.tsxis the canonical reference (Table + Select + Badge + PageHeader). - Pickers → shadcn
Select(@/components/ui/select) viaController. 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 (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
- Source: tomaspozo/agentlink
- License: MIT
- Homepage: https://agentlink.sh
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.