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.
-
Install the SDKs.
Terminal window pnpm add @dropinnodex/client @dropinnodex/react @dropinnodex/serverTerminal window npm install @dropinnodex/client @dropinnodex/react @dropinnodex/serverTerminal window yarn add @dropinnodex/client @dropinnodex/react @dropinnodex/server -
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.getSessionUserbelow 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 browserimport { DropInServer } from '@dropinnodex/server'import { getSessionUser } from '@/lib/auth' // yours: returns the signed-in user, or nullexport 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 | undefinedconst 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' } })}// pages/api/token.ts — server only, never bundled into the browserimport type { NextApiRequest, NextApiResponse } from 'next'import { DropInServer } from '@dropinnodex/server'import { getSessionUser } from '@/lib/auth' // yours: returns the signed-in user, or null// 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 | undefinedconst 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 default async function handler(req: NextApiRequest, res: NextApiResponse) {const user = await getSessionUser(req)if (user === null) return res.status(401).json({ error: 'unauthenticated' })// The id comes from the session — `req.query` is never consulted.const token = await dropin().createUserToken(user.id, { expiresIn: '1h' })res.setHeader('Cache-Control', 'no-store') // never cache a minted tokenres.status(200).json({ token })}Not on Next.js? The dropin part is three lines — construct
DropInServer, authenticate the caller yourself, returncreateUserToken(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.
-
Wrap your app in the provider. It needs three things: your public API key and URL, a
tokenProviderthat calls the route above, anduserId— who that route is currently minting for.useSessionhere is the app’s own client-side session; step 2’sgetSessionUseris 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 handuserId, 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 (<DropInProviderurl={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>)} -
Render a feed. The explore page reads a flat feed with
useFeed; the following page reads the current user’s aggregated timeline withuseTimeline. Both hand the same shape —activities,isLoading,error,hasNext,loadNext,refresh— to a sharedFeedList:// 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 (<FeedListactivities={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 (<FeedListactivities={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 →</>}/>)}FeedListitself 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 toloadNext: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>)} -
Post an activity and react.
ComposeSheetposts throughuseFeedActionsagainst the signed-in user’s own feed;LikeButtonreacts throughuseReactions, 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: stringinitialCounts: Record<string, number>initialOwn: string[]}) {const { react, unreact, counts, ownReactions } = useReactions(activityId, initialCounts, initialOwn)const liked = ownReactions.includes('like')const count = counts.like ?? 0const 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/unreactapply optimistically and roll back on failure — thecatchabove is there only to swallow the already-handled rejection.
Troubleshooting integration
Section titled “Troubleshooting integration”What you haven’t seen yet
Section titled “What you haven’t seen yet”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.