# React Marketplace Template That Works Like an App

> Build a react marketplace template on Next.js: a what and where search, facets in the URL, listing cards and a detail page that behave like an app on phones.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Build it: screens and apps](https://wingo-ui.com/blog/category/build)
- Reading time: 15 min
- Canonical: https://wingo-ui.com/blog/react-marketplace-template

## TL;DR

A React marketplace template needs four screens that share one listing shape: a home page with a what and where search, a search page with facets, listing cards and a listing detail page. Keep the whole search (query, place, filters, sort, view) in the URL, have one search endpoint return the results, the facet counts and a price histogram, and on phones put the filters in a full height sheet whose button counts the results before you apply them. The Wingo UI marketplace blocks cover all four screens and are Pro; the open source Sharetribe Web Template (Apache-2.0) is the alternative when your backend is Sharetribe.

You want a marketplace front end: people search for something near them, narrow the results, save a few listings and message a seller. The usual starting point, a product grid next to a sidebar of checkboxes, turns into one long column on a phone. This tutorial builds the four screens a react marketplace template needs (home, search with facets, listing cards and a listing detail page) on the Next.js App Router, with the search state in the URL and a phone layout that behaves like an app. We build Wingo UI and the code uses our blocks, so we are not neutral; the URL and API patterns work with any components.

## What does a React marketplace template need?

Four screens around one listing shape, plus a shell that holds them together. A rental site, a used gear market and a classifieds app all share them:

| Screen | Its job | Built with | On a phone |
| --- | --- | --- | --- |
| Shell | Search, categories, account, footer | [Marketplace Shell](https://wingo-ui.com/components/marketplace-shell) | A compact header, a search sheet and a bottom tab bar with a raised Post action |
| Home | Start a search, browse categories and new listings | [Marketplace Home Page](https://wingo-ui.com/components/marketplace-home-page) | The fields stack, the promoted rail shows 1.3 cards, the grid shows two columns |
| Search | Narrow the results | [Marketplace Search Page](https://wingo-ui.com/components/marketplace-search-page) with the [Filter Panel](https://wingo-ui.com/components/filter-panel) | Filters in a full height sheet with a counted apply button |
| Card | Show one listing in a grid, a list or a rail | [Listing Card](https://wingo-ui.com/components/listing-card) | Swipe the photos, tap anywhere to open, tap the heart to save |
| Detail | Decide and contact the seller | [Listing Detail Page](https://wingo-ui.com/components/listing-detail-page) | A bottom bar with the price, Call and Message once the contact buttons scroll away |

Install the blocks you need. Each one pulls in the components and helpers it uses:

```bash
npx wingo-ui@latest add marketplace-shell marketplace-home-page marketplace-search-page listing-detail-page
```

The routes live in one route group, so the shell renders once around every page:

```
app/(market)/layout.tsx               the shell
app/(market)/page.tsx                 home
app/(market)/search/page.tsx          search results
app/(market)/listings/[id]/page.tsx   listing detail
```

The URL scheme is the one the blocks already use: the shell's header search and the home page's hero search both navigate to `/search?q=…&loc=…&cat=…` when you pass no `onSearch`, and the category tiles link to `/search?cat=<id>`. Build your search page around those three parameters and the default navigation works unchanged.

## How do you build the marketplace shell?

The shell is a client component that takes your router's link and push. It renders the header search (what and where), the category strip, the footer and, below 768px, the tab bar. The tab bar keeps clear of the home indicator through the safe area inset, and the page content gets matching bottom padding so the last row is never hidden behind it.

Its defaults are a finished demo, and that is the trap. With no props it shows a signed in sample user, 4 saved listings, 2 unread messages, the sample brand "Northwind Market" and category links to `/<id>` routes you do not have. So read the session in the server layout and hand it to a client view:

```tsx
// app/(market)/layout.tsx
import type { ReactNode } from "react"
import { getSession } from "@/server/session" // your auth
import { ShellView } from "./shell-view"

export default async function MarketLayout({ children }: { children: ReactNode }) {
  const session = await getSession() // { user: { name, email, avatar }, saved, unread } or null
  return <ShellView session={session}>{children}</ShellView>
}
```

```tsx
// app/(market)/shell-view.tsx
"use client"

import { Aperture, Camera, Plane } from "lucide-react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import type { ReactNode } from "react"
import {
  MarketplaceShell,
  type MarketplaceCategory,
  type MarketplaceShellUser,
} from "@/components/blocks/marketplace-shell"
import { Toaster } from "@/components/ui/toast"

// module scope: a new array on every render would reset the header search each time
const categories: MarketplaceCategory[] = [
  { id: "cameras", label: "Cameras", icon: <Camera />, href: "/search?cat=cameras" },
  { id: "lenses", label: "Lenses", icon: <Aperture />, href: "/search?cat=lenses" },
  { id: "drones", label: "Drones", icon: <Plane />, href: "/search?cat=drones" },
]
const noSearches: string[] = []
const labels = {
  appName: "Gearloop",
  home: "Gearloop, home",
  tagline: "Buy and sell used camera gear near you.",
  copyright: "© 2026 Gearloop",
}

type Session = { user: MarketplaceShellUser; saved: number; unread: number } | null

export function ShellView({ session, children }: { session: Session; children: ReactNode }) {
  const pathname = usePathname()
  const router = useRouter()
  return (
    <>
      <MarketplaceShell
        currentPath={pathname}
        linkComponent={Link}
        onNavigate={router.push}
        user={session?.user ?? null}
        favoritesCount={session?.saved ?? 0}
        messagesCount={session?.unread ?? 0}
        categories={categories}
        popularSearches={noSearches}
        recentSearches={noSearches}
        searchPlaceholder="Search cameras, lenses and drones"
        appBadges={false}
        labels={labels}
      >
        {children}
      </MarketplaceShell>
      <Toaster />
    </>
  )
}
```

The module scope constants are not style. The shell restarts its search state when `recentSearches` changes identity, and it does that during render, so an inline `recentSearches={[]}` sets state on every render until React stops it with a "Too many re-renders" error. The shell does not mount a toast region either, and the search page (saved searches) and the detail page (favorite errors, copied links) both raise toasts, so the `Toaster` goes here once.

Every link inside the blocks, including the stretched link on each listing card, goes through the `linkComponent` the shell provides, so a tap on a card navigates on the client without a full page load.

## How do you build the home page?

Fetch the listings in a server component and hand them to a small client view that owns the callbacks. The block is a client component, and a function such as `onFavoriteChange` cannot be passed from a server component to a client one.

```tsx
// app/(market)/page.tsx
import { getPromoted, getRecent, getStats } from "@/server/listings" // your data layer
import { HomeView } from "./home-view"

export default async function Page() {
  const [promoted, recent, stats] = await Promise.all([getPromoted(), getRecent({ limit: 24 }), getStats()])
  // stats: { listings, sellers, cities }
  return <HomeView promoted={promoted} recent={recent} stats={stats} />
}
```

```tsx
// app/(market)/home-view.tsx
"use client"

import { Aperture, Camera, Plane } from "lucide-react"
import {
  MarketplaceHomePage,
  type MarketplaceHomeCategory,
  type MarketplaceHomeStats,
} from "@/components/blocks/marketplace-home-page"
import type { ListingData } from "@/components/ui/listing-card"
import { saveFavorite } from "@/lib/favorites"

// each tile links to /search?cat=<id> unless it has an href
const categories: MarketplaceHomeCategory[] = [
  { id: "cameras", label: "Cameras", icon: <Camera /> },
  { id: "lenses", label: "Lenses", icon: <Aperture /> },
  { id: "drones", label: "Drones", icon: <Plane /> },
]
const popularSearches = ["Fujifilm X100", "Sony A7 III", "DJI Mini"]

type Props = { promoted: ListingData[]; recent: ListingData[]; stats: MarketplaceHomeStats }

export function HomeView({ promoted, recent, stats }: Props) {
  return (
    <MarketplaceHomePage
      promoted={promoted}
      recent={recent}
      stats={stats}
      categories={categories}
      popularSearches={popularSearches}
      defaultLocation=""
      showTrust={false}
      showApp={false}
      onFavoriteChange={saveFavorite}
    />
  )
}
```

`defaultLocation=""` is the prop that is easiest to miss. The New listings grid keeps only the listings whose `location`, up to the first comma, equals the chosen town's name, and the default town is Cluj-Napoca. Listings in New York or Berlin would leave the grid on "No listings in Cluj-Napoca yet". An empty string means every location.

The heart is optimistic: it fills at once and rolls back when the promise you return rejects. `fetch` only rejects on a network error, so the helper throws on an error status itself:

```ts
// lib/favorites.ts
export async function saveFavorite(id: string, favorite: boolean) {
  const response = await fetch(`/api/favorites/${id}`, { method: favorite ? "PUT" : "DELETE" })
  if (!response.ok) throw new Error("Could not save the listing")
}
```

> Live demo (Marketplace Home Page): Type a search, pick a place and press Search. Then swipe the Promoted rail, change the city chip above New listings and tap a heart: it fills in the rail and the grid together. Try it at [Marketplace Home Page](https://wingo-ui.com/components/marketplace-home-page) and install it with `npx wingo-ui@latest add marketplace-home-page`.

The other props in the view replace sample content that would otherwise ship. `stats` feeds the line under the headline, which defaults to "1,284,310 listings in 3,180 towns and villages". `showTrust` and `showApp` hide the trust tiles (cash on delivery, couriers from $9.99, verified sellers) and the app band with its QR code, until your own `labels` and `appUrl` say something true about your service. `loading` turns the tiles, the rail and the grid into skeletons of their real shape while the hero stays usable.

## How should a React listing card behave on a phone?

A listing card looks simple until you put it on a phone. The photos should swipe sideways without fighting the page scroll, a tap anywhere should open the listing, the heart must not open the listing, and a grid of 48 cards should not load 240 images. The [Listing Card](https://wingo-ui.com/components/listing-card) handles those: the title is one link stretched over the card (so long press gives the native link menu), and vertical scrolling always wins over a photo swipe. Each card renders one plain image until the carousel is needed: with a mouse, on the first pointer or focus on the photo; on a touch screen, when the card comes within 240px of the viewport.

Every block in this tutorial passes the same `ListingData` object around:

```ts
import type { ListingData } from "@/components/ui/listing-card"

export const camera: ListingData = {
  id: "l2041",
  title: "Fujifilm X-T4 body, low shutter count",
  href: "/listings/l2041",
  images: ["/uploads/l2041-1.jpg", "/uploads/l2041-2.jpg", "/uploads/l2041-3.jpg"],
  price: 950,
  currency: "USD",
  priceType: "negotiable",
  location: "New York, Brooklyn",
  postedAt: "2026-10-15T09:12:00",
  delivery: true,
  condition: "used",
  attributes: ["26 MP", "4K video", "Box included"],
  seller: { name: "Emma Carter", verified: true, rating: 4.9 },
}
```

When you use the card on your own pages, such as a saved listings screen, three props matter. `size="sm"` fits two columns on a phone, `priority` loads the first row eagerly, and `maxImages={1}` turns the photo carousel off for cards inside a horizontal rail, because a carousel inside a carousel is a gesture nobody can predict.

```tsx
"use client"

import { ListingCard, type ListingData } from "@/components/ui/listing-card"
import { saveFavorite } from "@/lib/favorites"

export function SavedListings({ listings }: { listings: ListingData[] }) {
  return (
    <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
      {listings.map((listing, index) => (
        <ListingCard
          key={listing.id}
          listing={listing}
          size="sm"
          priority={index < 4}
          defaultFavorite
          onFavoriteChange={(favorite) => saveFavorite(listing.id, favorite)}
        />
      ))}
    </div>
  )
}
```

> Live demo (Listing card): Swipe or drag the photos, then tap the heart: it pops when it saves and shakes back if saving fails. Switch layout to list or compact in the playground to see the other shapes. Try it at [Listing card](https://wingo-ui.com/components/listing-card) and install it with `npx wingo-ui@latest add listing-card`.

## How do marketplace search filters work in React?

Marketplace search filters need two layouts and live counts. A sidebar of checkboxes breaks in two places: on phones, where it has no room, and on the server, where every facet needs a count. The [Filter Panel](https://wingo-ui.com/components/filter-panel) takes a list of `FilterDef` objects and ships both layouts in the server HTML with CSS classes that show one of them, so a phone never flashes the sidebar before hydration. From 1024px it is a sticky sidebar where each change applies at once. Below that it becomes a Filters button with the active count, and the button opens a full height sheet that keeps a draft. The apply button counts the draft as you change it, so people see "No results, try fewer filters" before they commit. Swiping the sheet down or pressing Escape throws the draft away. We cover why a sheet beats a popover on a phone in our guide to the [React bottom sheet for the web](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web).

The search page builds its facets from the category by default. For your own category, pass `filters`:

```ts
// app/(market)/search/filters.ts
import type { FilterDef } from "@/components/ui/filter-panel"

export const cameraFilters: FilterDef[] = [
  {
    type: "price",
    id: "price",
    label: "Price",
    min: 0,
    max: 5000,
    presets: [
      { min: null, max: 300 },
      { min: 300, max: 1000 },
      { min: 1000, max: null },
    ],
  },
  {
    type: "chips",
    id: "condition",
    label: "Condition",
    options: [
      { value: "new", label: "New" },
      { value: "used", label: "Used" },
    ],
  },
  {
    type: "checkbox",
    id: "brand",
    label: "Brand",
    options: [
      { value: "canon", label: "Canon" },
      { value: "fujifilm", label: "Fujifilm" },
      { value: "nikon", label: "Nikon" },
      { value: "sony", label: "Sony" },
    ],
  },
  { type: "toggle", id: "delivery", label: "Delivery available" },
]
```

With a `search` function, the apply button's count is one more call: the page runs `search(draft, 0)` and reads `total`, debounced by 250ms, with stale answers dropped. So every pause in a draft costs one page 0 request: keep that query fast.

The options carry no `count` on purpose. When a checkbox, chips or radio option has none, the page fills it from the `counts` your search returns, so the numbers next to Canon and Sony always match the current results. An option with a count of 0 is disabled. The price filter has no `histogram` either: the page draws the bars from the `histogram` your search returns.

## How do you keep the search in the URL?

Parse the URL in the server page, then let a client view own the state and write it back with `window.history.replaceState`. Next.js integrates native `pushState` and `replaceState` calls with its router (`usePathname` and `useSearchParams` stay in sync), so the address bar updates without a request to the server on every checkbox. `searchParams` is a promise in Next.js 16, so the page awaits it.

```tsx
// app/(market)/search/page.tsx
import type { MarketplaceSearchState } from "@/components/blocks/marketplace-search-page"
import { SearchView } from "./search-view"

type Params = Record<string, string | string[] | undefined>
const one = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value)

function parseFilters(raw: string | undefined) {
  try {
    return raw ? JSON.parse(raw) : {}
  } catch {
    return {}
  }
}

export default async function SearchPage({ searchParams }: { searchParams: Promise<Params> }) {
  const params = await searchParams
  const state: MarketplaceSearchState = {
    query: one(params.q) ?? "",
    category: one(params.cat) ?? "",
    location: one(params.loc) ?? "",
    filters: parseFilters(one(params.f)),
    sort: one(params.sort) ?? "newest",
    view: one(params.view) === "list" ? "list" : "grid",
  }
  // a new search from the header remounts the view with the new state
  return <SearchView key={JSON.stringify(state)} initialState={state} />
}
```

```tsx
// app/(market)/search/search-view.tsx
"use client"

import { useState } from "react"
import {
  MarketplaceSearchPage,
  type MarketplaceSearchResult,
  type MarketplaceSearchState,
} from "@/components/blocks/marketplace-search-page"
import { saveFavorite } from "@/lib/favorites"
import { cameraFilters } from "./filters"

const relatedSearches = ["Mirrorless camera", "50mm lens", "Camera bag"]

function toQuery(state: MarketplaceSearchState) {
  const params = new URLSearchParams()
  if (state.query) params.set("q", state.query)
  if (state.category) params.set("cat", state.category)
  if (state.location) params.set("loc", state.location)
  if (Object.keys(state.filters).length) params.set("f", JSON.stringify(state.filters))
  if (state.sort !== "newest") params.set("sort", state.sort)
  if (state.view === "list") params.set("view", "list")
  return params.toString()
}

async function search(state: MarketplaceSearchState, page: number): Promise<MarketplaceSearchResult> {
  const response = await fetch(`/api/search?${toQuery(state)}&page=${page}`)
  if (!response.ok) throw new Error("Search failed")
  return response.json()
}

export function SearchView({ initialState }: { initialState: MarketplaceSearchState }) {
  const [state, setState] = useState(initialState)
  return (
    <MarketplaceSearchPage
      state={state}
      onStateChange={(next) => {
        setState(next)
        const query = toQuery(next)
        window.history.replaceState(null, "", query ? `/search?${query}` : "/search")
      }}
      filters={cameraFilters}
      search={search}
      relatedSearches={relatedSearches}
      viewPersistKey=""
      onFavoriteChange={saveFavorite}
      labels={{ title: ({ query, place }) => `${query ? `“${query}”` : "Cameras"} in ${place || "every location"}` }}
    />
  )
}
```

Two details in this code prevent real bugs. The `key` on `SearchView`: when someone runs a new search from the shell's header while already on `/search`, Next.js renders the same page with new `searchParams`, and without the key React would keep the old `useState` value. And `search` is defined outside the component: the page fetches page 0 again whenever the `search` function changes, so an inline arrow would refetch on every render.

We use `replaceState` so Back leaves the search page instead of undoing one checkbox at a time. Switch to `pushState` if your users expect Back to step through their filters.

### What does the search function return?

One page of results per call. Page 0 replaces the results and shows skeletons while it is pending; later pages append as the reader nears the end of the list (or presses Show more with `loadMode="button"`). Return this shape from `/api/search`:

```ts
type MarketplaceSearchResult = {
  items: ListingData[]
  total: number
  promoted?: ListingData[] // only with page 0
  histogram?: number[] // the price bars over the price slider
  counts?: Record<string, Record<string, number>> // filter id, then option value, then matches
}
```

Compute each facet's counts with every other filter applied, which is what the sample search (`searchListings` in the block's data file) does. That is the difference between a count that predicts the next click and one that only describes the whole catalog.

> Live demo (Marketplace Search Page): In this frame the filters sit behind the Filters button. Open it, drag the price thumbs and tick a condition: the apply button counts the draft before you apply it. Then remove a chip or switch to the list view. Try it at [Marketplace Search Page](https://wingo-ui.com/components/marketplace-search-page) and install it with `npx wingo-ui@latest add marketplace-search-page`.

One trade-off: with a `search` function, the first page loads in the browser after hydration, so the server HTML holds skeletons. Without `search`, the page runs `searchListings` over `items` (listings plus a `cityId`, a `facets` value per filter id and `keywords`) during render, and the results are in the HTML. For a small catalog you can fetch every listing on the server and pass `items`; for a large one, build indexable category pages separately and keep `/search` as the app screen.

## What goes on the listing detail page?

The photos, the price, the attributes, the seller and a way to make contact. The [Listing Detail Page](https://wingo-ui.com/components/listing-detail-page) takes the listing and the seller as data and the actions as callbacks. The phone number stays masked until someone presses Show phone, which calls your `onRevealPhone`, so you can log reveals and rate limit them.

```tsx
// app/(market)/listings/[id]/page.tsx
import { notFound } from "next/navigation"
import { getListing } from "@/server/listings" // your data layer
import { ListingView } from "./listing-view"

export default async function ListingPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const data = await getListing(id) // { listing, seller, sellerListings, similar } or null
  if (!data) notFound()
  return <ListingView {...data} />
}
```

Props you leave out fall back to sample data or a pretend request: no `sellerListings` shows the sample seller's other ads, no `onReport` reports a fake success, and no `onRevealPhone` reveals "(503) 555-0142". The callbacks drive the UI: a rejected `onMessage` keeps the text and shows the error in the dialog, and whatever `onRevealPhone` resolves to is shown as the number. Because `fetch` resolves on a 429 or a 500, check the status before you hand the response back, or a rate limit page ends up printed as a phone number:

```tsx
// app/(market)/listings/[id]/listing-view.tsx
"use client"

import { useRouter } from "next/navigation"
import { ListingDetailPage, type ListingDetail } from "@/components/blocks/listing-detail-page"
import type { ListingData } from "@/components/ui/listing-card"
import type { SellerData } from "@/components/ui/seller-card"
import { saveFavorite } from "@/lib/favorites"

type Props = { listing: ListingDetail; seller: SellerData; sellerListings: ListingData[]; similar: ListingData[] }

async function request(url: string, init: RequestInit | undefined, error: string) {
  const response = await fetch(url, init)
  if (!response.ok) throw new Error(error)
  return response
}

export function ListingView({ listing, seller, sellerListings, similar }: Props) {
  const router = useRouter()
  return (
    <ListingDetailPage
      listing={listing}
      seller={seller}
      sellerListings={sellerListings}
      similar={similar}
      status={listing.status === "sold" || listing.status === "reserved" ? listing.status : "active"}
      onMessage={(text) =>
        request("/api/messages", { method: "POST", body: JSON.stringify({ listing: listing.id, text }) }, "Your message was not sent. Try again.")
      }
      onRevealPhone={() =>
        request(`/api/listings/${listing.id}/phone`, undefined, "Could not load the number").then((response) => response.text())
      }
      onReport={(report) =>
        request(`/api/listings/${listing.id}/reports`, { method: "POST", body: JSON.stringify(report) }, "Your report was not sent")
      }
      onFavoriteChange={(favorite) => saveFavorite(listing.id, favorite)}
      onOpenConversation={() => router.push(`/messages?listing=${listing.id}`)}
    />
  )
}
```

Below 1024px, `stickyBar` (on by default) adds a bottom bar with the price, Call and Message once the contact buttons scroll out of view. The page's `status` prop is separate from `listing.status`, which is why the view maps one to the other. `status="sold"` greys the photos, disables contact and moves the similar listings up, so the page still leads somewhere; `"reserved"` shows a warning banner and keeps contact open. Return the number in international format (`+1 503 555 0142`): the tel: link adds a Romanian `+4` to a number that starts with 0.

## What will you need to change for your market?

The place data, and the category table behind the search page's title and breadcrumb. The blocks ship with sample data, and some of it is regional: the place fields, the radius filter and the distances use the Romanian town list in `lib/ro-locations`, because the kit started as a classifieds kit for that market.

| Block | Where the places come from | What to change |
| --- | --- | --- |
| Shell | `locations` prop, default `cityOptions()` from `lib/ro-locations` | Pass your cities as `{ value, label }` options |
| Home page | `cityOptions()` and `getCity()` imported in the file | Pass `defaultLocation=""` and point the import at your own list |
| Search page | `state.location` is looked up with `getCity()` | An unknown id shows as typed in the title; pass your own `location` filter in `filters` |

The sample categories (Bikes, Apartments for sale) live in `marketplace-search-page-data.ts`. An unknown category id falls back to the last entry, "All listings", with its sample related searches (iPhone 14, Sofa), so replace that table with your own categories, or at least pass `relatedSearches` and override `labels.title` as in the search view above.

The sample brand also hides in a few strings the snippets above do not touch. Search the installed files for "Northwind" before launch:

| Where | Sample value | What to change |
| --- | --- | --- |
| Shell | `consentStorageKey`, the cookie banner's link to `/cookies` | Your own key, a `/cookies` page, or `cookieConsent={false}` if you already ask for consent |
| Shell | `footerLinks` sample groups | Pass your own `{ title, links }` groups |
| Search page | `viewPersistKey`, the localStorage key for grid or list | `""` when the view lives in the URL, as in the search view above |
| Detail page | `labels.safetyTips` ("Keep the conversation in the Northwind Market chat") | Your own tips |

For the rest of the phone details, such as safe areas, tap highlights and touch targets, our list of [fixes for a web app that feels native](https://wingo-ui.com/blog/web-app-that-feels-native) applies to every screen here.

## Is there a free React marketplace template?

If you need a react marketplace template free of license fees, one open source option is the [Sharetribe Web Template](https://www.sharetribe.com/docs/template/introduction/sharetribe-web-template/), licensed Apache-2.0 on [GitHub](https://github.com/sharetribe/web-template). As of October 2026 its docs describe a React app on a vendored copy of Create React App (which they call deprecated), with Redux, CSS Modules and server-side rendering on a Node.js server, so serverless hosts are out. It talks to the Sharetribe APIs, which makes it the better choice when you run your marketplace on Sharetribe and want its backend instead of building your own. If you have your own backend and want App Router pages you own, the blocks in this tutorial are the shorter path.

## Where do you go from here?

Posting is the screen we left out: the [Post Listing Wizard](https://wingo-ui.com/components/post-listing-wizard) covers category, details, photos, price, place and a preview with an autosaved draft. The marketplace blocks and the Listing Card are Pro (see [pricing](https://wingo-ui.com/pricing)); the Button, Card, Dialog and other basics they build on are free. If you are building more screens than a storefront, the [Next.js admin dashboard tutorial](https://wingo-ui.com/blog/nextjs-admin-dashboard-tutorial) covers the back office side, and the [Build category](https://wingo-ui.com/blog/category/build) has the other screen by screen guides.

## Components in this post

- [Marketplace Home Page](https://wingo-ui.com/components/marketplace-home-page) (Pro): A React marketplace home page with a what and where search, category tiles, a promoted rail, new listings near you and an app band. Install: `npx wingo-ui@latest add marketplace-home-page`
- [Marketplace Search Page](https://wingo-ui.com/components/marketplace-search-page) (Pro): A React marketplace search results page: count, facets in a sidebar or phone sheet, filter chips, sort, grid or list and endless results. Install: `npx wingo-ui@latest add marketplace-search-page`
- [Listing card](https://wingo-ui.com/components/listing-card) (Pro): A React listing card for marketplaces: swipeable photos, price, place and time, badges and a heart, in grid, list and compact layouts. Install: `npx wingo-ui@latest add listing-card`
- [Filter panel](https://wingo-ui.com/components/filter-panel) (Pro): A React filter panel for faceted search: a sidebar that applies as you go on desktop, a full-height sheet with a live count on phones. Install: `npx wingo-ui@latest add filter-panel`
- [Marketplace Shell](https://wingo-ui.com/components/marketplace-shell) (Pro): A React marketplace layout shell with a what and where search header, a category strip, a footer, bottom tabs on phones and cookie consent. Install: `npx wingo-ui@latest add marketplace-shell`
- [Listing Detail Page](https://wingo-ui.com/components/listing-detail-page) (Pro): A React listing detail page for a classifieds marketplace: photo gallery, price card, seller, map, contact actions and similar listings. Install: `npx wingo-ui@latest add listing-detail-page`

## FAQ

### Is there a free React marketplace template?

Yes. The Sharetribe Web Template is open source under the Apache-2.0 license, but it is built to talk to the Sharetribe APIs and runs on a Node.js server. The Wingo UI marketplace blocks (shell, home, search, listing card, filter panel and detail page) are Pro.

### What pages does a marketplace front end need?

At minimum a layout shell with search, a home page, a search results page with filters, a listing detail page and a way to post a listing. Saved listings, messages and an account area usually follow, and they reuse the same listing card.

### How should marketplace search filters work on mobile?

Open them in a full height sheet from a Filters button that shows how many filters are active. Keep a draft while the sheet is open, count the results on the apply button as the draft changes, and throw the draft away when the sheet is swiped down or closed.

### Should marketplace filters be stored in the URL?

Yes. A search that lives in the URL survives a reload, the back button and a shared link, and the server can render the right page on the first request. In Next.js you can update it with window.history.replaceState without a round trip to the server.

### Do the Wingo UI marketplace blocks only work with Romanian cities?

No, but the sample place data is the Romanian town list in lib/ro-locations. The shell takes your own cities through its locations prop and the search page takes your own location filter; the home page reads the town list directly, so pass defaultLocation="" and point its import at your own list, since the block installs as source code.

---

Source: https://wingo-ui.com/blog/react-marketplace-template
