Web implementation

Next.js page + admin.

9 minmedium

Web implementation: bookmark button on the product card, /bookmarks list page, optimistic UI so taps feel instant. We'll write everything by hand β€” turn off Copilot/Cursor/Tabnine so the patterns sink in.

The API client

apps/web/lib/api/bookmarks.ts
import { BookmarkSchema, type Bookmark } from '@my-saas/shared'
const API = process.env.NEXT_PUBLIC_API_URL!
export async function listBookmarks(): Promise<Bookmark[]> {
const res = await fetch(`${API}/api/bookmarks`, { credentials: 'include' })
if (!res.ok) throw new Error('Failed to load bookmarks')
const json = await res.json()
return json.data.map((b: unknown) => BookmarkSchema.parse(b))
}
export async function createBookmark(productId: number) {
const res = await fetch(`${API}/api/bookmarks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ product_id: productId }),
})
if (!res.ok) throw new Error('Failed to bookmark')
return BookmarkSchema.parse((await res.json()).data)
}
export async function deleteBookmark(id: number) {
const res = await fetch(`${API}/api/bookmarks/${id}`, {
method: 'DELETE',
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to remove')
}

Notice: BookmarkSchema.parse validates the response. If the API ever drifts, you crash here with a clear Zod error, not 5 components later.

The React Query hooks

apps/web/hooks/use-bookmarks.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import * as api from '@/lib/api/bookmarks'
export function useBookmarks() {
return useQuery({
queryKey: ['bookmarks'],
queryFn: api.listBookmarks,
})
}
export function useToggleBookmark(productId: number) {
const qc = useQueryClient()
const { data: bookmarks = [] } = useBookmarks()
const existing = bookmarks.find((b) => b.product_id === productId)
return useMutation({
mutationFn: () =>
existing ? api.deleteBookmark(existing.id) : api.createBookmark(productId),
onMutate: async () => {
// Optimistic update β€” flip the UI before the request returns
await qc.cancelQueries({ queryKey: ['bookmarks'] })
const prev = qc.getQueryData(['bookmarks'])
qc.setQueryData(['bookmarks'], (old: any[] = []) =>
existing
? old.filter((b) => b.id !== existing.id)
: [...old, { id: -1, product_id: productId, user_id: 0, created_at: new Date().toISOString() }],
)
return { prev }
},
onError: (_e, _v, ctx) => qc.setQueryData(['bookmarks'], ctx?.prev),
onSettled: () => qc.invalidateQueries({ queryKey: ['bookmarks'] }),
})
}

The optimistic update is the key UX win β€” click the heart, the heart fills, the request goes in the background. If it fails, the rollback restores the prior state.

The bookmark button

apps/web/components/bookmark-button.tsx
'use client'
import { Heart } from 'lucide-react'
import { useBookmarks, useToggleBookmark } from '@/hooks/use-bookmarks'
import { cn } from '@/lib/utils'
export function BookmarkButton({ productId }: { productId: number }) {
const { data: bookmarks = [] } = useBookmarks()
const toggle = useToggleBookmark(productId)
const isBookmarked = bookmarks.some((b) => b.product_id === productId)
return (
<button
onClick={() => toggle.mutate()}
disabled={toggle.isPending}
aria-label={isBookmarked ? 'Remove bookmark' : 'Bookmark'}
className="p-2 rounded-full hover:bg-bg-hover"
>
<Heart
className={cn(
'h-5 w-5 transition-colors',
isBookmarked ? 'fill-rose-500 text-rose-500' : 'text-text-secondary',
)}
/>
</button>
)
}

Drop this on every product card. One click, optimistic toggle, zero loading spinners on the happy path.

The /bookmarks list page

apps/web/app/bookmarks/page.tsx
'use client'
import { useBookmarks } from '@/hooks/use-bookmarks'
import { ProductCard } from '@/components/product-card'
import { useProducts } from '@/hooks/use-products'
export default function BookmarksPage() {
const { data: bookmarks = [], isLoading } = useBookmarks()
const { data: products = [] } = useProducts()
if (isLoading) return <p className="p-6">Loading…</p>
const items = bookmarks
.map((b) => products.find((p) => p.id === b.product_id))
.filter(Boolean)
if (items.length === 0) {
return <p className="p-6">No bookmarks yet β€” tap the heart on any product.</p>
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 p-6">
{items.map((p) => <ProductCard key={p!.id} product={p!} />)}
</div>
)
}
Why filter on the client? The list is small (rarely > 100 bookmarks per user). One products query + one bookmarks query, joined client-side, beats designing a special API endpoint. If your list grows, add ?include=product to the bookmarks endpoint.

Admin view β€” bookmark stats

On the admin side, bookmarks per product is a useful signal. Add it to the products admin page:

apps/admin (snippet)
columns: [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'price_cents', label: 'Price', format: (v) => '$' + (v / 100).toFixed(2) },
{ key: 'bookmark_count', label: 'Bookmarks', sortable: true },
],

The API computes bookmark_count with a JOIN COUNT(*). The admin UI is read-only; the action happens on web/mobile/desktop.

Quick check

Why use optimistic updates for the bookmark toggle instead of waiting for the server?

Try it

Build the web side of bookmarks end-to-end:

  1. Create the api client + hooks above.
  2. Add the BookmarkButton to every product card on the products page.
  3. Build the /bookmarks page.
  4. Test: log in, bookmark 3 products, visit /bookmarks, see them. Remove one β€” list updates instantly.
  5. Simulate offline (Chrome DevTools β†’ Network β†’ Offline). Click the heart β€” it should optimistically toggle, then rollback when the request fails. Test this β€” easy to miss.

What's next

Next lesson β€” Mobile implementation. Same feature on Expo. The biggest UX shift: heart-tap from a list of cards on a small screen, plus a dedicated bottom-tab.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled β€” suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub