
Loading the page…
Loading the page…
How Parley keeps the open chat in the URL under Next 16, and ships its 22 Base UI primitives inside the component, under UpFork's 400,000-character cap.
Written with AI: Claude Opus 5.5, through Claude
3 lessons learned building it
The product
Parley — Team Chat App for React, shadcn/ui & Tailwind (Messenger UI)Pro plan · Component

The product
Pro plan · Component
A complete team chat for React and shadcn/ui: conversation list with tabs, threads with replies, reactions and Markdown, image lightbox, file uploads, voice notes, internal notes, @mentions, calls, contact and group details, profile and invites. Light and dark, phone to desktop.
npx shadcn@latest add @upfork/shadcn-chat-app-messenger-reactParley is a complete team chat for React, built on shadcn/ui with Base UI primitives and Tailwind CSS: a conversation list with tabs and live counts, threads with replies, reactions and Markdown, an image lightbox, file uploads, voice notes, internal notes, @mentions, calls, contact and group details, a profile sheet and invites. It works in light and dark, from a phone to a wide desktop.
This article is for anyone building a chat or inbox screen on Next.js and shadcn/ui, and for anyone shipping a shadcn component built on Base UI to other people's projects. It shows how Parley is put together, then three traps: one in how Next 16 reads the URL, and two that come from shipping Base UI components into someone else's project.
The chat's data changes only through pure actions in actions.ts, functions of the form (data, …args) → new data that never read the clock or the DOM: callers pass now and new ids in, so tests and Undo can use them directly. A zustand store (store.ts) wires them to React and holds the UI state beside them. User commands (commands.ts) run an action and show a sonner toast with Undo, and the destructive ones (clear, delete, block, leave) ask first in an AlertDialog. ChatLayout lays out the panes: the list, the conversation, and on wide screens a details panel; below lg it is one column, the list and then the open conversation full screen. The demo workspace's sixteen conversations are built on the client when the page opens, with their dates moved forward so the newest land "today".
The open conversation is ?c=<id> (and ?m=<id> for a linked message), so a chat is a link and the browser's Back button works. Opening a chat from the list pushes a history entry, so Back returns to the list on a phone; moving from one chat to another replaces it, so Back still lands on the list rather than walking through every chat you visited. Reading that back through Next's useSearchParams is where it broke.
Keeping its own route state also suits UpFork's live preview, where next/navigation is a stand-in whose useSearchParams is always empty. writeHistory wraps both history calls in a try, because, its comment says, sandboxed previews refuse them, and the chat works without them.
Parley was written on shadcn/ui's Base UI flavour, as the dashboard it comes from is. That decides how it has to be delivered.
That moved 22 more files into the listing, and UpFork's server has a hard limit on how much code one product may carry.
next build.npm run shots): light and dark at 1440, 1280, 1024, 768 and 390 px.ChatLayout takes defaultConversationId, the chat to open when the URL names none, and a className.actions.ts, wired to React by the store in store.ts. For a real backend, remove demo.ts, which fakes receipts, typing, canned replies and uploads.lib/contacts.ts; the demo conversations in data.ts.<Toaster /> once in your layout: the commands' Undo toasts need it.npx shadcn@latest add @upfork/shadcn-chat-app-messenger-reactInstalls with your UpFork access token once you have it: see how on its page.
Written by
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork
Full-stack developer and founder of UpFork · .NET, React/Next.js and WebGL
This version is signed in UpFork’s ledger as entry #221, so anyone can check it hasn’t changed since it was published. Check it.
Something wrong in it, or against the rules? Report it.

Behind the build · Rapport — Contacts App & CRM for React, shadcn/ui & Tailwind (Address Book UI)
What we learned building Rapport, a contacts app and light CRM on shadcn/ui with Base UI: six traps in the shadcn CLI, Base UI, Intl.Collator, libphonenumber and avatar loading.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork5 min readAI
6 lessons


Behind the build · Wisp — AI Chat App Template for Next.js, shadcn/ui & Tailwind (Streaming, Research, Canvas)
How Wisp keeps a 520-message chat scrolling with Shiki in a Web Worker, persists to IndexedDB without crashing next start, and eight traps with blur, scrollbars and code blocks.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork5 min readAI
8 lessons


