Examples
Real-world examples and code snippets for building with Skateboard v3.4.0
Examples
Real-world examples and code snippets to help you build with Skateboard.
All examples follow the Application Shell Architecture: views live in src/components/, import shadcn primitives and icons from the @stevederico/skateboard-ui package, and talk to the backend through the apiRequest utility (which auto-includes credentials and the CSRF token — never read a token from localStorage). See Components, API, and Authentication for the full reference.
View Examples
Empty-state View (BlankView)
The boilerplate ships BlankView, a reusable empty-state template used by the Analytics, Projects, and Team routes in src/main.tsx. It composes the shell <Header> with the shadcn Empty compound component.
// src/components/BlankView.tsx
import type { ReactNode } from 'react';
import Header from '@stevederico/skateboard-ui/Header';
import { Button } from '@stevederico/skateboard-ui/shadcn/ui/button';
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@stevederico/skateboard-ui/shadcn/ui/empty';
import { LayoutDashboard, Plus } from '@stevederico/skateboard-ui/icons';
/** Props for the BlankView starter template. */
interface BlankViewProps {
/** Header title */
title?: string;
/** Empty state description text */
description?: string;
/** CTA button text (e.g. "Create Project") */
buttonTitle?: string;
/** CTA button click handler */
onButtonClick?: () => void;
/** Custom icon element for empty state */
icon?: ReactNode;
/** Optional content to replace empty state */
children?: ReactNode;
}
export default function BlankView({ title = "Blank", description, buttonTitle, onButtonClick, icon, children }: BlankViewProps) {
return (
<>
<Header title={title} />
<div className="flex flex-1 flex-col gap-4 p-4">
{children || (
<div className="flex flex-1 items-center justify-center">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
{icon || <LayoutDashboard size={24} />}
</EmptyMedia>
<EmptyTitle>No {title.toLowerCase()} yet</EmptyTitle>
<EmptyDescription>
{description || `${title} will appear here once you get started.`}
</EmptyDescription>
</EmptyHeader>
{buttonTitle && (
<Button onClick={onButtonClick}>
<Plus size={18} />
{buttonTitle}
</Button>
)}
</Empty>
</div>
)}
</div>
</>
);
}
Wire it up in src/main.tsx via appRoutes. appRoutes is a typed array of { path, element } objects (paths are relative — no leading slash):
// src/main.tsx
import { createSkateboardApp } from '@stevederico/skateboard-ui/App';
import type { AppRoute } from '@stevederico/skateboard-ui/App';
import BlankView from './components/BlankView';
const appRoutes: AppRoute[] = [
{
path: 'projects',
element: <BlankView
title="Projects"
description="Create your first project to get started."
buttonTitle="Create Project"
/>,
},
];
Data-fetching View
Every data view handles three states — loading, error, and data — and fetches with useListData (never fetch in useEffect directly). useListData returns { data, loading, error, refetch } and calls the backend through apiRequest.
// src/components/ProjectsView.tsx
import Header from '@stevederico/skateboard-ui/Header';
import { useListData } from '@stevederico/skateboard-ui/Utilities';
import { Button } from '@stevederico/skateboard-ui/shadcn/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@stevederico/skateboard-ui/shadcn/ui/card';
import { Spinner } from '@stevederico/skateboard-ui/shadcn/ui/spinner';
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@stevederico/skateboard-ui/shadcn/ui/empty';
import { CircleAlert, Folder } from '@stevederico/skateboard-ui/icons';
/** A single project record returned by GET /api/projects. */
interface Project {
id: string;
name: string;
description: string;
}
export default function ProjectsView() {
const { data, loading, error, refetch } = useListData<Project>('/projects');
if (loading) {
return (
<div className="flex flex-1 items-center justify-center">
<Spinner />
</div>
);
}
if (error) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><CircleAlert size={24} /></EmptyMedia>
<EmptyTitle>Failed to load projects</EmptyTitle>
<EmptyDescription>{error}</EmptyDescription>
</EmptyHeader>
<Button onClick={refetch}>Try again</Button>
</Empty>
);
}
if (data.length === 0) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><Folder size={24} /></EmptyMedia>
<EmptyTitle>No projects yet</EmptyTitle>
<EmptyDescription>Create your first project to get started.</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
return (
<>
<Header title="Projects" />
<div className="grid grid-cols-1 gap-4 p-6 md:grid-cols-2 lg:grid-cols-3">
{data.map(project => (
<Card key={project.id}>
<CardHeader>
<CardTitle>{project.name}</CardTitle>
</CardHeader>
<CardContent>{project.description}</CardContent>
</Card>
))}
</div>
</>
);
}
Dashboard with Metric Cards
The boilerplate ships SectionCards, a static metric grid using the shadcn Card and Badge primitives with Lucide trend icons. HomeView renders it inside an @container/main layout.
// src/components/SectionCards.tsx
import { Card, CardHeader, CardTitle, CardDescription, CardFooter } from '@stevederico/skateboard-ui/shadcn/ui/card';
import { Badge } from '@stevederico/skateboard-ui/shadcn/ui/badge';
import { TrendingUp, TrendingDown } from '@stevederico/skateboard-ui/icons';
/** A single dashboard metric card. */
interface MetricCard {
title: string;
value: string;
trend: string;
up: boolean;
}
export function SectionCards() {
const cards: MetricCard[] = [
{ title: "Total Revenue", value: "$1,250.00", trend: "+12.5%", up: true },
{ title: "New Customers", value: "1,234", trend: "-20%", up: false },
{ title: "Active Accounts", value: "45,678", trend: "+12.5%", up: true },
{ title: "Growth Rate", value: "4.5%", trend: "+4.5%", up: true },
];
return (
<div className="grid grid-cols-1 gap-4 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{cards.map(card => (
<Card key={card.title}>
<CardHeader>
<CardDescription>{card.title}</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardFooter>
<Badge variant="outline">
{card.up ? <TrendingUp size={16} /> : <TrendingDown size={16} />}
{card.trend}
</Badge>
</CardFooter>
</Card>
))}
</div>
);
}
// src/components/HomeView.tsx
import Header from '@stevederico/skateboard-ui/Header';
import { SectionCards } from './SectionCards';
export default function HomeView() {
return (
<>
<Header title="Documents" />
<div className="@container/main flex flex-1 flex-col gap-4 p-6">
<SectionCards />
</div>
</>
);
}
Form Examples
Form with useForm
Use the shell useForm hook for form state. It returns { values, handleChange, handleSubmit, reset, submitting, error }. Validate on submit, pair every Input with a Label, wrap pairs in Field, use gap-* for spacing, and keep submit enabled until submission.
// src/components/SettingsForm.tsx
import Header from '@stevederico/skateboard-ui/Header';
import { useForm, apiRequest } from '@stevederico/skateboard-ui/Utilities';
import { Card, CardContent, CardHeader, CardTitle } from '@stevederico/skateboard-ui/shadcn/ui/card';
import { Button } from '@stevederico/skateboard-ui/shadcn/ui/button';
import { Input } from '@stevederico/skateboard-ui/shadcn/ui/input';
import { Label } from '@stevederico/skateboard-ui/shadcn/ui/label';
import { Field } from '@stevederico/skateboard-ui/shadcn/ui/field';
import { Spinner } from '@stevederico/skateboard-ui/shadcn/ui/spinner';
/** Props for the SettingsForm view. */
interface SettingsFormProps {
/** Authenticated user whose profile is being edited */
user?: { name?: string };
}
/** Editable profile fields posted to PUT /api/me. */
interface ProfileValues {
name: string;
}
export default function SettingsForm({ user }: SettingsFormProps) {
const { values, handleChange, handleSubmit, submitting, error } = useForm<ProfileValues>(
{ name: user?.name ?? '' },
async (formValues: ProfileValues) => {
// PUT /api/me whitelists only `name`
await apiRequest('/me', {
method: 'PUT',
body: JSON.stringify({ name: formValues.name }),
});
}
);
return (
<>
<Header title="Settings" />
<Card className="mx-auto max-w-md">
<CardHeader>
<CardTitle>Profile</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Field>
<Label htmlFor="name">Name</Label>
<Input
id="name"
name="name"
value={values.name}
onChange={handleChange('name')}
/>
</Field>
{error && <p className="text-destructive text-sm">{error}</p>}
<div className="flex justify-end gap-2">
<Button type="submit" disabled={submitting}>
{submitting ? <Spinner /> : 'Save'}
</Button>
</div>
</form>
</CardContent>
</Card>
</>
);
}
Note: the shell’s
useFormexposeshandleChangeas a curried helper — callhandleChange('name')to get theonChangehandler for thenamefield.
Authentication forms (sign up / sign in) are provided by the shell.
createSkateboardAppauto-mounts/signinand/signup, which post toPOST /api/signup({ email, password, name }) andPOST /api/signin({ email, password }) and set the HttpOnlytokenandcsrf_tokencookies. You don’t build these yourself — see Authentication.
Stripe / Checkout Example
The shell handles checkout end to end. The app only displays pricing from constants.stripeProducts; the upgrade flow lives in @stevederico/skateboard-ui. To trigger checkout from your own UI, call showCheckout — it posts { lookup_key, email } to POST /api/checkout (with CSRF + credentials) and redirects to the returned Stripe URL.
// src/components/UpgradeButton.tsx
import { showCheckout } from '@stevederico/skateboard-ui/Utilities';
import { useUser } from '@stevederico/skateboard-ui/Context';
import { Button } from '@stevederico/skateboard-ui/shadcn/ui/button';
export default function UpgradeButton() {
const user = useUser();
// showCheckout(email, productIndex = 0) reads
// constants.stripeProducts[productIndex].lookup_key for you.
return (
<Button onClick={() => showCheckout(user?.email)}>
Upgrade
</Button>
);
}
stripeProducts are referenced by Stripe lookup_key, not a hard-coded price ID. The default constants.json ships one product:
{
"stripeProducts": [
{
"price": "$5.00",
"title": "Unlimited",
"interval": "month",
"lookup_key": "my_lookup_key",
"features": ["Unlimited Todos", "Unlimited Messages", "All Premium Features"]
}
]
}
To open the Stripe customer portal for an existing subscriber, call showManage(stripeID) (posts { customerID } to POST /api/portal). See Stripe for the full setup, including the webhook endpoint POST /api/payment.
Usage Tracking Example
The boilerplate’s ChatView demonstrates freemium gating: free users get a usage limit (FREE_USAGE_LIMIT, default 20, over a rolling 30-day window), subscribers (subscription.status === 'active') are unlimited (remaining: -1). Usage flows through getRemainingUsage / trackUsage, which call POST /api/usage with { operation: 'check' } or { operation: 'track' }.
// src/components/UsageGatedAction.tsx
import { useState, useEffect, useRef } from 'react';
import type { ElementRef } from 'react';
import UpgradeSheet from '@stevederico/skateboard-ui/UpgradeSheet';
import { getRemainingUsage, trackUsage, showUpgradeSheet } from '@stevederico/skateboard-ui/Utilities';
import { useUser } from '@stevederico/skateboard-ui/Context';
import { Button } from '@stevederico/skateboard-ui/shadcn/ui/button';
/** Usage snapshot returned by getRemainingUsage / trackUsage. */
interface UsageInfo {
/** Remaining operations (-1 = unlimited) */
remaining: number;
/** Whether the user has an active subscription */
isSubscriber: boolean;
}
export default function UsageGatedAction() {
const user = useUser();
const [usageInfo, setUsageInfo] = useState<UsageInfo>({ remaining: -1, isSubscriber: true });
const upgradeSheetRef = useRef<ElementRef<typeof UpgradeSheet>>(null);
useEffect(() => {
getRemainingUsage('messages')
.then(setUsageInfo)
.catch(() => console.error("Couldn't load usage"));
}, []);
const handleAction = async () => {
// Gate non-subscribers who hit the limit
if (!usageInfo.isSubscriber && usageInfo.remaining <= 0) {
showUpgradeSheet(upgradeSheetRef);
return;
}
// Do the work, then record usage
const updated = await trackUsage('messages');
setUsageInfo(updated);
};
return (
<>
<Button onClick={handleAction}>
Run action
{!usageInfo.isSubscriber && usageInfo.remaining >= 0 && ` (${usageInfo.remaining} left)`}
</Button>
<UpgradeSheet ref={upgradeSheetRef} userEmail={user?.email} />
</>
);
}
When a free user exceeds the limit on track, the backend responds 429 with { error, remaining: 0, total, isSubscriber: false }.
API Integration Examples
apiRequest
apiRequest(endpoint, options) is the standard way to call the backend. It auto-includes credentials, auto-adds the CSRF token on mutations (POST/PUT/DELETE), auto-redirects to /signout on 401, parses JSON, throws on error, and has a 30-second timeout. Endpoints are relative to constants.backendURL (the /api prefix is configured there).
import { apiRequest } from '@stevederico/skateboard-ui/Utilities';
// GET the current user (GET /api/me)
const me = await apiRequest('/me');
// POST with a body — CSRF token added automatically
const project = await apiRequest('/projects', {
method: 'POST',
body: JSON.stringify({ name: 'New Project' }),
});
// PUT /api/me — only `name` is whitelisted by the backend
await apiRequest('/me', {
method: 'PUT',
body: JSON.stringify({ name: 'Ada Lovelace' }),
});
Context (current user)
Read the authenticated user and dispatch actions from the shell context.
import { getState } from '@stevederico/skateboard-ui/Context';
function Profile() {
const { state, dispatch } = getState();
const user = state.user;
if (!user) return null;
return (
<div>
<h1 className="text-heading-lg">Welcome, {user.name}!</h1>
<p className="text-copy-md text-muted-foreground">{user.email}</p>
<button onClick={() => dispatch({ type: 'CLEAR_USER' })}>Sign out</button>
</div>
);
}
These examples reflect the real component, utility, and endpoint surface in Skateboard v3.4.0. Copy them into src/components/ and adapt — the shell handles routing, auth, and theming so you can focus on views.