跪拜 Guibai
← Back to the summary

Splitting Next.js Server and Client Components for a Redis-Backed Sidebar

Next.js Note System (Part 2): Redis Data Service and Sidebar Component Splitting in Practice

In the previous article, we built the skeleton of "routing + layout." This article goes one level deeper: where does the data come from (Redis), and how does the data flow to the page (component splitting). The process answers three questions from tonight.

1. Data Service: Why Choose Redis?

Before writing lib/redis.js, answer a more fundamental question—why use an "in-memory database" instead of MySQL?

1. What is Redis

Redis is a type of NOSQL in-memory database:

There is a particularly apt analogy in the notes: it is a bit like the browser's localStorage—store it with a key, retrieve it with a key, without any of the ceremony of "creating tables, building relationships."

2. What's "Advanced": Different Optimization Methods for Different Types

Redis is not just about the simplest strings; it provides different storage methods and commands for different data types:

Data Type Purpose Command
String Store a single value get / set
Hash Store multiple field→value pairs under one key hget / hset / hgetall

Classic application scenarios are: caching, counters, leaderboards.

3. Redis + MySQL: Solving the Read/Write I/O Bottleneck

When using MySQL alone, disk read/write (I/O) is the bottleneck. The typical use of Redis is to sit in front of MySQL as a cache. The notes use the "Juejin homepage" as a particularly good example:

The article list is basically unchanged for several minutes
  ↓
First user comes → queries MySQL → stores the result in Redis (key:value)
  ↓
Subsequent users → read directly from Redis, no longer hitting MySQL

Because the homepage article list changes infrequently, the cache hit rate is extremely high, and most requests never touch the disk at all, improving performance by several orders of magnitude. This is the value of caching.

4. Where to Put Data Logic: the lib Directory

The convention in Next.js is: data business logic is uniformly placed in the lib directory. So the data access function getAllNotes is written in lib/redis.js, forming a clear chain:

/ (route) → lib (fetch data notes) → sidebar (display) → good SEO navigation

2. redis.js: Line-by-Line Analysis of the Data Layer Code

// node redis client, driver
import Redis from 'ioredis'
const redis = new Redis();
// hash key string ID, value note serialized string
// redis key:value value specifically supports hash type
const initialData = {
  "1702459181837": '{"title":"sunt aut","content":"quia et...","updateTime":"..."}',
  "1702459182837": '{"title":"qui est","content":"est rerum...","updateTime":"..."}',
  "1702459188837": '{"title":"ea molestias","content":"et iusto...","updateTime":"..."}'
}
export async function getAllNotes(){
    const data = await redis.hgetall('notes');
    if(Object.keys(data).length === 0){
        await redis.hset('notes', initialData);
    }
    return await redis.hgetall('notes');
}

Question ①: What does const data = await redis.hgetall('notes') fetch?

First, look at the data structure design: a hash is used here, with the key being notes, and each note inside is a "string ID → serialized JSON string":

graph LR
    subgraph hash["key: notes (a hash)"]
        A["field: 1702459181837<br/>value: {title:sunt aut,...}"]
        B["field: 1702459182837<br/>value: {title:qui est,...}"]
        C["field: 1702459188837<br/>value: {title:ea molestias,...}"]
    end

So the meaning of this line is: asynchronously fetch all notes in the notes hash and store them in data.

Question ②: Why check for emptiness first, then hset?

if(Object.keys(data).length === 0){
    await redis.hset('notes', initialData);
}

