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.

The feed code is the showcase’s, verbatim. The token route here is deliberately stricter than the showcase’s, for the reason step 2 gives — the showcase has no login at all, so it cannot demonstrate the one thing that route has to get right.

  1. Install the SDKs.

    Terminal window
    pnpm add @dropinnodex/client @dropinnodex/react @dropinnodex/server
  2. Mint a token on your backend. One rule decides whether this route is safe: the user id comes from the session you already trust, never from the request. A route that mints for ?userId= is an impersonation endpoint — anyone can read anyone’s feed by editing a query string, and it will not look broken in testing.

    getSessionUser below is your function — whatever already answers “who is calling”, be it a cookie session, a verified Firebase ID token, or NextAuth. Everything else is dropin-specific and complete as written. The first tab is Next.js App Router; the Pages Router tab differs only in the handler signature.

    The showcase app is the exception that proves the rule: it is a public demo with an athlete switcher and no accounts, so it has no session to read. It mints from ?athlete= gated against a fixed roster — the strictest thing available when there is nobody to authenticate. Do not carry that pattern into an app that has users.

    // app/api/token/route.ts — server only, never bundled into the browser
    import { DropInServer } from '@dropinnodex/server'
    import { getSessionUser } from '@/lib/auth' // yours: returns the signed-in user, or null
    export const dynamic = 'force-dynamic' // never cache a minted token
    // Lazily, not at module scope: the constructor throws on missing config, and Next
    // evaluates route modules during `next build` — where these vars may not be set.
    let server: DropInServer | undefined
    const dropin = () => (server ??= new DropInServer({
    tenantId: process.env.DROPIN_TENANT_ID!,
    apiKey: process.env.DROPIN_API_KEY!,
    apiSecret: process.env.DROPIN_API_SECRET!, // never NEXT_PUBLIC_*
    url: process.env.DROPIN_API_URL!,
    }))
    export async function GET(): Promise<Response> {
    const user = await getSessionUser()
    if (user === null) return Response.json({ error: 'unauthenticated' }, { status: 401 })
    // The id comes from the session — nothing the caller sent reaches this line.
    const token = await dropin().createUserToken(user.id, { expiresIn: '1h' })
    return Response.json({ token }, { headers: { 'cache-control': 'no-store' } })
    }

    Not on Next.js? The dropin part is three lines — construct DropInServer, authenticate the caller yourself, return createUserToken(userId). Express, Fastify, Hono, Remix and SvelteKit all wrap it the same way.

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

  3. Wrap your app in the provider. It needs three things: your public API key and URL, a tokenProvider that calls the route above, and userId — who that route is currently minting for.

    useSession here is the app’s own client-side session; step 2’s getSessionUser is its server-side counterpart. They answer the same question from opposite sides, and the split matters: the client’s answer only decides which id to hand userId, so a tampered one buys nothing. The server’s answer is what actually authorizes minting.

    app/providers.tsx
    'use client'
    import type { ReactNode } from 'react'
    import { DropInProvider } from '@dropinnodex/react'
    import { SessionProvider, useSession } from '../lib/session.js'
    function FeedClient({ children }: { children: ReactNode }) {
    const { athleteId } = useSession()
    const tokenProvider = async () => {
    // No id in the URL — the route reads it from the session. See step 2.
    const res = await fetch('/api/token')
    if (!res.ok) throw new Error('token mint failed')
    return (await res.json()).token as string
    }
    return (
    <DropInProvider
    url={process.env.NEXT_PUBLIC_DROPIN_API_URL!}
    apiKey={process.env.NEXT_PUBLIC_DROPIN_API_KEY!}
    userId={athleteId}
    tokenProvider={tokenProvider}
    >
    {children}
    </DropInProvider>
    )
    }
    export function Providers({ children }: { children: ReactNode }) {
    return (
    <SessionProvider>
    <FeedClient>{children}</FeedClient>
    </SessionProvider>
    )
    }
  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 '@dropinnodex/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 '@dropinnodex/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 '@dropinnodex/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 '@dropinnodex/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.

The screen above is the smallest complete integration. Everything else the API does, in the order most apps need it:

You want to Read
Know every field on an activity Activity shape
Scroll instead of a “Load more” button Pagination — infinite scroll
Keep a rendered page fresh (new posts, edits, polling) Keeping a rendered page fresh
Update data an activity points at, after posting Objects & refs
Tell a user someone reacted or followed Notifications
Inject app-chosen content into a feed Promoted activities
React to feed events in your backend Webhooks
Import an existing feed history Migrating existing data
Write your first catch Errors
Know the ceilings before you hit them Limits

Read Errors before you write that first catch — every code, whether it is retryable, and what each SDK throws, in one table.

For the exhaustive surface — every method, option and type, generated from the SDK source — see the SDK reference.