Styling und Optimization

Next.js ist kein Meinungsführer, wenn es ums Styling geht – es unterstützt gleich mehrere Ansätze, die sich teils gegenseitig ergänzen. Als Rails-Entwickler bist du gewohnt, dass die Asset-Pipeline Sass kompiliert und du globales CSS in application.css einträgst. In Next.js läuft das anders: Es gibt keinen zentralen Build-Schritt für Stylesheets, der dir aufgezwungen wird. Du wählst selbst, wie nah am Markup die Styles leben sollen.

Bevor wir in Tailwind eintauchen – das de-facto-Standardwerkzeug im Next.js-Ökosystem – verschaffen wir uns einen Überblick über alle Optionen.

Styling-Optionen im Überblick

Next.js unterstützt vier Hauptansätze, die sich nicht ausschließen:

Tailwind CSS ist ein Utility-First-Framework, das du direkt in JSX-Attributen verwendest. Du schreibst keine CSS-Klassen selbst, sondern kombinierst vordefinierte, einzweckige Klassen wie flex, gap-4, text-slate-700. Das klingt zunächst nach Inline-Styles, ist aber semantisch sauberer, weil jede Klasse eine einzige CSS-Eigenschaft beschreibt und responsive Prefixe wie sm: oder md: nativ unterstützt.

CSS Modules sind lokale Stylesheets, bei denen Klassenamen automatisch gehasht werden, damit sie nicht aus ihrer Komponente herauslecken. Eine Datei Button.module.css mit .button { … } erzeugt zur Laufzeit eine eindeutige Klasse wie .Button_button__x7fZ3. Du importierst die Klassen als JavaScript-Objekt.

Global CSS ist das klassischste Mittel: Du importierst eine .css-Datei in layout.tsx oder _app.tsx, und ihre Selektoren gelten im gesamten Dokument. In Next.js darf globales CSS nur in Layouts oder dem Root-Layout importiert werden, nicht in beliebigen Komponenten – das verhindert unerwartete Ladereihenfolgen.

CSS-in-JS-Bibliotheken wie styled-components oder Emotion können in Next.js mit Server Components verwendet werden, erfordern aber eine explizite Konfiguration im Root-Layout, weil sie zur Laufzeit JavaScript ausführen, um Styles einzufügen. Sie gelten im App Router als fortgeschrittenes Thema.

Für dieses Tutorial nutzen wir Tailwind v4 – das entspricht dem State of the Art in neuen Next.js-Projekten im Jahr 2024–2025.

Tailwind v4 – Utility-First ohne Konfigurationsdatei

Tailwind v4 bricht mit einer langjährigen Konvention: Es gibt keine tailwind.config.js mehr. Die gesamte Konfiguration – eigene Farben, Abstände, Schriften – lebt jetzt in der CSS-Datei selbst, über den @theme-Block.

Das bedeutet konkret: Dein app/globals.css ist nicht nur eine Sammlung von Reset-Regeln, sondern auch das Design-Token-System der gesamten App.

/* app/globals.css – Tailwind v4 */
@import "tailwindcss";

@theme {
  --color-brand: oklch(55% 0.2 264);
  --font-sans: "Geist", sans-serif;
  --font-mono: "Geist Mono", monospace;
  --radius-card: 0.75rem;
}

Mit @import "tailwindcss" lädst du das Framework. Im @theme-Block definierst du CSS Custom Properties, die Tailwind als neue Utility-Klassen registriert. --color-brand wird automatisch zu bg-brand, text-brand, border-brand. --radius-card zu rounded-card. Das ist ein fundamentaler Paradigmenwechsel gegenüber v3.

In Komponenten schreibst du dann unverändert:

// Tailwind-Klassen direkt im className-Attribut
export default function NoteCard({ title, body }: { title: string; body: string }) {
  return (
    <div className="rounded-card border border-slate-200 bg-white p-4 shadow-sm hover:shadow-md transition-shadow">
      <h2 className="text-base font-semibold text-slate-900 mb-2">{title}</h2>
      <p className="text-sm text-slate-600 leading-relaxed">{body}</p>
    </div>
  );
}
Rails-Analogie
Ruby on Rails
/* app/assets/stylesheets/application.css */
@import "tailwindcss";

/* Oder mit SCSS: */
$brand-color: #4f46e5;

.note-card {
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
padding: 1rem;
}

/* ViewComponent-Sidecar */
/* app/components/note_card_component.scss */
.NoteCardComponent {
.title { font-weight: 600; }
}
Next.js
// Tailwind v4: kein tailwind.config.js
// Design-Tokens leben in globals.css:

