Next.js

Next.js is a React framework for building full-stack web applications. You use React Components to build user interfaces, and Next.js provides the architecture, optimizations, and tooling on top — routing, server-side rendering, bundling, and more — so you can focus on building your application instead of spending time with configuration.

We will be using Next.js 16, the latest major version. It ships with Turbopack as the default bundler (2–5x faster builds), stable React Compiler support, and a new caching model called Cache Components. These are the key concepts you should understand before building Chirp.

React Server Components (RSC)

React Server Components allow you to render components on the server. This means the server does the heavy lifting (data fetching, rendering) and sends the result to the browser, reducing bundle size and improving performance.

In Next.js, all components are Server Components by default. If a component needs interactivity (state, effects, event handlers, browser APIs), you opt it into the client by adding "use client" at the top of the file.

// This is a Server Component by default — runs on the server
export default async function PostList() {
  const posts = await prisma.post.findMany()

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}
'use client'

// This is a Client Component — runs in the browser
import { useState } from 'react'

export default function LikeButton() {
  const [liked, setLiked] = useState(false)

  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? 'Liked' : 'Like'}
    </button>
  )
}

The mental model: Server Components for data and layout, Client Components for interactivity. You can nest Client Components inside Server Components (but not the other way around).

Resources

React Compiler

The React Compiler is a build-time tool that automatically optimizes your React application through automatic memoization. It understands the Rules of React and applies optimizations that you would otherwise have to write manually with useMemo, useCallback, and React.memo.

Before the compiler, you had to manually memoize to prevent unnecessary re-renders:

// Before: manual memoization
import { useMemo, useCallback, memo } from 'react'

const ExpensiveList = memo(function ExpensiveList({ items, onSelect }) {
  const sorted = useMemo(() => expensiveSort(items), [items])
  const handleSelect = useCallback((id) => onSelect(id), [onSelect])

  return sorted.map((item) => (
    <Item key={item.id} onClick={() => handleSelect(item.id)} />
  ))
})
// After: the compiler handles it automatically
function ExpensiveList({ items, onSelect }) {
  const sorted = expensiveSort(items)
  const handleSelect = (id) => onSelect(id)

  return sorted.map((item) => (
    <Item key={item.id} onClick={() => handleSelect(item.id)} />
  ))
}

The compiler figures out the optimal memoization boundaries at build time — you just write straightforward code.

Enable it in next.config.ts:

const nextConfig = {
  reactCompiler: true,
}

export default nextConfig

Note: The React Compiler relies on Babel, so compile times will be slightly higher when enabled.

Resources

Cache Components

Previous versions of Next.js applied implicit caching — it was hard to predict what was cached and what wasn't. Next.js 16 flips this model: everything is dynamic by default, and you explicitly opt into caching with the "use cache" directive.

Cache Components work together with Partial Pre-Rendering (PPR): at build time, Next.js renders a static HTML shell from everything it can resolve ahead of time. Dynamic parts are wrapped in <Suspense> and stream in at request time.

This gives you the speed of static sites with the flexibility of dynamic rendering.

Enable Cache Components in next.config.ts:

const nextConfig = {
  cacheComponents: true,
}

export default nextConfig

How it works

There are three types of content in a Cache Components page:

  1. Static content — plain markup, synchronous computations. Automatically included in the static shell.
  2. Cached dynamic content — data from APIs or databases that doesn't change often. Mark it with "use cache" and control its lifetime with cacheLife.
  3. Request-time dynamic content — personalized or real-time data (cookies, headers). Wrap it in <Suspense> to stream it at request time.
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife } from 'next/cache'

export default function BlogPage() {
  return (
    <>
      {/* 1. Static — automatically in the shell */}
      <h1>Our Blog</h1>

      {/* 2. Cached — included in the shell, revalidated hourly */}
      <BlogPosts />

      {/* 3. Dynamic — streams at request time */}
      <Suspense fallback={<p>Loading preferences...</p>}>
        <UserPreferences />
      </Suspense>
    </>
  )
}

async function BlogPosts() {
  'use cache'
  cacheLife('hours')

  const posts = await fetch('https://api.example.com/posts').then((r) => r.json())

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

async function UserPreferences() {
  const theme = (await cookies()).get('theme')?.value || 'light'
  return <p>Your theme: {theme}</p>
}

Tagging and revalidation

Use cacheTag to tag cached data, then invalidate it after mutations:

  • updateTag(tag) — use in Server Actions when users need to see their changes immediately (read-your-writes).
  • revalidateTag(tag, profile) — use when eventual consistency is acceptable (stale-while-revalidate).
import { cacheTag, cacheLife } from 'next/cache'
import { updateTag } from 'next/cache'

async function getCart() {
  'use cache'
  cacheTag('cart')
  cacheLife('max')
  // fetch cart data...
}

async function addToCart(itemId: string) {
  'use server'
  // write to database...
  updateTag('cart') // user sees the update immediately
}

Resources

Adding the Apps to the Monorepo

Now scaffold the web and API apps inside the Chirp monorepo.

Web — Next.js

cd apps
bunx create-next-app@latest web

Select TypeScript, Tailwind CSS, and the App Router when prompted. Then enable the React Compiler and Cache Components in next.config.ts:

const nextConfig = {
  reactCompiler: true,
  cacheComponents: true,
}

export default nextConfig

API — Hono

cd apps
bun create hono api

This is where your tRPC router will be mounted and where Prisma handles database access. You already learned about Hono and Prisma in the REST API section — the same concepts apply here.

Run bun install at the monorepo root to link everything, then verify both apps start with turbo run dev.

Now continue to the Shared Packages section to set up tRPC, auth, and email.