This is the "initialize if empty" seed data logic:

  1. Object.keys(data).length === 0Object.keys() collects the keys of data into an array, .length === 0 checks if the hash has zero notes (an empty hash's hgetall returns {}).
  2. redis.hset('notes', initialData) — Corresponds to the HSET command, writes the initialData sample notes into notes. Its purpose: on the first run, when the database is empty, populate it with sample data, otherwise the page would be empty and impossible to demo.

The last line return await redis.hgetall('notes'): Regardless of whether initialization happened, query and return all the latest notes.

The entire getAllNotes is an async function, so page components can await getAllNotes() to directly get the data—this is exactly the implementation of the "server component async" from the previous article.


3. Sidebar Component Tree: Why Split into Four Layers?

After getting the data, how to display it in the left list? This reflects "specification-driven programming"—plan the components first, then write the code. The planned component tree:

graph TD
    Sidebar["Sidebar (RSC, await fetches data)"] --> SNL["SidebarNoteList (RSC, iterates list)"]
    SNL --> SNI["SidebarNoteItem (each note)"]
    SNI --> SNIC["SidebarNoteItemContent (use client, interaction)"]

Core Design Idea (this is the most valuable point tonight):

The code comments are very straightforward:

// SidebarNoteList (RSC SED) -> split out SidebarNoteItem interaction

Why split it this way? Because server components (RSC) cannot have interaction—no useState, no useEffect, no event binding. If each note needs interaction like "click to expand" in the future, a client component is necessary. So, separate what can be on the server (list structure) from what must be on the client (interaction), server renders the skeleton, client handles the interaction, each playing to its strengths.

SidebarNoteList2.js is the original version "before splitting," used later for comparison.


4. Sidebar.js: Fetch Data + Pass to List

import { getAllNotes } from '@/lib/redis';
import SidebarNoteList from './SidebarNoteList';

export default async function Sidebar() {
    const notes = await getAllNotes();   // 1. Server directly fetches data
    console.log(notes);                  // 2. Debug print
  return (
    <>
    <section className="col sidebar">
        <Link href="/" className="sidebar-header"> ... </Link>
        <section className="sidebar-menu" role="menubar">
            {/* SidebarSearchField future work */}
        </section>
        <nav>
         {/* SidebarNoteList */}
         <SidebarNoteList notes={notes} />   {/* 3. Pass data to list component */}
        </nav>
    </section>
    </>
  );
}

Three new points:

  1. await getAllNotes(): Sidebar itself is an async server component, so it can directly await data fetching inside the component—the same principle as the async Page discussed in the previous article.
  2. <nav> semantic tag: The list is placed inside <nav>, indicating this is a navigation area (along with the previous <section>, these are semantic tags, friendly for SEO and accessibility).
  3. {/* SidebarSearchField future work */}: This is the "to be continue comment technique" in the notes—using comments to placeholder future features, beneficial for team collaboration, memory, and maintenance. Write down what to do first, then fill it in later.

5. SidebarNoteList.js: Convert Hash to List

import SidebarNoteItem from '@/components/SidebarNoteItem';

export default async function SidebarNoteList({ notes }) {
  const arr = Object.entries(notes); // convert hash to 2D array for easy map component
  if (arr.length == 0) {
    return <div className="notes-empty">No Notes created yet!</div>
  }

  return (
    <ul className="notes-list">
    {
      arr.map(([noteId, note]) => {
        return (
          <li key={noteId}>
            <SidebarNoteItem noteId={noteId} note={JSON.parse(note)} />
          </li>
        )
      })
    }
    </ul>
  )
}

Line-by-line breakdown:

  1. Object.entries(notes): notes is the object { id: 'json string' } returned by hgetall. Object.entries converts it into a two-dimensional array [["id1","json1"], ["id2","json2"], ...]. Why? Because only arrays can be mapped, objects cannot be directly iterated into components—the comment "for easy map component" means exactly this.
  2. Empty check arr.length == 0: When there are no notes, return an empty state prompt "No Notes created yet!" instead of rendering an empty <ul>.
  3. arr.map(([noteId, note]) => ...): Destructuring assignment is used here, [noteId, note] directly extracts the "id" and "value" from each item.
  4. JSON.parse(note): note is currently a string (because it was serialized when stored in Redis), JSON.parse restores it to a real JS object to access fields like .title, .content.
  5. key={noteId}: React lists must have a key, using a unique id helps React efficiently update the list.

6. SidebarNoteItem.js: Format a Single Note

import dayjs from 'dayjs';
import SidebarNoteItemContent from './SidebarNoteItemContent';

export default function SidebarNoteItem({ noteId, note }) {
  const { title, content='', updateTime } = note;
  return(
    <SidebarNoteItemContent
      id={noteId}
      title={note.title}
      expandChildren={
        <p className="sidebar-note-excerpt">
          {content.substring(0, 20) || <i>(No content)</i>}
        </p>
      }
    >
        <header className="sidebar-note-header">
          <strong>{title}</strong>
          <small>{dayjs(updateTime).format('YYYY-MM-DD')}</small>
        </header>
    </SidebarNoteItemContent>
  )
}

Question ③: What is pnpm i dayjs for?

It's used right here. dayjs is a lightweight date library of about 2KB, used to format time. The updateTime stored in the notes is a long ISO format string ("2023-12-13T09:19:48.837Z"), which looks ugly displayed directly. Using:

dayjs(updateTime).format('YYYY-MM-DD')
// "2023-12-13T09:19:48.837Z" → "2023-12-13"

turns it into a readable date. pnpm i dayjs installs it using the pnpm package manager.

Other points:

  1. const { title, content='', updateTime } = note: When destructuring, a default value of empty string is set for content, preventing substring from throwing an error if a note lacks a content field.
  2. content.substring(0, 20): Truncates the first 20 characters of the body as a summary. || <i>(No content)</i> is "if the summary is empty, display an italic prompt"—this is another null value fallback.
  3. The expandChildren prop: Note that it passes a JSX fragment (the summary <p>), not a string. This is the "reserved expand content"—in conjunction with the client component in the next section, this summary will be displayed when clicked to expand in the future. This is precisely the "to be continue comment technique" manifested in code structure: leave the slot ready first.

7. SidebarNoteItemContent.js: The Boundary of the Client Component

"use client";
import { useState, useEffect } from 'react';

export default function SidebarNoteItemContent({
    id,
    title,
    children,
    expandChildren
}) {
  return (
    <>
      {children}
    </>
  )
}

This layer is the key to the entire split. Although it is almost empty now, it carries a lot of information:

  1. "use client": This is the declaration boundary for a client component. With it, this component (and its subtree) will run in the browser and can have interaction. It is the watershed between "server components" and "client components."
  2. import { useState, useEffect }: Although not used yet, they are imported in advance—this is a "placeholder" indicating: interaction logic will be placed here in the future (e.g., the expand/collapse state for expandChildren).
  3. children / expandChildren two props: This is the slot mechanism. SidebarNoteItem stuffs a header into children and a summary into expandChildren—this component is a "content container," responsible for deciding in the future "display children by default, display expandChildren when expanded."

Understanding the split in one sentence: SidebarNoteItemContent is the client boundary reserved for "interaction," SidebarNoteItem is the server responsible for preparing data, and the two are combined through the children slot.


8. SidebarNoteList2.js: The Pre-Split Version (for Comparison)

import dayjs from 'dayjs';

export default async function SidebarNoteList({ notes }) {
  const arr = Object.entries(notes);
  if (arr.length == 0) {
    return <div className="notes-empty">No Notes created yet!</div>
  }

  return (
    <ul className="notes-list">
    {
      arr.map(([noteId, note]) => {
        const { title, updateTime } = JSON.parse(note);
        return (
          <li key={noteId}>
            <header className="sidebar-note-header">
              <strong>{title}</strong>
              <small>{ dayjs(updateTime).format('YYYY-MM-DD HH:mm:ss') }</small>
            </header>
          </li>
        )
      })
    }
    </ul>
  )
}

Comparing the two versions, the differences are clear at a glance:

SidebarNoteList2 (Pre-Split) SidebarNoteList (Post-Split)
Responsibility Fetch data + iterate + format + render, all crammed into one component Only responsible for fetching data + iterating, rendering details delegated to child components
Single Note Directly inline header inside <li> Extracted into SidebarNoteItem for reuse
Interaction None (pure server) Client boundary reserved via SidebarNoteItemContent
Time Format Precise to seconds YYYY-MM-DD HH:mm:ss Only to day YYYY-MM-DD (simpler after splitting)

Why split? It still comes down to: single responsibility + reusability + interactivity. After splitting, SidebarNoteItem can be reused elsewhere, interaction logic is centralized in the client component, and SidebarNoteList remains pure server rendering.


9. Complete Data Flow

Stringing together all the code written tonight forms a clear "fetch → iterate → render" chain:

sequenceDiagram
    participant P as Page (RSC)
    participant S as Sidebar (async)
    participant R as lib/redis.js
    participant RD as Redis

    P->>S: Render Sidebar
    S->>R: await getAllNotes()
    R->>RD: hgetall('notes')
    RD-->>R: Return hash object
    R-->>S: notes (if empty, hset initialize first)
    S->>S: <SidebarNoteList notes={notes} />
    S->>S: Object.entries to array → map
    S->>S: Each JSON.parse → SidebarNoteItem
    S->>S: dayjs format → SidebarNoteItemContent("use client")
    S-->>P: Render left note list

10. Summary

In this article, we went one level deeper, focusing on mastering four "fundamental" concepts:

  1. Redis is an in-memory NOSQL, key:value is extremely simple, hash type uses hget/hset/hgetall; the classic scenario is caching, sitting in front of MySQL to solve the read/write I/O bottleneck.
  2. hgetall fetches the entire hash, combined with the "initialize if empty" seed data logic, making the data layer ready to use out of the box.
  3. Server components cannot have interaction, so split "list rendering (RSC)" and "single note interaction (use client)" apart—this is the core idea of Next.js component splitting.
  4. dayjs for lightweight time formatting, Object.entries to convert to an array for easy map, JSON.parse to restore objects, children/expandChildren slots to leave openings—these are the basic skills for daily data rendering.

Comparing SidebarNoteList2 (pre-split) and SidebarNoteList (post-split), you can most intuitively feel "why good code is split out."