// @theme {
//   --color-brand: oklch(55% 0.2 264);
//   --radius-card: 0.75rem;
// }

// Nutzung direkt im JSX:
<div className="rounded-card border border-slate-200 bg-white p-4">
<h2 className="text-base font-semibold text-slate-900">
  {title}
</h2>
</div>

Ein entscheidender Vorteil: Tailwind analysiert beim Build alle Dateien nach verwendeten Klassen und entfernt alle anderen aus dem finalen CSS. Das Bundle ist dadurch winzig – oft unter 10 KB für eine vollständige App. In Rails mit klassischer Asset-Pipeline landet das gesamte Framework im Browser, auch wenn du nur 20 % davon nutzt.

CSS Modules – Scoped Styles ohne Framework

Wenn du lieber klassisches CSS schreibst, aber trotzdem keine globalen Namenskollisionen riskieren willst, sind CSS Modules die richtige Wahl. Next.js unterstützt sie ohne jede Konfiguration.

Du erstellst eine Datei mit dem Suffix .module.css:

/* components/notes/NoteCard.module.css */
.card {
  border-radius: 0.75rem;
  border: 1px solid #e2e8f0;
  padding: 1rem;
  transition: box-shadow 150ms ease;
}

.card:hover {
  box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}

.title {
  font-size: 1rem;
  font-weight: 600;
  color: #0f172a;
  margin-bottom: 0.5rem;
}

Und importierst die Klassen als Objekt in der Komponente:

// components/notes/NoteCard.tsx
import styles from "./NoteCard.module.css";

export default function NoteCard({ title, body }: { title: string; body: string }) {
  return (
    <div className={styles.card}>
      <h2 className={styles.title}>{title}</h2>
      <p>{body}</p>
    </div>
  );
}

Next.js transformiert .card zur Build-Zeit in einen eindeutigen Hash wie NoteCard_card__3xKy2. Kollisionen zwischen Komponenten sind damit strukturell ausgeschlossen – kein Scoping über BEM-Konventionen oder spezifische Selektoren mehr nötig.

Rails-Analogie

CSS Modules sind konzeptuell sehr nah an Sidecar-SCSS in ViewComponents. In Rails erstellt man app/components/note_card_component.scss und scoped alle Selektoren manuell unter .NoteCardComponent { … }. CSS Modules erledigen dieses Scoping automatisch – und ohne die Konvention, die jeder kennen und einhalten muss. Der Import-Mechanismus (styles.card) ersetzt die implizite Kopplung über Klassennamen-Strings.

next/image – Optimierte Bilder ohne Aufwand

In Rails weißt du, wie umständlich Bildoptimierung sein kann: image_tag rendert ein <img>-Element, ActiveStorage verwaltet den Upload, und für WebP-Konvertierung brauchst du image_processing mit libvips oder ImageMagick. Das alles muss du konfigurieren, testen, deployen.

Next.js liefert die Image-Komponente aus next/image, die das meiste davon erledigt – automatisch und ohne externe Abhängigkeiten:

import Image from "next/image";

export default function UserAvatar({ src, name }: { src: string; name: string }) {
  return (
    <Image
      src={src}
      alt={`Avatar von ${name}`}
      width={48}
      height={48}
      className="rounded-full"
      // Bilder unterhalb des Viewports werden nicht sofort geladen:
      loading="lazy"
      // Kritische Bilder sofort laden (z.B. Hero-Bild above the fold):
      // priority
    />
  );
}

Was Next.js dabei automatisch erledigt:

  • WebP/AVIF-Konvertierung: Moderne Browser bekommen automatisch das komprimiertere Format geliefert, ohne dass du das Bild vorher konvertieren musst.
  • Lazy Loading: Bilder außerhalb des Viewports werden erst geladen, wenn sie sichtbar werden – das ist die Standardeinstellung.
  • Layout Shift Prevention: Das Reservieren des Platzes im Layout verhindert das störende Springen der Seite beim Laden. Dafür musst du width und height angeben (oder fill für responsive Container).
  • Srcset: Next.js generiert automatisch mehrere Bildgrößen und gibt dem Browser via srcset die Wahl – auf einem Mobilgerät mit 375px Breite wird kein 1200px-Bild geladen.
Rails-Analogie
Ruby on Rails
<%# Erfordert: gem 'image_processing', libvips/ImageMagick %>
<%= image_tag note.cover.variant(format: :webp, resize_to_limit: [800, nil]),
    loading: "lazy",
    alt: note.title %>

