# React Marketplace Template, das sich wie eine App anfühlt

> Bau ein React Marketplace Template mit Next.js: Suche nach Was und Wo, Filter in der URL, Inseratskarten und Detailseite, die am Smartphone wie eine App wirken.

- Autor: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Veröffentlicht: 16. Okt. 2026
- Kategorie: [Praxis: Screens und Apps](https://wingo-ui.com/de/blog/category/build)
- Lesezeit: 15 Min.
- Kanonische URL: https://wingo-ui.com/de/blog/react-marketplace-template
- Englische Fassung: https://wingo-ui.com/blog/react-marketplace-template

## Kurz gesagt

Ein React Marketplace Template braucht vier Screens mit einem gemeinsamen Datenformat für Inserate: eine Startseite mit einer Suche nach Was und Wo, eine Suchseite mit Filtern, Inseratskarten und eine Detailseite. Leg die ganze Suche (Begriff, Ort, Filter, Sortierung, Ansicht) in die URL, lass einen einzigen Such-Endpunkt die Ergebnisse, die Trefferzahlen pro Filter und ein Preis-Histogramm liefern, und pack die Filter auf dem Smartphone in ein Sheet in voller Höhe, dessen Button die Treffer zählt, bevor du sie anwendest. Die Marketplace-Blöcke von Wingo UI decken alle vier Screens ab und sind Pro; das Open-Source-Projekt Sharetribe Web Template (Apache-2.0) ist die Alternative, wenn dein Backend Sharetribe ist.

Du brauchst ein Frontend für einen Marktplatz: Leute suchen etwas in ihrer Nähe, grenzen die Ergebnisse ein, merken sich ein paar Inserate und schreiben einem Verkäufer. Der übliche Ausgangspunkt, ein Produktraster neben einer Seitenleiste voller Checkboxen, wird auf dem Smartphone zu einer einzigen langen Spalte. In diesem Tutorial baust du die vier Screens, die ein React Marketplace Template braucht (Startseite, Suche mit Filtern, Inseratskarten und eine Detailseite), mit dem Next.js App Router: den Suchzustand in der URL und ein Smartphone-Layout, das sich wie eine App verhält. Wir entwickeln Wingo UI, und der Code nutzt unsere Blöcke, neutral sind wir also nicht. Die Muster für URL und API funktionieren mit beliebigen Komponenten.

## Was braucht ein React Marketplace Template?

Vier Screens rund um ein gemeinsames Datenformat für Inserate, dazu eine Shell, die sie zusammenhält. Ein Vermietungsportal, ein Markt für gebrauchtes Equipment und eine Kleinanzeigen-App brauchen sie alle:

| Screen | Aufgabe | Gebaut mit | Auf dem Smartphone |
| --- | --- | --- | --- |
| Shell | Suche, Kategorien, Konto, Footer | [Marketplace Shell](https://wingo-ui.com/components/marketplace-shell) | Ein kompakter Header, ein Such-Sheet und eine Tab-Leiste unten mit einem hervorgehobenen Button zum Inserieren |
| Startseite | Eine Suche starten, Kategorien und neue Inserate durchsehen | [Marketplace Home Page](https://wingo-ui.com/components/marketplace-home-page) | Die Felder stehen untereinander, die Leiste mit hervorgehobenen Inseraten zeigt 1,3 Karten, das Raster zwei Spalten |
| Suche | Die Ergebnisse eingrenzen | [Marketplace Search Page](https://wingo-ui.com/components/marketplace-search-page) mit dem [Filter Panel](https://wingo-ui.com/components/filter-panel) | Filter in einem Sheet in voller Höhe mit einem Button, der die Treffer zählt |
| Karte | Ein Inserat im Raster, in einer Liste oder in einer Leiste zeigen | [Listing Card](https://wingo-ui.com/components/listing-card) | Fotos wischen, zum Öffnen irgendwo tippen, zum Merken aufs Herz tippen |
| Detail | Entscheiden und den Verkäufer kontaktieren | [Listing Detail Page](https://wingo-ui.com/components/listing-detail-page) | Eine Leiste unten mit Preis, Anrufen und Nachricht, sobald die Kontakt-Buttons aus dem Bild scrollen |

Installier die Blöcke, die du brauchst. Jeder bringt die Komponenten und Helfer mit, die er nutzt:

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

Die Routen liegen in einer Route Group, damit die Shell nur einmal gerendert wird und jede Seite umschließt:

```
app/(market)/layout.tsx               die Shell
app/(market)/page.tsx                 Startseite
app/(market)/search/page.tsx          Suchergebnisse
app/(market)/listings/[id]/page.tsx   Inserat im Detail
```

Als URL-Schema nimmst du das, was die Blöcke schon verwenden: Die Suche im Header der Shell und die Suche im Hero der Startseite navigieren beide zu `/search?q=…&loc=…&cat=…`, wenn du kein `onSearch` übergibst, und die Kategorie-Kacheln verlinken auf `/search?cat=<id>`. Bau deine Suchseite um diese drei Parameter herum, dann funktioniert die Standard-Navigation ohne Änderung.

## Wie baust du die Shell des Marktplatzes?

Die Shell ist eine Client-Komponente, die den Link und die Push-Funktion deines Routers bekommt. Sie rendert die Suche im Header (Was und Wo), die Kategorieleiste, den Footer und unter 768px die Tab-Leiste. Die Tab-Leiste hält über den Safe-Area-Inset Abstand zum Home-Indikator, und der Seiteninhalt bekommt unten den passenden Abstand, damit die letzte Reihe nie dahinter verschwindet.

Ihre Standardwerte ergeben eine fertige Demo, und genau das ist die Falle. Ohne Props zeigt sie einen angemeldeten Beispielnutzer, 4 gemerkte Inserate, 2 ungelesene Nachrichten, die Beispielmarke „Northwind Market“ und Kategorie-Links auf Routen wie `/<id>`, die es bei dir nicht gibt. Lies also die Session im Server-Layout und gib sie an eine Client-View weiter:

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

export default async function MarketLayout({ children }: { children: ReactNode }) {
  const session = await getSession() // { user: { name, email, avatar }, saved, unread } oder 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"

// auf Modulebene: ein neues Array bei jedem Render würde die Suche im Header jedes Mal zurücksetzen
const categories: MarketplaceCategory[] = [
  { id: "cameras", label: "Kameras", icon: <Camera />, href: "/search?cat=cameras" },
  { id: "lenses", label: "Objektive", icon: <Aperture />, href: "/search?cat=lenses" },
  { id: "drones", label: "Drohnen", icon: <Plane />, href: "/search?cat=drones" },
]
const noSearches: string[] = []
const labels = {
  appName: "Gearloop",
  home: "Gearloop, Startseite",
  tagline: "Gebrauchtes Kamera-Equipment in deiner Nähe kaufen und verkaufen.",
  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="Kameras, Objektive und Drohnen suchen"
        appBadges={false}
        labels={labels}
      >
        {children}
      </MarketplaceShell>
      <Toaster />
    </>
  )
}
```

Die Konstanten auf Modulebene sind keine Stilfrage. Die Shell setzt ihren Suchzustand zurück, sobald sich die Referenz von `recentSearches` ändert, und das passiert während des Renderns. Ein inline übergebenes `recentSearches={[]}` setzt also bei jedem Render State, bis React mit dem Fehler „Too many re-renders“ abbricht. Außerdem bringt die Shell keine Toast-Region mit. Die Suchseite (gespeicherte Suchen) und die Detailseite (Fehler beim Merken, kopierte Links) lösen aber beide Toasts aus, deshalb kommt der `Toaster` einmal hierher.

Jeder Link in den Blöcken, auch der Link, der sich über jede Inseratskarte spannt, läuft über die `linkComponent` der Shell. Ein Tipp auf eine Karte navigiert also im Client, ohne die Seite komplett neu zu laden.

## Wie baust du die Startseite?

Lade die Inserate in einer Server-Komponente und gib sie an eine kleine Client-View weiter, die die Callbacks enthält. Der Block ist eine Client-Komponente, und eine Funktion wie `onFavoriteChange` lässt sich nicht von einer Server-Komponente an eine Client-Komponente übergeben.

```tsx
// app/(market)/page.tsx
import { getPromoted, getRecent, getStats } from "@/server/listings" // deine Datenschicht
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"

// jede Kachel verlinkt auf /search?cat=<id>, außer sie hat ein href
const categories: MarketplaceHomeCategory[] = [
  { id: "cameras", label: "Kameras", icon: <Camera /> },
  { id: "lenses", label: "Objektive", icon: <Aperture /> },
  { id: "drones", label: "Drohnen", 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=""` ist die Prop, die du am leichtesten übersiehst. Das Raster „New listings“ zeigt nur Inserate, deren `location` bis zum ersten Komma dem Namen des gewählten Orts entspricht, und der Standardort ist Cluj-Napoca. Bei Inseraten in New York oder Berlin bliebe das Raster bei „No listings in Cluj-Napoca yet“ hängen. Ein leerer String bedeutet: alle Orte.

Das Herz ist optimistisch: Es füllt sich sofort und springt zurück, wenn das Promise, das du zurückgibst, abgelehnt wird. Das Promise von `fetch` wird nur bei einem Netzwerkfehler abgelehnt, deshalb wirft der Helfer bei einem Fehlerstatus selbst einen Fehler:

```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("Das Inserat konnte nicht gespeichert werden")
}
```

> Live-Demo (Marketplace Home Page): Gib einen Suchbegriff ein, wähl einen Ort und drück Search. Wisch dann durch die Leiste „Promoted“, wechsle den Orts-Chip über „New listings“ und tipp auf ein Herz: Es füllt sich in der Leiste und im Raster gleichzeitig. Probier sie unter [Marketplace Home Page](https://wingo-ui.com/components/marketplace-home-page) aus und installier sie mit `npx wingo-ui@latest add marketplace-home-page`.

Die übrigen Props in der View ersetzen Beispielinhalte, die sonst mit ausgeliefert würden. `stats` füllt die Zeile unter der Überschrift, die standardmäßig „1,284,310 listings in 3,180 towns and villages“ lautet. `showTrust` und `showApp` blenden die Vertrauens-Kacheln (Barzahlung bei Lieferung, Kurierversand ab $9.99, verifizierte Verkäufer) und den App-Bereich mit QR-Code aus, bis deine eigenen `labels` und deine `appUrl` etwas Zutreffendes über deinen Dienst sagen. `loading` ersetzt Kacheln, Leiste und Raster durch Skeletons in ihrer echten Form, während der Hero bedienbar bleibt.

## Wie sollte sich eine React Listing Card auf dem Smartphone verhalten?

Eine Inseratskarte sieht einfach aus, bis du sie aufs Smartphone bringst. Die Fotos sollen sich seitlich wischen lassen, ohne dem Scrollen der Seite in die Quere zu kommen, ein Tipp irgendwo soll das Inserat öffnen, das Herz darf es nicht öffnen, und ein Raster aus 48 Karten darf nicht 240 Bilder laden. Die [Listing Card](https://wingo-ui.com/components/listing-card) löst das: Der Titel ist ein Link, der über die ganze Karte gezogen ist (langes Drücken öffnet also das native Link-Menü), und vertikales Scrollen hat immer Vorrang vor dem Wischen durch die Fotos. Jede Karte rendert nur ein einfaches Bild, bis das Karussell gebraucht wird: mit der Maus, sobald der Zeiger zum ersten Mal auf das Foto kommt oder es den Fokus bekommt; auf einem Touchscreen, sobald die Karte bis auf 240px an den Viewport herankommt.

Jeder Block in diesem Tutorial reicht dasselbe `ListingData`-Objekt herum:

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

export const camera: ListingData = {
  id: "l2041",
  title: "Fujifilm X-T4 Gehäuse, wenige Auslösungen",
  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", "Mit Originalkarton"],
  seller: { name: "Emma Carter", verified: true, rating: 4.9 },
}
```

Wenn du die Karte auf eigenen Seiten nutzt, etwa auf einem Screen mit gemerkten Inseraten, kommt es auf drei Props an. `size="sm"` passt zweispaltig aufs Smartphone, `priority` lädt die erste Reihe sofort, und `maxImages={1}` schaltet das Foto-Karussell für Karten in einer horizontalen Leiste ab, denn bei einem Karussell im Karussell kann niemand vorhersagen, was eine Geste auslöst.

```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): Wisch oder zieh durch die Fotos und tippe dann auf das Herz: Es ploppt auf, wenn es speichert, und wackelt zurück, wenn das Speichern fehlschlägt. Stell im Playground das Layout auf list oder compact, um die anderen Formen zu sehen. Probier sie unter [Listing card](https://wingo-ui.com/components/listing-card) aus und installier sie mit `npx wingo-ui@latest add listing-card`.

## Wie funktionieren Suchfilter für Marktplätze in React?

Suchfilter für Marktplätze brauchen zwei Layouts und Live-Trefferzahlen. Eine Seitenleiste mit Checkboxen scheitert an zwei Stellen: auf dem Smartphone, wo sie keinen Platz hat, und auf dem Server, wo jeder Filter eine Trefferzahl braucht. Das [Filter Panel](https://wingo-ui.com/components/filter-panel) nimmt eine Liste von `FilterDef`-Objekten und liefert beide Layouts im Server-HTML aus; CSS-Klassen blenden jeweils eins davon ein. Auf dem Smartphone blitzt die Seitenleiste vor der Hydration also nie kurz auf. Ab 1024px ist es eine Sticky-Seitenleiste, in der jede Änderung sofort greift. Darunter wird daraus ein Button „Filters“ mit der Zahl der aktiven Filter. Er öffnet ein Sheet in voller Höhe, in dem du an einem Entwurf arbeitest. Der Anwenden-Button zeigt schon beim Ändern, wie viele Treffer der Entwurf hat. So sehen die Leute „No results, try fewer filters“, bevor sie sich festlegen. Wer das Sheet nach unten wischt oder Escape drückt, verwirft den Entwurf. Warum ein Sheet auf dem Smartphone besser ist als ein Popover, erklären wir in unserem Guide zum [React Bottom Sheet fürs Web](https://wingo-ui.com/de/blog/react-bottom-sheet-for-the-web).

Die Suchseite leitet ihre Filter standardmäßig aus der Kategorie ab. Für deine eigene Kategorie übergibst du `filters`:

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

export const cameraFilters: FilterDef[] = [
  {
    type: "price",
    id: "price",
    label: "Preis",
    min: 0,
    max: 5000,
    presets: [
      { min: null, max: 300 },
      { min: 300, max: 1000 },
      { min: 1000, max: null },
    ],
  },
  {
    type: "chips",
    id: "condition",
    label: "Zustand",
    options: [
      { value: "new", label: "Neu" },
      { value: "used", label: "Gebraucht" },
    ],
  },
  {
    type: "checkbox",
    id: "brand",
    label: "Marke",
    options: [
      { value: "canon", label: "Canon" },
      { value: "fujifilm", label: "Fujifilm" },
      { value: "nikon", label: "Nikon" },
      { value: "sony", label: "Sony" },
    ],
  },
  { type: "toggle", id: "delivery", label: "Versand möglich" },
]
```

Mit einer `search`-Funktion ist die Zahl auf dem Anwenden-Button nur ein weiterer Aufruf: Die Seite ruft `search(draft, 0)` auf und liest `total` aus, mit 250ms Debounce; veraltete Antworten verwirft sie. Jede Pause beim Bearbeiten des Entwurfs kostet also eine Anfrage für Seite 0. Sorg dafür, dass diese Abfrage schnell ist.

Die Optionen haben absichtlich kein `count`. Fehlt es bei einer Option vom Typ checkbox, chips oder radio, füllt die Seite es aus den `counts`, die deine Suche zurückgibt. Die Zahlen neben Canon und Sony passen also immer zu den aktuellen Ergebnissen. Eine Option mit 0 Treffern ist deaktiviert. Auch der Preisfilter hat kein `histogram`: Die Seite zeichnet die Balken aus dem `histogram`, das deine Suche zurückgibt.

## Wie speicherst du die Suche in der URL?

Werte die URL in der Server-Seite aus und lass eine Client-View den State verwalten, die ihn mit `window.history.replaceState` zurück in die URL schreibt. Next.js bindet native Aufrufe von `pushState` und `replaceState` in seinen Router ein (`usePathname` und `useSearchParams` bleiben synchron). Die Adressleiste aktualisiert sich also, ohne dass jede Checkbox eine Anfrage an den Server schickt. `searchParams` ist in Next.js 16 ein Promise, deshalb wartet die Seite per `await` darauf.

```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",
  }
  // eine neue Suche aus dem Header mountet die View mit dem neuen State neu
  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 = ["Spiegellose Kamera", "50-mm-Objektiv", "Kameratasche"]

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("Die Suche ist fehlgeschlagen")
  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}“` : "Kameras"} in ${place || "allen Orten"}` }}
    />
  )
}
```

Zwei Details in diesem Code verhindern echte Bugs. Der `key` auf `SearchView`: Startet jemand aus dem Header der Shell eine neue Suche, während er schon auf `/search` ist, rendert Next.js dieselbe Seite mit neuen `searchParams`, und ohne den Key würde React den alten Wert aus `useState` behalten. Und `search` steht außerhalb der Komponente: Die Seite lädt Seite 0 neu, sobald sich die Funktion `search` ändert. Eine inline definierte Arrow Function würde also bei jedem Render neu laden.

Wir nutzen `replaceState`, damit der Zurück-Button die Suchseite verlässt, statt jede Checkbox einzeln rückgängig zu machen. Wechsle zu `pushState`, wenn deine Nutzer erwarten, mit Zurück Schritt für Schritt durch ihre Filter zu gehen.

### Was gibt die Suchfunktion zurück?

Pro Aufruf eine Seite mit Ergebnissen. Seite 0 ersetzt die Ergebnisse und zeigt Skeletons, solange sie lädt; spätere Seiten werden angehängt, wenn sich jemand dem Ende der Liste nähert (oder mit `loadMode="button"` auf Show more drückt). Diese Form muss `/api/search` zurückgeben:

```ts
type MarketplaceSearchResult = {
  items: ListingData[]
  total: number
  promoted?: ListingData[] // nur bei Seite 0
  histogram?: number[] // die Preisbalken über dem Preis-Slider
  counts?: Record<string, Record<string, number>> // Filter-ID, dann Optionswert, dann Treffer
}
```

Berechne die Trefferzahlen jedes Filters so, dass alle anderen Filter schon angewendet sind. Genau das tut die Beispielsuche (`searchListings` in der Datendatei des Blocks). Das ist der Unterschied zwischen einer Zahl, die den nächsten Klick vorhersagt, und einer, die nur den ganzen Katalog beschreibt.

> Live-Demo (Marketplace Search Page): In diesem Rahmen stecken die Filter hinter dem Button „Filters“. Öffne ihn, zieh die Preisregler und wähl einen Zustand: Der Anwenden-Button zählt die Treffer, bevor du den Entwurf anwendest. Entferne dann einen Chip oder wechsle zur Listenansicht. Probier sie unter [Marketplace Search Page](https://wingo-ui.com/components/marketplace-search-page) aus und installier sie mit `npx wingo-ui@latest add marketplace-search-page`.

Ein Kompromiss bleibt: Mit einer `search`-Funktion lädt die erste Seite nach der Hydration im Browser, das Server-HTML enthält also Skeletons. Ohne `search` wendet die Seite beim Rendern `searchListings` auf `items` an (Inserate plus eine `cityId`, ein `facets`-Wert pro Filter-ID und `keywords`), und die Ergebnisse stehen im HTML. Bei einem kleinen Katalog kannst du alle Inserate auf dem Server laden und `items` übergeben. Bei einem großen baust du indexierbare Kategorieseiten separat und behältst `/search` als App-Screen.

## Was gehört auf die Detailseite eines Inserats?

Die Fotos, der Preis, die Merkmale, der Verkäufer und ein Weg, Kontakt aufzunehmen. Die [Listing Detail Page](https://wingo-ui.com/components/listing-detail-page) bekommt das Inserat und den Verkäufer als Daten und die Aktionen als Callbacks. Die Telefonnummer bleibt maskiert, bis jemand auf Show phone drückt. Der Klick ruft dein `onRevealPhone` auf. So kannst du jedes Aufdecken protokollieren und per Rate Limit begrenzen.

```tsx
// app/(market)/listings/[id]/page.tsx
import { notFound } from "next/navigation"
import { getListing } from "@/server/listings" // deine Datenschicht
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 } oder null
  if (!data) notFound()
  return <ListingView {...data} />
}
```

Lässt du Props weg, greift die Seite auf Beispieldaten oder eine simulierte Anfrage zurück: Ohne `sellerListings` erscheinen die anderen Anzeigen des Beispielverkäufers, ohne `onReport` meldet sie einen simulierten Erfolg, und ohne `onRevealPhone` erscheint „(503) 555-0142“. Die Callbacks steuern die UI: Wird das Promise von `onMessage` abgelehnt, bleibt der Text erhalten und der Dialog zeigt den Fehler, und was `onRevealPhone` liefert, wird als Nummer angezeigt. Weil das Promise von `fetch` auch bei einem 429 oder einem 500 erfüllt wird, prüf den Status, bevor du die Antwort zurückgibst. Sonst steht am Ende eine Rate-Limit-Seite als Telefonnummer da:

```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 }) }, "Deine Nachricht wurde nicht gesendet. Versuch es noch einmal.")
      }
      onRevealPhone={() =>
        request(`/api/listings/${listing.id}/phone`, undefined, "Die Nummer konnte nicht geladen werden").then((response) => response.text())
      }
      onReport={(report) =>
        request(`/api/listings/${listing.id}/reports`, { method: "POST", body: JSON.stringify(report) }, "Deine Meldung wurde nicht gesendet")
      }
      onFavoriteChange={(favorite) => saveFavorite(listing.id, favorite)}
      onOpenConversation={() => router.push(`/messages?listing=${listing.id}`)}
    />
  )
}
```

Unter 1024px blendet `stickyBar` (standardmäßig aktiv) unten eine Leiste mit Preis, Anrufen und Nachricht ein, sobald die Kontakt-Buttons aus dem Bild scrollen. Die Prop `status` der Seite ist unabhängig von `listing.status`, deshalb übersetzt die View das eine ins andere. `status="sold"` graut die Fotos aus, deaktiviert den Kontakt und schiebt die ähnlichen Inserate nach oben, damit die Seite trotzdem weiterführt; `"reserved"` zeigt ein Warnbanner und lässt den Kontakt offen. Gib die Nummer im internationalen Format zurück (`+1 503 555 0142`): Der tel:-Link setzt ein rumänisches `+4` vor eine Nummer, die mit 0 beginnt.

## Was musst du für deinen Markt anpassen?

Die Ortsdaten und die Kategorietabelle hinter dem Titel und der Breadcrumb der Suchseite. Die Blöcke kommen mit Beispieldaten, und ein Teil davon ist regional: Die Ortsfelder, der Umkreisfilter und die Entfernungen nutzen die rumänische Städteliste in `lib/ro-locations`, weil das Kit ursprünglich für Kleinanzeigen auf diesem Markt entstanden ist.

| Block | Woher die Orte kommen | Was du änderst |
| --- | --- | --- |
| Shell | Prop `locations`, Standard `cityOptions()` aus `lib/ro-locations` | Übergib deine Städte als Optionen `{ value, label }` |
| Startseite | `cityOptions()` und `getCity()`, in der Datei importiert | Übergib `defaultLocation=""` und lass den Import auf deine eigene Liste zeigen |
| Suchseite | `state.location` wird mit `getCity()` nachgeschlagen | Eine unbekannte ID erscheint im Titel so, wie sie eingegeben wurde; übergib deinen eigenen Filter `location` in `filters` |

Die Beispielkategorien (Bikes, Apartments for sale) liegen in `marketplace-search-page-data.ts`. Eine unbekannte Kategorie-ID fällt auf den letzten Eintrag zurück, „All listings“, mit seinen verwandten Beispielsuchen (iPhone 14, Sofa). Ersetz diese Tabelle also durch deine eigenen Kategorien oder übergib wenigstens `relatedSearches` und überschreib `labels.title` wie in der Such-View oben.

Die Beispielmarke versteckt sich außerdem in ein paar Strings, die die Snippets oben nicht anfassen. Durchsuch die installierten Dateien nach „Northwind“, bevor du live gehst:

| Wo | Beispielwert | Was du änderst |
| --- | --- | --- |
| Shell | `consentStorageKey`, der Link des Cookie-Banners auf `/cookies` | Dein eigener Key, eine Seite `/cookies` oder `cookieConsent={false}`, wenn du schon nach Zustimmung fragst |
| Shell | Beispielgruppen in `footerLinks` | Übergib deine eigenen Gruppen `{ title, links }` |
| Suchseite | `viewPersistKey`, der localStorage-Key für Raster oder Liste | `""`, wenn die Ansicht in der URL steht, wie in der Such-View oben |
| Detailseite | `labels.safetyTips` („Keep the conversation in the Northwind Market chat“) | Deine eigenen Tipps |

Für die übrigen Details auf dem Smartphone, etwa Safe Areas, Tap-Highlights und Touch-Ziele, gilt für jeden Screen hier unsere Liste mit [Fixes für eine Web-App, die sich nativ anfühlt](https://wingo-ui.com/de/blog/web-app-that-feels-native).

## Gibt es ein kostenloses React Marketplace Template?

Wenn du ein React Marketplace Template ohne Lizenzkosten brauchst, ist das [Sharetribe Web Template](https://www.sharetribe.com/docs/template/introduction/sharetribe-web-template/) eine Open-Source-Option, lizenziert unter Apache-2.0 auf [GitHub](https://github.com/sharetribe/web-template). Stand Oktober 2026 beschreibt seine Doku eine React-App auf einer mitgelieferten Kopie von Create React App (die sie selbst als veraltet bezeichnet), mit Redux, CSS Modules und Server-Side Rendering auf einem Node.js-Server. Serverless-Hosts fallen also weg. Es arbeitet mit den APIs von Sharetribe und ist damit die bessere Wahl, wenn dein Marktplatz auf Sharetribe läuft und du dessen Backend nutzen willst, statt ein eigenes zu bauen. Hast du ein eigenes Backend und willst Seiten im App Router, die dir gehören, sind die Blöcke aus diesem Tutorial der kürzere Weg.

## Wie geht es weiter?

Das Einstellen von Inseraten ist der Screen, den wir ausgelassen haben: Der [Post Listing Wizard](https://wingo-ui.com/components/post-listing-wizard) deckt Kategorie, Details, Fotos, Preis, Ort und eine Vorschau mit automatisch gespeichertem Entwurf ab. Die Marketplace-Blöcke und die Listing Card sind Pro (siehe [Preise](https://wingo-ui.com/de/pricing)); Button, Card, Dialog und die anderen Grundbausteine, auf denen sie aufbauen, sind kostenlos. Baust du mehr als nur die Storefront, deckt das [Tutorial zum Admin-Dashboard mit Next.js](https://wingo-ui.com/de/blog/nextjs-admin-dashboard-tutorial) die Backoffice-Seite ab, und die [Kategorie Build](https://wingo-ui.com/de/blog/category/build) hat die übrigen Guides, die Screen für Screen vorgehen.

## Komponenten in diesem Artikel

- [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. Installation: `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. Installation: `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. Installation: `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. Installation: `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. Installation: `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. Installation: `npx wingo-ui@latest add listing-detail-page`

## Häufige Fragen

### Gibt es ein kostenloses React Marketplace Template?

Ja. Das Sharetribe Web Template ist Open Source unter der Apache-2.0-Lizenz, ist aber für die APIs von Sharetribe gebaut und läuft auf einem Node.js-Server. Die Marketplace-Blöcke von Wingo UI (Shell, Startseite, Suche, Listing Card, Filter Panel und Detailseite) sind Pro.

### Welche Seiten braucht das Frontend eines Marktplatzes?

Mindestens eine Layout-Shell mit Suche, eine Startseite, eine Seite mit Suchergebnissen und Filtern, eine Detailseite pro Inserat und einen Weg, ein Inserat einzustellen. Gemerkte Inserate, Nachrichten und ein Kontobereich kommen meist dazu, und sie nutzen dieselbe Inseratskarte.

### Wie sollten die Suchfilter eines Marktplatzes auf dem Smartphone funktionieren?

Öffne sie in einem Sheet in voller Höhe über einen Button „Filter“, der zeigt, wie viele Filter aktiv sind. Arbeite mit einem Entwurf, solange das Sheet offen ist, zeig auf dem Anwenden-Button die Trefferzahl, sobald sich der Entwurf ändert, und verwirf ihn, wenn das Sheet nach unten gewischt oder geschlossen wird.

### Sollten die Filter eines Marktplatzes in der URL stehen?

Ja. Eine Suche in der URL übersteht ein Neuladen, den Zurück-Button und einen geteilten Link, und der Server kann schon bei der ersten Anfrage die richtige Seite rendern. In Next.js aktualisierst du sie mit window.history.replaceState, ohne Umweg über den Server.

### Funktionieren die Marketplace-Blöcke von Wingo UI nur mit rumänischen Städten?

Nein, aber die Beispieldaten für Orte sind die rumänische Städteliste in lib/ro-locations. Die Shell nimmt deine eigenen Städte über die Prop locations, die Suchseite deinen eigenen Ortsfilter; die Startseite liest die Städteliste direkt, also übergib defaultLocation="" und stell ihren Import auf deine eigene Liste um, denn der Block wird als Quellcode installiert.

---

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