Zum Inhalt springen
    WingoUI
    KomponentenVorlagenDokuPreiseKI-AgentenBlogChangelog
    1. Startseite
    2. Blog
    3. Praxis: Screens und Apps
    4. React Marketplace Template, das sich wie eine App anfühlt
    Read in English
    1. Blog
    2. Praxis: Screens und Apps
    3. React Marketplace Template, das sich wie eine App anfühlt
    Praxis: Screens und Apps
    Praxis: Screens und Apps

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

    SR
    Serban Rusu · Gründer von Wingo UI
    16. Okt. 2026 · 15 Min. Lesezeit

    Auf dieser Seite

    0%
    1. Was braucht ein React Marketplace Template?
    2. Wie baust du die Shell des Marktplatzes?
    3. Wie baust du die Startseite?
    4. Wie sollte sich eine React Listing Card auf dem Smartphone verhalten?
    5. Wie funktionieren Suchfilter für Marktplätze in React?
    6. Wie speicherst du die Suche in der URL?
      1. Was gibt die Suchfunktion zurück?
    7. Was gehört auf die Detailseite eines Inserats?
    8. Was musst du für deinen Markt anpassen?
    9. Gibt es ein kostenloses React Marketplace Template?
    10. Wie geht es weiter?
    11. Häufige Fragen
      1. Gibt es ein kostenloses React Marketplace Template?
      2. Welche Seiten braucht das Frontend eines Marktplatzes?
      3. Wie sollten die Suchfilter eines Marktplatzes auf dem Smartphone funktionieren?
      4. Sollten die Filter eines Marktplatzes in der URL stehen?
      5. Funktionieren die Marketplace-Blöcke von Wingo UI nur mit rumänischen Städten?

    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.

    Veröffentlicht am 16. Okt. 2026

    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:

    ScreenAufgabeGebaut mitAuf dem Smartphone
    ShellSuche, Kategorien, Konto, FooterMarketplace ShellEin kompakter Header, ein Such-Sheet und eine Tab-Leiste unten mit einem hervorgehobenen Button zum Inserieren
    StartseiteEine Suche starten, Kategorien und neue Inserate durchsehenMarketplace Home PageDie Felder stehen untereinander, die Leiste mit hervorgehobenen Inseraten zeigt 1,3 Karten, das Raster zwei Spalten
    SucheDie Ergebnisse eingrenzenMarketplace Search Page mit dem Filter PanelFilter in einem Sheet in voller Höhe mit einem Button, der die Treffer zählt
    KarteEin Inserat im Raster, in einer Liste oder in einer Leiste zeigenListing CardFotos wischen, zum Öffnen irgendwo tippen, zum Merken aufs Herz tippen
    DetailEntscheiden und den Verkäufer kontaktierenListing Detail PageEine 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:

    text
    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")
    }
    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
    $ npx wingo-ui@latest add marketplace-home-page
    ProDoku: 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 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>
    )
    }
    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
    $ npx wingo-ui@latest add listing-card
    ProDoku: 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 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.

    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.

    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
    $ npx wingo-ui@latest add marketplace-search-page
    ProDoku: 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 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.

    BlockWoher die Orte kommenWas du änderst
    ShellProp locations, Standard cityOptions() aus lib/ro-locationsÜbergib deine Städte als Optionen { value, label }
    StartseitecityOptions() und getCity(), in der Datei importiertÜbergib defaultLocation="" und lass den Import auf deine eigene Liste zeigen
    Suchseitestate.location wird mit getCity() nachgeschlagenEine 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:

    WoBeispielwertWas du änderst
    ShellconsentStorageKey, der Link des Cookie-Banners auf /cookiesDein eigener Key, eine Seite /cookies oder cookieConsent={false}, wenn du schon nach Zustimmung fragst
    ShellBeispielgruppen in footerLinksÜbergib deine eigenen Gruppen { title, links }
    SuchseiteviewPersistKey, der localStorage-Key für Raster oder Liste"", wenn die Ansicht in der URL steht, wie in der Such-View oben
    Detailseitelabels.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.

    Gibt es ein kostenloses React Marketplace Template?

    Wenn du ein React Marketplace Template ohne Lizenzkosten brauchst, ist das Sharetribe Web Template (opens in a new tab) eine Open-Source-Option, lizenziert unter Apache-2.0 auf GitHub (opens in a new tab). 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 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); 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 die Backoffice-Seite ab, und die Kategorie Build hat die übrigen Guides, die Screen für Screen vorgehen.

    Komponenten in diesem Artikel

    • Marketplace Home Page

      A React marketplace home page with a what and where search, category tiles, a promoted rail, new listings near you and an app band.

      Pro
    • Marketplace Search Page

      A React marketplace search results page: count, facets in a sidebar or phone sheet, filter chips, sort, grid or list and endless results.

      Pro
    • Listing card

      A React listing card for marketplaces: swipeable photos, price, place and time, badges and a heart, in grid, list and compact layouts.

      Pro
    • Filter panel

      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.

      Pro
    • Marketplace Shell

      A React marketplace layout shell with a what and where search header, a category strip, a footer, bottom tabs on phones and cookie consent.

      Pro
    • Listing Detail Page

      A React listing detail page for a classifieds marketplace: photo gallery, price card, seller, map, contact actions and similar listings.

      Pro

    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.

    • Marktplatz
    • Next.js
    • Facettensuche
    • Mobile UI
    • React

    Teilen

    SR

    Über den Autor

    Serban Rusu

    Gründer von Wingo UI

    Serban Rusu hat Wingo UI gegründet. Er baut die Komponenten, die CLI und den MCP-Server und schreibt über React-UIs für Smartphones und KI-Agents.

    Mehr von Serban
    Weiterlesen

    Ähnliche Artikel

    Alle Artikel
    Mobile UI mit React

    React Swipe Cards: Ein Tinder-Stapel mit Undo

    So baust du React Swipe Cards wie bei Tinder: Neigung und Stempel auf Motion Values, ein Wurf mit deinem Tempo, Undo, Buttons und Tasten für alle ohne Swipe.

    SRSerban Rusu·13. Okt. 2026·9 Min. Lesezeit
    Praxis: Screens und Apps

    Next.js Blog Template: Markdown, Live-Demos und JSON-LD

    So haben wir ein Next.js Blog Template auf Markdown gebaut: zod-Frontmatter, Live-Demos, Sticky-Inhaltsverzeichnis, JSON-LD, Feeds und ein strenger Validator.

    SRSerban Rusu·16. Okt. 2026·10 Min. Lesezeit
    Praxis: Screens und Apps

    Next.js SaaS Landing Page: Was jeder Abschnitt beweisen muss

    Bau eine Next.js SaaS Landing Page Abschnitt für Abschnitt: was Hero, Social Proof, Features, Preise und FAQ beweisen müssen, was rausfliegt, was mobil zählt.

    SRSerban Rusu·16. Okt. 2026·11 Min. Lesezeit
    Newsletter

    Neue Artikel per E-Mail

    Neue Guides, Tutorials und Vergleiche aus dem Wingo UI Blog, sobald sie erscheinen. Die meisten Artikel sind auf Englisch.

    Kein Spam. Du kannst dich jederzeit abmelden.

    WingoUI

    Animierte, konfigurierbare, mobile-first React-Komponenten und Blöcke. Kopier den Quellcode, pass ihn an und lass deinen Coding-Agenten damit bauen.

    Produkt

    • Komponenten
    • Vorlagen
    • Preise
    • KI-Agenten
    • Theme-Generator

    Ressourcen

    • Doku
    • Blog
    • Changelog
    • Changelog-RSS
    • llms.txt

    Rechtliches

    • AGB
    • Datenschutz
    • Rückerstattung
    • Lizenz
    • Impressum

    Komponenten-Kategorien

    • Buttons & Actions
    • Inputs
    • Forms
    • Navigation
    • Overlays
    • Feedback
    • Data Display
    • Tables & Lists
    • Charts & Stats
    • Layout
    • Media
    • AI Kit
    • Text & Effects
    • Mobile
    • Commerce
    • Marketing Sections
    • Blocks
    • Hooks & Utilities

    © 2026 Wingo UI

    Wingo UI