<%# ActiveStorage Variant-Konfiguration in note.rb: %>
<%# has_one_attached :cover %>
<%# def cover_webp %>
<%#   cover.variant(format: :webp, ...) %>
<%# end %>
Next.js
// Keine Gem-Abhängigkeiten, kein ActiveStorage nötig.
// next/image übernimmt Formatkonvertierung, Lazy Loading
// und Srcset automatisch:

import Image from "next/image";

<Image
src={note.coverUrl}
alt={note.title}
width={800}
height={450}
// loading="lazy" ist bereits Standard
/>

Live-Demo: Tailwind-Farbschemas

Das folgende Beispiel zeigt, wie sich Tailwind-Klassen per State-Update dynamisch austauschen lassen. Die Karte wechselt komplett ihr Farbschema, nur durch das Tauschen von Utility-Klassen – kein einziges CSS-Property wird direkt gesetzt.

M8TailwindDemoFarbschema-Wechsel mit Tailwind Utilities
Farbschema:

Neuer Eintrag: Tailwind in Next.js

Notiz

Tailwind v4 benötigt keine tailwind.config.js. Das Design-Token-System lebt direkt in der CSS-Datei über @theme.

Aktive Tailwind-Klassen
bg-indigo-50border-indigo-200text-indigo-900text-indigo-700bg-indigo-600

Beachte, wie unten in der Demo die aktiven Tailwind-Klassen angezeigt werden. Jeder Zustand ist vollständig in Klassen ausgedrückt – das macht den Code vorhersehbar und leicht zu lesen: Statt in einer CSS-Datei zu suchen, welche Property bei welchem State gilt, siehst du es direkt im className-Attribut.

next/font – Self-hosted Fonts ohne CLS

Layout Shift durch Webfonts ist eines der häufigsten Performance-Probleme. Der Browser lädt die Seite, rendert sie mit dem System-Fallback-Font, und sobald der Webfont ankommt, springt der gesamte Text. Die sogenannte Cumulative Layout Shift (CLS) ist eine der Core Web Vitals-Metriken und beeinflusst das SEO-Ranking direkt.

next/font löst dieses Problem mit einem ungewöhnlichen Ansatz: Es lädt die Schrift beim Build herunter, bettet sie als selbst-gehostete Ressource ein und berechnet automatisch eine size-adjust-CSS-Property für den Fallback-Font – so dass dieser bereits die gleichen Dimensionen hat wie der finale Font. Der Browser muss das Layout beim Nachladen nicht mehr neu berechnen.

// app/layout.tsx
import { Geist, Geist_Mono } from "next/font/google";