Behind the build · Sofreh — Restaurant POS, Kitchen Display & Back Office Template for Next.js (English & Persian)
How Sofreh keeps a POS, kitchen screen and order board in sync across tabs with no server, builds 120 days of data in a Web Worker, and seven traps in Next.js, React 19, pnpm and testing.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork6 min readAI
7 lessons
history.pushStatehistory.replaceStatea plain pushState(null, …) did update it: probed in the browser, the header switched to the chat named in the new URL. But replaceState(null, …), which the layout uses to move from one chat to the next, changed the address bar and left useSearchParams with the old value. Alt+↓ (next conversation) worked once and then stuck: in a scripted run the URL went to ?c=leadership and stayed there on the next two presses, because the layout still thought the first chat was open. The build notes add that handing Next's own history.state back to these calls, the first attempt, broke both.
the layout keeps its own copy of the route, set whenever it opens or closes a chat and on popstate (Back and Forward), and reads useSearchParams only until it has one. The same run then stepped ?c=leadership, ?c=sales-marketing, ?c=jessica-martinez.
the build's probes and scripted runs before and after; chat-layout.tsx lines 57–63 and 90–128, with the comment "Next only syncs useSearchParams with history.pushState".
UpFork leaves a project's components/ui/* to the buyer's shadcn, and its preview builds with the Radix flavour. A buyer's Dialog, Popover or Select may be Radix-based, with different props from the Base UI ones Parley's code is written against.
the 22 primitives Parley uses ship inside it, in components/chat/ui/, with their imports rewritten to @/components/chat/ui/…, so it works in any shadcn project whichever flavour the buyer has.
the registry script's header (scripts/registry.mjs lines 8–10); LISTING.md, "Why the primitives ship inside the component".
steps in the registry script. Each vendored primitive keeps only the exports the chat (or another kept primitive) uses, pruned from the users down: 72,000 to 57,000, still about 430,000 in all. Then the listed copy is indented with tabs instead of pairs of spaces (safe here: no string in the code keeps leading spaces), and four exports that turned out to be dead were removed from the chat itself: 392,799 characters, checked with UpFork's own importer.
the build's measurements; scripts/registry.mjs lines 65–66 and 199–200; LISTING.md (63 files, 3 full-page demos, 25 assets, 392,799 of the 400,000 characters).
export function ChatLayout({ className, defaultConversationId }: ChatLayoutProps) {
// The open chat lives in the URL (?c=…, and ?m=… for a linked message). Next only syncs useSearchParams with
// history.pushState, so the layout keeps its own copy, updated on open/close and on back/forward.
const params = useSearchParams()
const [route, setRoute] = React.useState<{ c: string | null; m: string | null } | null>(null)
const urlId = route ? route.c : (params.get("c") ?? defaultConversationId ?? null)
const urlMessage = route ? route.m : params.get("m") const pushed = React.useRef(false)
const open = React.useCallback(
(id: string) => {
const url = new URL(window.location.href)
if (url.searchParams.get("c") === id && !url.searchParams.get("m")) return
url.searchParams.set("c", id)
url.searchParams.delete("m")
// Opening from the list adds a history entry (the back button returns to the list); moving from one chat to
// another replaces it, so Back always lands on the list.
if (new URL(window.location.href).searchParams.get("c")) {
writeHistory("replace", url)
} else {
pushed.current = writeHistory("push", url)
}
setRoute({ c: id, m: null })
},
[]
)
React.useEffect(() => {
const onPop = () => {
pushed.current = false
const url = new URL(window.location.href)
setRoute({ c: url.searchParams.get("c"), m: url.searchParams.get("m") })
}
window.addEventListener("popstate", onPop)
return () => window.removeEventListener("popstate", onPop)
}, [])
const close = React.useCallback(() => {
if (pushed.current) {
pushed.current = false
window.history.back()
return
}
const url = new URL(window.location.href)
url.searchParams.delete("c")
url.searchParams.delete("m")
writeHistory("replace", url)
setRoute({ c: null, m: null })
}, [])
Comments
0 comments
No comments yet. Questions, what you built with it, what could be better: its developer reads them.
Sign in to comment.
Sign in to comment