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:
- Data is stored in memory, making reads and writes extremely fast;
- Runs on port 6379 by default;
- No data tables, not relational, no need to write SQL;
- Extremely simple structure: key: value to get started directly.
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
redis: The client instance created by theioredislibrary (the "driver"—the bridge between code and Redis).hgetall: Corresponds to Redis's HGETALL command, fetches all fields and values in the hash at once, returning an object.await: Because this is network I/O, it must wait for Redis to return the result before proceeding.
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:
Object.keys(data).length === 0—Object.keys()collects the keys ofdatainto an array,.length === 0checks if the hash has zero notes (an empty hash'shgetallreturns{}).redis.hset('notes', initialData)— Corresponds to the HSET command, writes theinitialDatasample notes intonotes. 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
getAllNotesis anasyncfunction, so page components canawait 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
SidebarNoteListis an RSC (Server Component), responsible for fetching data, iterating and rendering the list skeleton.SidebarNoteItemContentis a"use client"client component, responsible for the interaction of a single note (e.g., future click-to-expandexpandChildren).
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:
await getAllNotes():Sidebaritself is anasyncserver component, so it can directlyawaitdata fetching inside the component—the same principle as theasync Pagediscussed in the previous article.<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).{/* 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:
Object.entries(notes):notesis the object{ id: 'json string' }returned byhgetall.Object.entriesconverts it into a two-dimensional array[["id1","json1"], ["id2","json2"], ...]. Why? Because only arrays can bemapped, objects cannot be directly iterated into components—the comment "for easy map component" means exactly this.- 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>. arr.map(([noteId, note]) => ...): Destructuring assignment is used here,[noteId, note]directly extracts the "id" and "value" from each item.JSON.parse(note):noteis currently a string (because it was serialized when stored in Redis),JSON.parserestores it to a real JS object to access fields like.title,.content.key={noteId}: React lists must have akey, 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:
const { title, content='', updateTime } = note: When destructuring, a default value of empty string is set forcontent, preventingsubstringfrom throwing an error if a note lacks a content field.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.- The
expandChildrenprop: 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:
"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."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 forexpandChildren).children/expandChildrentwo props: This is the slot mechanism.SidebarNoteItemstuffs aheaderintochildrenand a summary intoexpandChildren—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:
- Redis is an in-memory NOSQL,
key:valueis extremely simple, hash type useshget/hset/hgetall; the classic scenario is caching, sitting in front of MySQL to solve the read/write I/O bottleneck. hgetallfetches the entire hash, combined with the "initialize if empty" seed data logic, making the data layer ready to use out of the box.- 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. dayjsfor lightweight time formatting,Object.entriesto convert to an array for easymap,JSON.parseto restore objects,children/expandChildrenslots 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."