// Schriften werden beim Build heruntergeladen und lokal eingebettet.
// Zur Laufzeit kein externer Request zu Google Fonts.
const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="de" className={`${geistSans.variable} ${geistMono.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  );
}

Das Ergebnis: Die Schrift ist immer verfügbar, es gibt keinen externen DNS-Lookup zur Ladezeit, und der CLS-Score bleibt bei 0. Das gilt nicht nur für Google Fonts – über next/font/local kannst du eigene Font-Dateien genauso einbinden.

Rails-Analogie

In Rails gibt es keinen direkten Äquivalent. Webfonts werden üblicherweise über ein <link>-Tag im <head> eingebunden, das direkt auf Google Fonts oder einen anderen CDN-Anbieter zeigt. Der Browser macht beim ersten Laden einen externen Request, und bis die Schrift ankommt, rendert er mit dem Fallback. Tools wie google-fonts-helper für den Eigenbetrieb existieren, sind aber Zusatzaufwand. next/font löst das Problem auf Framework-Ebene ohne zusätzliche Konfiguration.

Metadata und SEO

Gutes SEO beginnt mit korrekten <title>- und <meta>-Tags. Next.js bietet dafür ein typsicheres Metadata-System, das direkt aus dem App Router heraus funktioniert – kein Gem, kein Helper, kein manuelles content_for.

Statisches Metadata eignet sich für Seiten, deren Titel sich nie ändert:

// app/notes/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Meine Notizen",
  description: "Alle persönlichen Notizen im Überblick",
  openGraph: {
    title: "Meine Notizen",
    description: "Alle persönlichen Notizen im Überblick",
    type: "website",
  },
};

export default function NotesPage() {
  return <div>…</div>;
}

Dynamisches Metadata mit generateMetadata wird eingesetzt, wenn der Seitentitel von Daten abhängt – zum Beispiel beim Titel einer einzelnen Notiz:

// app/notes/[id]/page.tsx
import type { Metadata } from "next";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";

interface Props {
  params: Promise<{ id: string }>;
}

// generateMetadata läuft auf dem Server, bevor die Seite gerendert wird.
// Next.js dedupliciert den Datenbankaufruf automatisch mit Request-Memoization.
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { id } = await params;
  const note = await prisma.note.findUnique({ where: { id } });

  if (!note) return { title: "Notiz nicht gefunden" };

  return {
    title: `${note.title} – DevNotes`,
    description: note.body.slice(0, 160),
  };
}

export default async function NotePage({ params }: Props) {
  const { id } = await params;
  const note = await prisma.note.findUnique({ where: { id } });
  if (!note) notFound();

  return <article>{/* … */}</article>;
}

Next.js führt generateMetadata parallel zur Seitenkomponente aus. Falls beide dieselbe Datenbankabfrage machen, wird die Anfrage dank Request-Memoization nur einmal ausgeführt – du bezahlst keinen Performance-Preis dafür, Metadata und Inhalt getrennt zu laden.

Rails-Analogie
Ruby on Rails
<%# Option 1: rails-meta-tags Gem %>
<%# Gemfile: gem 'meta-tags' %>

<%# app/views/notes/show.html.erb %>
<% set_meta_tags title: @note.title,
               description: @note.body.truncate(160) %>

<%# Option 2: content_for %>
<% content_for :title do %>
<%= @note.title %> – DevNotes
<% end %>

<%# app/views/layouts/application.html.erb %>
<title><%= yield(:title) || "DevNotes" %></title>
Next.js
// app/notes/[id]/page.tsx
// Kein Gem nötig – Next.js verarbeitet Metadata nativ.
// generateMetadata läuft serverseitig parallel zur Page-Komponente.

export async function generateMetadata({ params }) {
const { id } = await params;
const note = await prisma.note.findUnique({ where: { id } });
return {
  title: `${note.title} – DevNotes`,
  description: note.body.slice(0, 160),
};
}

Weitere nützliche Metadata-Felder in Next.js sind robots (Crawling-Hinweise), canonical (URL-Dubletten vermeiden), icons (Favicon-Konfiguration) und twitter (Twitter-Card-Vorschau). Das Metadata-Objekt ist vollständig typisiert – TypeScript weist dich auf fehlerhafte Felder hin.

generateMetadata für Notizdetailseite ergänzen

Öffne app/notes/[id]/page.tsx in deiner DevNotes-App. Die Seite rendert bereits eine einzelne Notiz, aber der Browser-Tab zeigt noch keinen Notiztitel.

  1. Importiere Metadata aus "next" und prisma aus "@/lib/prisma".
  2. Füge eine generateMetadata-Funktion hinzu, die die Notiz per params.id lädt und ein Metadata-Objekt mit title und description zurückgibt.
  3. Für den Fall, dass keine Notiz gefunden wird (note === null), gib { title: "Notiz nicht gefunden" } zurück.
  4. Rufe im Browser eine Notizdetailseite auf und überprüfe im Browser-Tab, ob der Titel der Notiz erscheint. Du kannst auch in den DevTools unter Elements → head schauen, ob <title> korrekt gesetzt ist.

Bonus: Füge ein openGraph-Feld hinzu, damit die Seite beim Teilen in sozialen Netzwerken eine sinnvolle Vorschau anzeigt.

Im Capstone: DevNotes

In der DevNotes-App verwenden wir Tailwind v4 durchgehend – von den einfachen NoteCard-Komponenten bis hin zu den Formular-Layouts. Die globals.css definiert das Designsystem über @theme: Schriften (Geist Sans und Geist Mono), die Accent-Farbe und grundlegende Radius-Werte. Dadurch sind alle Komponenten konsistent, ohne dass wir Utility-Klassen mit fixen Hex-Werten pflegen müssen.

Jede Seite der DevNotes-App trägt statisches Metadata: die Übersichtsseite hat title: "Meine Notizen – DevNotes", die Login-Seite title: "Anmelden – DevNotes". Die Detailseite /notes/[id] nutzt generateMetadata, um den Notiztitel in den Tab zu schreiben – genau die Übung aus diesem Modul.

Bilder spielen in DevNotes (noch) keine Rolle, aber die next/image-Grundlagen gelten sofort, wenn du Benutzer-Avatare oder Titelbilder zu Notizen hinzufügst: width und height angeben, priority für Above-the-fold-Bilder setzen, loading="lazy" für den Rest übrig lassen.

Die Fonts werden im Root-Layout eingebunden und via CSS Custom Properties (--font-geist-sans) durch Tailwind als font-sans-Klasse verfügbar gemacht. Ein Besucher der DevNotes-App sieht nie einen Schrift-Sprung – die CLS bleibt bei 0.