← Back to Index

Chapter 5: Styling & Assets

CSS Modules, Tailwind, Font & Image Optimization

1. Global Styles

In App Router projects, the global stylesheet is typically placed at src/app/globals.css. This is the right place for CSS variables, reset styles, and rules that apply site-wide; component-level styles should preferably use CSS Modules or Tailwind to avoid global pollution.

globals.css example (can coexist with Tailwind's @tailwind directives):

@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
  --background: #ffffff;
  --foreground: #0f172a;
}

body {
  color: var(--foreground);
  background: var(--background);
}

Importing in layout.tsx:

import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "My App",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="zh-CN">
      <body className="antialiased min-h-screen">{children}</body>
    </html>
  );
}

2. CSS Modules

CSS Modules bind stylesheets to components: the build tool generates a unique hash for each class name, preventing class name conflicts between different components. This is ideal for maintaining predictable style boundaries in large projects.

Button.module.css:

.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.5rem 1rem;
  border-radius: 0.375rem;
  font-weight: 600;
  transition: background-color 0.2s ease;
}

.primary {
  background-color: #0ea5e9;
  color: #ffffff;
}

.primary:hover {
  background-color: #0284c7;
}

.secondary {
  background-color: #f1f5f9;
  color: #0f172a;
}

Button.tsx (defaults to Server Component, no "use client" needed):

import clsx from "clsx";
import styles from "./Button.module.css";

type ButtonProps = {
  variant?: "primary" | "secondary";
  className?: string;
  children: React.ReactNode;
};

export default function Button({
  variant = "primary",
  className,
  children,
}: ButtonProps) {
  return (
    <button
      type="button"
      className={clsx(styles.button, styles[variant], className)}
    >
      {children}
    </button>
  );
}

Combining multiple class names: Besides clsx / classnames, you can also use template literals like {`${styles.button} ${styles.primary}`}; clsx is preferred when there are many conditions.

Install clsx:

npm install clsx

After installation, package.json will have the new dependency (excerpt):

{
  "dependencies": {
    "clsx": "^2.1.1",
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

3. Tailwind CSS

When you select Tailwind via create-next-app, the scaffold sets up tailwind.config.ts, postcss.config.mjs, and injects @tailwind directives into globals.css. You just need to write utility classes directly on component classNames.

tailwind.config.ts example (scanning src/app and src/components):

import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {
      colors: {
        brand: {
          DEFAULT: "#0ea5e9",
          foreground: "#f0f9ff",
        },
      },
      fontFamily: {
        sans: ["var(--font-inter)", "system-ui", "sans-serif"],
      },
    },
  },
  plugins: [],
};

export default config;

Using utility classes directly in components:

export default function Hero() {
  return (
    <section className="mx-auto max-w-3xl px-4 py-16 text-center">
      <h1 className="text-4xl font-bold tracking-tight text-slate-900">
        欒迎
      </h1>
      <p className="mt-4 text-lg text-slate-600">
        Tailwind 与 Next.js App Router ι…εˆδ½Ώη”¨ζ—ΆοΌŒζ— ιœ€ι’ε€–θΏθ‘Œζ—Ά CSS-in-JS。
      </p>
    </section>
  );
}

πŸ’‘ Key Point

Tailwind CSS is naturally compatible with React Server Components: class names are scanned and final CSS is generated at build time by Tailwind, with no runtime stylesheet injection in the browser. This means you can freely write className in Server Components without being forced to use client components as some CSS-in-JS solutions require.

4. CSS-in-JS Considerations

Libraries like styled-components and @emotion/react depend on the React runtime and client context to generate and mount styles. Under the App Router and RSC model, extra configuration is needed (e.g., the styled-components Registry pattern from Next official docs), and styled components typically can only be used in Client Components (add "use client" at the top).

CSS Modules vs Tailwind vs CSS-in-JS

Dimension CSS Modules Tailwind CSS CSS-in-JS (styled, etc.)
Scoping Build-time hash, local scope Utility classes + Purge, global atomic classes Typically encapsulated with component, runtime injection
Server Components βœ… Fully supported βœ… Fully supported ⚠️ Most require Client + config
Learning Curve Familiar with CSS is enough Need to memorize class names or rely on plugins Consistent with React component model
Bundle & Performance Only bundles used modules JIT generated, small size Runtime overhead varies by library

5. next/font - Font Optimization

next/font downloads or references font files at build time, automatically generating @font-face rules and font-display strategies, reducing layout shift (CLS) and avoiding extra network roundtrips that block rendering.

Combining Google font and local font in layout.tsx:

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});

const display = localFont({
  src: "./fonts/PlayfairDisplay.woff2",
  weight: "700",
  style: "normal",
  display: "swap",
  variable: "--font-display",
});

export const metadata: Metadata = {
  title: {
    default: "My App",
    template: "%s | My App",
  },
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html
      lang="zh-CN"
      className={`${inter.variable} ${display.variable}`}
    >
      <body className={`${inter.className} antialiased`}>{children}</body>
    </html>
  );
}

Note: inter.className applies Inter to body; display.variable can be used in any child component via className="font-[family-name:var(--font-display)]" or Tailwind's extended fontFamily. Place the actual PlayfairDisplay.woff2 file in src/app/fonts/.

6. next/image - Image Optimization

The Image component from next/image performs size optimization, modern format conversion (WebP/AVIF), and lazy loading at request or build time, significantly improving LCP and bandwidth usage.

next.config.ts remote domain allowlist example:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "images.unsplash.com",
        port: "",
        pathname: "/**",
      },
    ],
  },
};

export default nextConfig;

Component example: static import + remote URL + fill:

import Image from "next/image";
import localPic from "./photo.jpg";

export default function Gallery() {
  return (
    <div className="space-y-8">
      <Image
        src={localPic}
        alt="ζœ¬εœ°ζ‰“εŒ…δΌ˜εŒ–ηš„ε›Ύη‰‡"
        placeholder="blur"
        className="rounded-lg"
        priority
      />

      <div className="relative h-64 w-full max-w-2xl">
        <Image
          src="https://images.unsplash.com/photo-1500530855697-b586d89ba3ee"
          alt="θΏœη¨‹η€ΊδΎ‹"
          fill
          sizes="(max-width: 768px) 100vw, 672px"
          className="object-cover rounded-lg"
        />
      </div>
    </div>
  );
}

7. Static Assets (public directory)

The public/ directory at the project root stores as-is static files: copied to the output root during build, accessed with the site root URL as prefix, no need to import.

Referencing in JSX (plain <img> or Link):

import Link from "next/link";

export default function FooterBrand() {
  return (
    <Link href="/" className="inline-flex items-center gap-2">
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img src="/images/logo.svg" alt="η«™η‚Ή Logo" width={32} height={32} />
      <span>My Site</span>
    </Link>
  );
}

For automatic optimization and lazy loading, prefer placing images inside src and using next/image; public is better suited for fixed URL resources, favicons, manifest.json, etc.

8. Chapter Summary

Global & Modular

Import globals.css in layout.tsx; use *.module.css for component styles to avoid global pollution.

Tailwind

Integrated by default in official templates; extend themes in tailwind.config.ts; class names generated at build time, compatible with Server Components.

CSS-in-JS

styled-components / Emotion require extra configuration and are mostly used in Client Components; new projects commonly choose Tailwind + CSS Modules.

next/font

Self-host fonts with next/font/google and next/font/local to reduce CLS; mount variables in the root layout.

next/image

Static import for local images; configure remotePatterns for remote images; choose width/height or fill + sizes as needed.

public/

Direct-link static resources at root path; suitable for favicons, robots.txt, and fixed files that don't need optimization.