Web implementation
Next.js page + admin.
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
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
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 returnsawait 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
'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 (<buttononClick={() => toggle.mutate()}disabled={toggle.isPending}aria-label={isBookmarked ? 'Remove bookmark' : 'Bookmark'}className="p-2 rounded-full hover:bg-bg-hover"><HeartclassName={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
'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>)}
?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:
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
Try it
Build the web side of bookmarks end-to-end:
- Create the api client + hooks above.
- Add the BookmarkButton to every product card on the products page.
- Build the /bookmarks page.
- Test: log in, bookmark 3 products, visit /bookmarks, see them. Remove one β list updates instantly.
- 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