# Shadcn Profile Page in React: Follow, Tabs und Edit-Sheet

> So baust du eine shadcn Profile Page in React: Cover, Avatar mit Status, optimistisches Follow, Zähler, Swipe-Tabs, Heatmap und ein Sheet zum Bearbeiten.

- Autor: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Veröffentlicht: 6. Nov. 2026
- Kategorie: [Praxis: Screens und Apps](https://wingo-ui.com/de/blog/category/build)
- Lesezeit: 9 Min.
- Kanonische URL: https://wingo-ui.com/de/blog/shadcn-profile-page
- Englische Fassung: https://wingo-ui.com/blog/shadcn-profile-page

## Kurz gesagt

Eine shadcn Profile Page ist der öffentliche Screen, den andere sehen: ein Cover-Band, der Avatar mit Status-Punkt, Name, Bio und Links, Follow und Message, drei Zähler und Tabs für Übersicht, Projekte und Aktivität. Private Kontofelder wie E-Mail, Passwort, Benachrichtigungen und Abrechnung gehören auf eine eigene Einstellungsseite. Der Block Profile Page von Wingo UI bringt all das mit, dazu ein optimistisches Follow, eine Contributions-Heatmap und ein Sheet zum Bearbeiten des Profils, das auf dem Smartphone zum Bottom Sheet wird.

Eine shadcn Profile Page sieht einfach aus, bis du eine baust: ein Cover, ein Avatar, ein Name, ein Follow-Button und ein paar Tabs. Dann kommen die Details. Follow muss sich sofort anfühlen und trotzdem eine fehlgeschlagene Anfrage überstehen. Der Avatar braucht einen Status-Punkt, der auf dem Cover lesbar bleibt, die Zähler müssen zum Button passen, die Tabs brauchen Tastaturbedienung und auf dem Smartphone eine Wischgeste, und der Besitzer muss die Seite bearbeiten können, ohne auf einen Einstellungs-Screen geschickt zu werden. Dieser Guide geht jeden Teil einer React User Profile Page mit dem Block [Profile Page](https://wingo-ui.com/components/profile-page) von Wingo UI durch. Wir bauen Wingo UI, sind also nicht neutral, aber jedes Muster hier funktioniert auch mit einfachen Teilen aus shadcn/ui.

## Was gehört zu einem guten User Profile Page Design?

Ein gutes User Profile Page Design beantwortet schnell zwei Fragen: Wer ist das, und was hat diese Person gemacht? Der Header beantwortet die erste, die Tabs die zweite. So ist der Block aufgebaut, von oben nach unten:

- **Cover-Band.** Ein Foto (`user.cover`) oder, ohne Foto, ein Verlauf aus `user.coverColor`. Das gibt der Seite eine Identität, ohne dass jemand etwas hochladen muss.
- **Avatar mit Status.** Ein [Avatar](https://wingo-ui.com/components/avatar) mit 96px, der das Cover überlappt, mit einem Status-Punkt für online, abwesend oder beschäftigt. Bei Personen, die offline sind, steht stattdessen etwas wie „Seen 2 hours ago“ aus `user.lastSeen`.
- **Identität.** Name, ein Verifiziert-Häkchen, `@handle`, eine einzeilige Headline, die Bio, der Ort, Links, die sich in einem neuen Tab öffnen, und das Beitrittsdatum.
- **Aktionen.** Follow und Message für Besucher, Edit profile für den Besitzer (`isOwner`).
- **Zähler.** Followers, Following und Projects. Auf dem Smartphone werden daraus drei Kacheln in einer Reihe.
- **Tabs.** Overview (angepinnte Projekte, ein Jahr Contributions, letzte Aktivität), Projects (jedes Projekt als verlinkte Karte) und Activity (eine Timeline, nach Tagen gruppiert).

Probier es unten aus. Folge der Person und schau zu, wie die Zahl der Follower mitzieht. Wechsle die Tabs mit den Pfeiltasten, und fahr mit der Maus über einen Tag in der Heatmap oder fokussier ihn, um seine Anzahl zu sehen.

> Live-Demo (Profile Page): Drück auf Follow: Der Button wird mit einem Häkchen zu Following, und die Zahl der Follower steigt um eins. Nutz dann die Pfeiltasten auf den Tabs oder fahr über einen Tag der Heatmap. Probier sie unter [Profile Page](https://wingo-ui.com/components/profile-page) aus und installier sie mit `npx wingo-ui@latest add profile-page`.

## Was unterscheidet eine Profilseite von der Einstellungsseite?

Die Profilseite ist öffentlich, die Einstellungsseite privat. Das klingt selbstverständlich, und doch sind viele „Profil“-Vorlagen eigentlich Screens für Kontoeinstellungen. Der Block Profile 1 von ReUI zum Beispiel ist eine vertikale Tab-Leiste mit den Panels Profile, Workspace, Team und Billing und einem Data Grid mit den Teammitgliedern ([reui.io](https://reui.io/blocks/application/profile/profile-1), geprüft am 6. November 2026). Das ist eine gute Einstellungsseite, nur eben ein anderer Screen.

Wir ziehen die Grenze danach, wer ein Feld sieht:

| Feld | Profilseite (Edit-Sheet) | Einstellungsseite |
|---|---|---|
| Foto, Name, Username | ja | Foto und Name auch hier bearbeitbar |
| Bio, Ort, Website | ja | nein |
| E-Mail, Telefon | nein | ja |
| Passwort, Zwei-Faktor, Sessions | nein | ja |
| Benachrichtigungen, Abrechnung | nein | ja |
| Konto löschen | nein | ja, in einer Danger Zone |

Der Besitzer bearbeitet also das, was andere sehen, genau dort, wo andere es sehen: in einem Sheet über dem Profil. Für alles andere geht er auf `/settings`. Unser Block [Settings Page](https://wingo-ui.com/components/settings-page) deckt diese private Seite ab (Profil, Konto, Benachrichtigungen, Abrechnung, Sicherheit und eine Danger Zone mit Save Bar), und das [Tutorial zur React Settings Page](https://wingo-ui.com/de/blog/react-settings-page) erklärt, wie dort gespeichert wird.

## Wie installiere ich die Profile Page und binde sie an Next.js an?

Eine shadcn User Profile Page aus statischem Markup überlässt dir den Follow-State, die Validierung und das Speichern. Der Block bringt dieses Verhalten mit, also installierst du ihn, statt ein React Profile Page Template abzuschreiben. Die CLI fügt die Teile hinzu, auf denen er aufbaut (Avatar Upload, Swipeable Tabs, Sheet, Timeline, die Heatmap), samt ihren npm-Paketen:

```bash
npx wingo-ui@latest add profile-page
```

`ProfilePage` ist eine Client-Komponente, die Daten als Props bekommt und über Callbacks meldet, was die Person getan hat. Lade das Profil in einer Server-Komponente, entscheide dort, ob es dem Betrachter gehört, und reich die Daten an einen kleinen Client-Wrapper weiter:

```tsx
// app/u/[handle]/page.tsx
import { notFound } from "next/navigation"
import { getProfile, getViewerId } from "@/lib/profiles" // deine Datenschicht
import { ProfileView } from "./profile-view"

export default async function Page({
  params,
  searchParams,
}: {
  params: Promise<{ handle: string }>
  searchParams: Promise<{ tab?: string }>
}) {
  const { handle } = await params
  const { tab } = await searchParams
  const [profile, viewerId] = await Promise.all([getProfile(handle), getViewerId()])
  if (!profile) notFound()

  return (
    <ProfileView
      {...profile}
      isOwner={profile.user.id === viewerId}
      initialTab={tab === "projects" || tab === "activity" ? tab : "overview"}
    />
  )
}
```

Der Wrapper verbindet die Callbacks mit deiner API. `onFollow` und `onSave` dürfen beide ein Promise zurückgeben: Follow schaltet zuerst um und rollt bei einer Ablehnung zurück. Save zeigt seinen Spinner, solange das Promise läuft, und bei einer Ablehnung deren Meldung im Sheet.

```tsx
// app/u/[handle]/profile-view.tsx
"use client"

import { useRouter } from "next/navigation"
import { ProfilePage, type ProfileTab } from "@/components/blocks/profile-page"
import type {
  ProfileActivity,
  ProfileContribution,
  ProfileProject,
  ProfileUser,
} from "@/components/blocks/profile-page-data"

type Props = {
  user: ProfileUser
  projects: ProfileProject[]
  activity: ProfileActivity[]
  contributions: ProfileContribution[]
  isOwner: boolean
  initialTab: ProfileTab
}

export function ProfileView({ user, projects, activity, contributions, isOwner, initialTab }: Props) {
  const router = useRouter()

  return (
    <ProfilePage
      user={user}
      projects={projects}
      activity={activity}
      contributions={contributions}
      isOwner={isOwner}
      defaultTab={initialTab}
      // ein teilbarer Link pro Tab, ohne Roundtrip zum Server
      onTabChange={(tab) => window.history.replaceState(null, "", `?tab=${tab}`)}
      onFollow={async (following) => {
        const res = await fetch(`/api/users/${user.handle}/follow`, { method: following ? "POST" : "DELETE" })
        if (!res.ok) throw new Error("Folgen fehlgeschlagen")
      }}
      onMessage={() => router.push(`/messages/new?to=${user.handle}`)}
      onSave={async (values) => {
        const res = await fetch("/api/profile", {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(values),
        })
        if (!res.ok) {
          // das Sheet zeigt diese Meldung, so erreicht "Dieser Username ist schon vergeben" die Person
          const body = (await res.json().catch(() => null)) as { error?: string } | null
          throw new Error(body?.error ?? "Wir konnten dein Profil nicht speichern. Versuch es noch einmal.")
        }
        // neuer Handle heißt neue URL; sonst neu laden, damit die Serverdaten zum Sheet passen
        if (values.handle !== user.handle) router.replace(`/u/${values.handle}`)
        else router.refresh()
      }}
    />
  )
}
```

Die letzten beiden Zeilen von `onSave` sind wichtiger, als sie aussehen. Der Handle ist Teil der URL. Ohne sie würde ein umbenannter User beim Neuladen auf der alten Adresse in einem 404 landen.

`user` braucht `id`, `name`, `handle`, `followers` und `following`. Alles andere (Avatar, Cover, Headline, Bio, Links, Status, `verified`) ist optional und wird einfach ausgeblendet, wenn es fehlt. Ganz ohne Props rendert der Block ein Beispielprofil, Emma Carter aus Brooklyn. Das ist praktisch, solange deine API noch nicht fertig ist.

## Wie sollen sich der Follow-Button und die Zähler verhalten?

Optimistisch. Sobald du Follow drückst, wird der Button zu Following, ein Häkchen erscheint, der Zähler Followers steigt um eins, und Screenreader hören „You follow Emma Carter“. Erst dann wartet der Block auf `onFollow(true)`. Wird das Promise abgelehnt, schaltet der Button zurück, und Screenreader hören „That did not work. Try again.“ Ein zweiter Tipp, während eine Anfrage läuft, wird ignoriert. So schickt ein Doppeltipp nie Follow und Unfollow gleichzeitig los.

Zwei Details halten den Zähler ehrlich. Wenn deine Daten schon sagen, dass der Betrachter dieser Person folgt (`user.isFollowing`), enthält die übergebene Follower-Zahl ihn bereits, und der Block zählt nicht noch einmal eins dazu. Und wenn du den Follow-State woanders verwaltest (in einem Store oder einem Query-Cache), übergib `following` und kümmere dich selbst um `onFollow`. Der Block rendert dann nur, was du ihm gibst.

Der Zähler Projects öffnet immer den Tab Projects. Followers und Following werden zu Buttons, sobald du `onStatClick` übergibst. Der Callback bekommt `"followers"` oder `"following"`, also kannst du eine Liste in einem Sheet öffnen oder auf `/u/emma/followers` weiterleiten.

## Wie funktionieren die Swipeable Tabs und die Heatmap?

Die Tabs sind [Swipeable Tabs](https://wingo-ui.com/components/swipeable-tabs) in der Variante underline. Auf dem Desktop folgen sie dem WAI-ARIA-Muster für Tabs: Die Pfeiltasten wechseln zwischen den Tabs, Pos1 und Ende springen zum ersten und zum letzten ([W3C APG tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/)). Unter 768px folgen die Tab-Seiten außerdem einer horizontalen Wischgeste, wie auf einem nativen Profil-Screen, während vertikales Scrollen weiter die Seite bewegt. Enthält ein Tab ein horizontales Karussell oder eine Karte, die sich mit der Geste in die Quere käme, setz `data-no-swipe` auf dieses Element. Dann bleiben horizontale Drags, die dort beginnen, bei ihm. Oder übergib `swipeable={false}`, um das Wischen ganz abzuschalten. Mehr zur Geste steht in [React Swipeable Tabs](https://wingo-ui.com/de/blog/react-swipeable-tabs).

Die Heatmap im Tab Overview ist [GitHub Activity](https://wingo-ui.com/components/github-activity), und die ist kostenlos. Sie bekommt ein Jahr an Tagen, jeder als `{ date, count, level }` mit `level` von 0 bis 4, beginnend an einem Sonntag. Berechne das Level auf dem Server, damit der Client nur noch Zellen zeichnet:

```ts
// lib/contributions.ts
import type { ProfileContribution } from "@/components/blocks/profile-page-data"

// Zeilen aus deiner eigenen Tabelle: eine pro Tag, an dem die Person aktiv war
export function toContributions(days: { date: string; count: number }[]): ProfileContribution[] {
  return days.map(({ date, count }) => ({
    date, // "2026-11-05"
    count,
    level: count === 0 ? 0 : count < 3 ? 1 : count < 6 ? 2 : count < 9 ? 3 : 4,
  }))
}
```

Das sind dieselben Schwellen, die auch die Beispieldaten nutzen. Sind deine User viel häufiger aktiv, heb sie an, sonst färbt sich das ganze Jahr im dunkelsten Grün. `heatmapColor` nimmt jede CSS-Farbe (aktivere Tage sind deckender), und `showHeatmap={false}` oder ein leeres Array blendet das Raster aus. Für die Komponente allein, mit einem GitHub-Usernamen statt deiner eigenen Daten, lies den [Guide zum React GitHub Contribution Graph](https://wingo-ui.com/de/blog/react-github-contribution-graph).

## Wie validiert und speichert das Sheet zum Bearbeiten des Profils?

Auf der eigenen Seite öffnet Edit profile ein [Sheet](https://wingo-ui.com/components/sheet): auf dem Desktop von rechts, auf dem Smartphone als Bottom Sheet mit Griff zum Ziehen. Darin stehen Foto, Name, Username, eine Bio mit Zeichenzähler bis 160, Ort und Website. Das Fotofeld ist [Avatar Upload](https://wingo-ui.com/components/avatar-upload), der Besitzer kann also zuschneiden, den Upload-Fortschritt verfolgen oder das Bild entfernen. Übergib `onAvatarUpload`, um den zugeschnittenen Blob an deinen Speicher zu schicken und die neue URL zurückzugeben. Ohne den Callback bleibt das Foto bis Save eine lokale Vorschau.

Öffne unten das Sheet und drück Save mit leerem Namen. Tipp dann einen Username mit Leerzeichen ein.

> Live-Demo (Profile Page): Leer den Namen und drück Save: Das Feld wird rot und bekommt den Fokus. Korrigier es, und die Meldung folgt deinem Tippen. Auf dem Smartphone fährt das Sheet von unten hoch. Probier sie unter [Profile Page](https://wingo-ui.com/components/profile-page) aus und installier sie mit `npx wingo-ui@latest add profile-page`.

Save prüft drei Dinge, bevor es `onSave` aufruft: Der Name ist nicht leer, der Username besteht aus 3 bis 20 Buchstaben, Ziffern oder Unterstrichen (ein führendes `@` wird entfernt), und die Website sieht wie eine Adresse aus. Das erste ungültige Feld bekommt den Fokus, und nach diesem ersten Versuch aktualisieren sich die Meldungen beim Tippen. Gültige Werte kommen getrimmt an, und ein nacktes `example.com` bekommt `https://` vorangestellt. Solange das Promise läuft, zeigt Save einen Spinner, und das Sheet lässt sich nicht schließen. Escape kann also keinen halb gespeicherten Entwurf verwerfen. Eine Ablehnung behält den Entwurf und zeigt die Meldung des Fehlers im Sheet. Ein Erfolg aktualisiert sofort den Header und schließt das Sheet.

Validier trotzdem auf dem Server. Die Prüfung im Client spart nur einen Roundtrip, und ein Username kann zwischen dem Öffnen des Sheets und dem Druck auf Save vergeben werden. Gib also eine klare Fehlermeldung zurück, dann zeigt das Sheet sie an.

## Was passiert auf dem Smartphone?

Das Layout ist Mobile First. Unter 640px stapelt sich der Header: Der Avatar überlappt das Cover, die Zähler werden zu drei gleich breiten Kacheln, und Follow und Message rutschen ans Ende des Headers und teilen sich die volle Breite. Die Tab-Seiten lassen sich wischen, und das Edit-Sheet wird zum Bottom Sheet mit Cancel und Save in voller Breite. Die Tastatur bleibt dabei unten, bis du ein Feld antippst. Um ein brandneues Konto zu prüfen, übergib `projects={[]}` mit `defaultTab="projects"`: Der Empty State zeigt „No public projects yet“ statt eines leeren Rasters.

## Worauf sollte ich achten?

In Produktion gehen vier Dinge öfter schief als das Layout.

- **Private Daten geraten nach außen.** Das `user`-Objekt landet im Browser. Bilde deine Datenbankzeile auf dem Server auf `ProfileUser` ab und lass E-Mail, Telefon und interne Flags weg.
- **Veraltete Zähler nach dem Speichern.** Der Header zeigt die gespeicherten Werte sofort. Übergibt deine Elternkomponente später ein neues `user`-Objekt, ersetzt es diese Werte. Lade die Route nach einem erfolgreichen Speichern neu oder revalidiere sie (das `router.refresh()` oben), damit beides übereinstimmt.
- **Hydration und Uhrzeit.** Relative Zeiten wie „Updated 5 hours ago“ lesen die echte Uhr erst nach der Hydration. Das Server-HTML lässt sie deshalb weg, so entsteht kein Hydration-Mismatch. Übergib `now`, wenn du sie schon im Server-HTML brauchst, und in Tests und Screenshots, damit sie nie abdriften.
- **Übersetzungen.** Jeder sichtbare Text steht in `labels`, das über `DEFAULT_PROFILE_PAGE_LABELS` gemergt wird, und `locale` formatiert Zähler und Datumsangaben (1,284 in `en-US`, 1.284 in `de-DE`).

## Wo es weitergeht

Wenn du den Rest der App um diesen Screen herum baust, sammelt die Kategorie [Praxis: Screens und Apps](https://wingo-ui.com/de/blog/category/build) unsere anderen Guides zu ganzen Seiten. Der [Guide zum SaaS Website Template](https://wingo-ui.com/de/blog/saas-website-template) behandelt die öffentlichen Seiten, die daneben stehen. Für eine Portfolio-Website statt eines Profils in der App passt das [Next.js Portfolio Template](https://wingo-ui.com/de/blog/nextjs-portfolio-template) besser.

Die Blöcke Profile Page und Settings Page gehören zu Wingo UI Pro ([Pläne und Preise](https://wingo-ui.com/de/pricing)). Heatmap, Avatar, Sheet und die Formularfelder sind kostenlos: Leg einen kostenlosen Account an, führ `npx wingo-ui@latest add github-activity avatar sheet` aus und starte von dort.

## Komponenten in diesem Artikel

- [Profile Page](https://wingo-ui.com/components/profile-page) (Pro): A React user profile screen: cover band, avatar with presence, bio and links, follow and message, counters, tabs and an edit profile sheet. Installation: `npx wingo-ui@latest add profile-page`
- [Settings Page](https://wingo-ui.com/components/settings-page) (Pro): A React account settings page for a back office: profile, account, notifications, billing, security and a danger zone, with a save bar. Installation: `npx wingo-ui@latest add settings-page`
- [Avatar Upload](https://wingo-ui.com/components/avatar-upload) (Pro): A React avatar upload for profile pictures: pick or shoot a photo, crop it in a round frame and watch a ring fill while it uploads. Installation: `npx wingo-ui@latest add avatar-upload`
- [GitHub Activity](https://wingo-ui.com/components/github-activity) (Free): A React GitHub contribution graph: a yearly heatmap with a panel of your top repositories. Installation: `npx wingo-ui@latest add github-activity`
- [Swipeable Tabs](https://wingo-ui.com/components/swipeable-tabs) (Pro): React swipeable tabs whose pages sit side by side and follow your thumb: swipe and the indicator tracks the finger, tap and the pages slide. Installation: `npx wingo-ui@latest add swipeable-tabs`

## Häufige Fragen

### Was gehört auf eine Profilseite?

Ein Cover oder ein farbiges Band, der Avatar, der Name mit Handle, eine kurze Bio, Ort und Links, die wichtigsten Aktionen (Follow und Message, auf der eigenen Seite Edit profile), ein paar Zähler und die Arbeit der Person in Tabs. Alles, was nur der Besitzer sehen soll, etwa die E-Mail-Adresse, bleibt von der öffentlichen Seite fern.

### Was unterscheidet eine Profilseite von einer Einstellungsseite?

Die Profilseite zeigt eine Person allen und lässt den Besitzer die öffentlichen Felder direkt dort bearbeiten. Die Einstellungsseite ist privat: E-Mail, Passwort, Zwei-Faktor, Benachrichtigungen, Abrechnung und das Löschen des Kontos. Wenn Edit profile ein Sheet öffnet und Settings eine eigene Route hat, verschwimmen die beiden nicht.

### Sollte der Follow-Button auf den Server warten?

Nein. Schalte ihn sofort um, zähl einen Follower dazu, schick dann die Anfrage und schalte ihn mit einer Meldung zurück, wenn sie fehlschlägt. Wer bei jedem Tipp ein paar hundert Millisekunden wartet, hält den Button bei einer langsamen mobilen Verbindung für kaputt.

### Gibt es ein kostenloses shadcn Profile Page Template?

Der Block Profile Page von Wingo UI gehört zu Pro, ebenso die Teile Avatar Upload, Swipeable Tabs und Timeline, die er nutzt. Die Heatmap GitHub Activity, Avatar, Sheet, Button, Card, Badge, Input und Textarea sind mit einem kostenlosen Account frei, daraus kannst du ein einfacheres Profil zusammenbauen.

### Wie zeige ich eine Contributions-Heatmap in einem React-Profil?

Gib der Heatmap ein Jahr an Tagen im Format { date, count, level } mit level von 0 bis 4, beginnend an einem Sonntag. Berechne das Level auf dem Server aus deiner eigenen Aktivitätstabelle, dann rendert der Client nur noch Zellen.

---

Quelle: https://wingo-ui.com/de/blog/shadcn-profile-page
