Skip to content

Integration walkthrough

This walkthrough builds a feed screen the way the showcase app (apps/showcase — a fitness demo running against the live API) does it: install, authenticate, wrap your app in the provider, render a feed, then post an activity and react to one.

  1. Install the SDKs.

    Terminal window
    pnpm add @dropin/client @dropin/react @dropin/server
  2. Mint a token on your backend. The showcase app’s token route gates minting to a known roster (never an arbitrary caller-supplied id) and constructs a DropInServer per request from server-only env vars:

    // app/api/token/route.ts — server only, never bundled into the browser
    import { DropInServer } from '@dropin/server'
    export const dynamic = 'force-dynamic' // never cache a minted token
    export async function GET(req: Request): Promise<Response> {
    const athlete = new URL(req.url).searchParams.get('athlete') ?? ''
    if (!isAthleteId(athlete)) {
    return Response.json({ error: 'unknown athlete' }, { status: 400 })
    }
    const server = new DropInServer({
    tenantId: env.tenantId,
    apiKey: env.apiKey,
    apiSecret: env.signingSecret,
    url: env.apiUrl,
    })
    const token = await server.createUserToken(athlete, { expiresIn: '1h' })
    return Response.json({ token })
    }

    See Authentication & tokens for why the token is minted offline and what claims it carries.

  3. Wrap your app in the provider. The showcase’s Providers component reads the current athlete from its own session context, then hands DropInProvider a tokenProvider that calls the route above:

    app/providers.tsx
    'use client'
    import type { ReactNode } from 'react'
    import { DropInProvider } from '@dropin/react'
    import { SessionProvider, useSession } from '../lib/session.js'
    function KeyedClient({ children }: { children: ReactNode }) {
    const { athleteId } = useSession()
    const url = process.env.NEXT_PUBLIC_DROPIN_API_URL!
    const apiKey = process.env.NEXT_PUBLIC_DROPIN_API_KEY!
    const tokenProvider = async () => {
    const res = await fetch(`/api/token?athlete=${encodeURIComponent(athleteId)}`)
    if (!res.ok) throw new Error('token mint failed')
    return (await res.json()).token as string
    }
    return (
    <DropInProvider key={athleteId} url={url} apiKey={apiKey} tokenProvider={tokenProvider}>
    {children}
    </DropInProvider>
    )
    }
    export function Providers({ children }: { children: ReactNode }) {
    return (
    <SessionProvider>
    <KeyedClient>{children}</KeyedClient>
    </SessionProvider>
    )
    }

    The key={athleteId} remounts the provider (and its underlying client) whenever the signed-in user changes — that’s how the showcase’s athlete switcher works.

  4. Render a feed. The explore page reads a flat feed with useFeed; the following page reads the current user’s aggregated timeline with useTimeline. Both hand the same shape — activities, isLoading, error, hasNext, loadNext, refresh — to a shared FeedList:

    // app/(demo)/explore/page.tsx
    'use client'
    import { useFeed } from '@dropin/react'
    import { EXPLORE_FEED } from '../../../lib/demo-athletes.js'
    import { FeedList } from '../../../components/FeedList.js'
    export default function ExplorePage() {
    const f = useFeed<Workout>(EXPLORE_FEED.group, EXPLORE_FEED.id)
    return (
    <FeedList
    activities={f.activities} isLoading={f.isLoading} error={f.error}
    hasNext={f.hasNext} loadNext={f.loadNext} refresh={f.refresh}
    empty="No workouts yet — run the seed script."
    />
    )
    }
    // app/(demo)/following/page.tsx
    'use client'
    import { useTimeline } from '@dropin/react'
    import { useSession } from '../../../lib/session.js'
    export default function FollowingPage() {
    const { athleteId } = useSession()
    const f = useTimeline<Workout>(athleteId)
    return (
    <FeedList
    activities={f.activities} isLoading={f.isLoading} error={f.error}
    hasNext={f.hasNext} loadNext={f.loadNext} refresh={f.refresh}
    empty={<>Nothing here yet. Find athletes to follow →</>}
    />
    )
    }

    FeedList itself is a thin state-machine over those five fields — loading skeleton, error-with-retry, empty state, or a list of cards with a “Load more” button wired to loadNext:

    components/FeedList.tsx
    export function FeedList({
    activities, isLoading, error, hasNext, loadNext, refresh, empty,
    }: FeedListProps) {
    if (error && activities.length === 0) {
    return <div className="card">Couldn't load — <button onClick={refresh}>retry</button></div>
    }
    if (isLoading && activities.length === 0) return <FeedSkeleton />
    if (activities.length === 0) return <div className="card">{empty}</div>
    return (
    <div>
    {activities.map((a) => <WorkoutCard key={a.id} activity={a} />)}
    {hasNext && <button onClick={loadNext} disabled={isLoading}>
    {isLoading ? 'Loading…' : 'Load more'}</button>}
    </div>
    )
    }
  5. Post an activity and react. ComposeSheet posts through useFeedActions against the signed-in user’s own feed; LikeButton reacts through useReactions, seeded from the activity’s own denormalized counts so there’s no extra fetch:

    components/ComposeSheet.tsx
    'use client'
    import { useFeedActions } from '@dropin/react'
    import { useSession } from '../lib/session.js'
    export function ComposeSheet() {
    const { athleteId } = useSession()
    const { addActivity } = useFeedActions<Workout>('user', athleteId)
    const [note, setNote] = useState('')
    const [durationMin, setDuration] = useState(30)
    const [sport, setSport] = useState<Sport>('run')
    const post = async () => {
    await addActivity({ verb: 'workout', object: `workout:${Date.now()}`, custom: { sport, durationMin, note } })
    setNote('')
    }
    return <button onClick={post}>Post</button>
    }
    components/LikeButton.tsx
    'use client'
    import { useReactions } from '@dropin/react'
    export function LikeButton({
    activityId, initialCounts, initialOwn,
    }: {
    activityId: string
    initialCounts: Record<string, number>
    initialOwn: string[]
    }) {
    const { react, unreact, counts, ownReactions } = useReactions(activityId, initialCounts, initialOwn)
    const liked = ownReactions.includes('like')
    const count = counts.like ?? 0
    const toggle = () => (liked ? unreact('like') : react('like')).catch(() => { /* hook already rolled back */ })
    return (
    <button aria-pressed={liked} onClick={toggle} aria-label={liked ? 'Unlike' : 'Like'}>
    {liked ? '❤️' : '🤍'} {count}
    </button>
    )
    }

    react/unreact apply optimistically and roll back on failure — the catch above is there only to swallow the already-handled rejection.

Next: see the guides for the full per-task reference behind each hook used above.