Next.js 13:如何设计app目录结构实现动态侧边栏及关联内容区
Next.js 13 App 目录搜索场景 SSR + 客户端无限滚动方案
核心结构设计
采用「共享 Layout + 动态 Page + 客户端组件」的组合,既保留首屏 SSR 能力,又实现客户端交互逻辑:
1. 目录结构
app/ └── search/ ├── layout.tsx # 分栏布局容器(搜索框 + 左右分栏) ├── page.tsx # 搜索列表页(SSR 拉取第一页数据) ├── [slug]/ │ └── page.tsx # 详情页(SSR 拉取对应结果详情 + 第一页列表) └── components/ ├── SearchBar.tsx # 客户端搜索框组件 ├── ResultsList.tsx # 客户端列表组件(无限滚动) └── ResultDetail.tsx # 详情渲染组件(支持 SSR/CSR)
具体实现步骤
步骤1:创建搜索上下文(共享数据)
用 React Context 在服务器端初始化搜索状态,让 Layout 和子组件共享查询参数、初始结果:
// app/search/context.tsx 'use client'; import { createContext, useContext, useState, ReactNode } from 'react'; import type { SearchResult, ResultDetail } from '@/types'; type SearchContextType = { query: string; initialResults: SearchResult[]; selectedResult: ResultDetail | null; setSelectedResult: (result: ResultDetail | null) => void; }; const SearchContext = createContext<SearchContextType | undefined>(undefined); export function SearchProvider({ children, query, initialResults, selectedResult, }: { children: ReactNode; query: string; initialResults: SearchResult[]; selectedResult: ResultDetail | null; }) { const [selected, setSelected] = useState(selectedResult); return ( <SearchContext.Provider value={{ query, initialResults, selectedResult: selected, setSelectedResult: setSelected, }} > {children} </SearchContext.Provider> ); } export function useSearch() { const context = useContext(SearchContext); if (!context) throw new Error('useSearch must be used within SearchProvider'); return context; }
步骤2:实现 Layout 布局
Layout 负责渲染搜索框和分栏容器,不直接处理数据,通过 Context 共享状态:
// app/search/layout.tsx import { SearchBar } from './components/SearchBar'; import { ReactNode } from 'react'; export default function SearchLayout({ children }: { children: ReactNode }) { return ( <div className="flex flex-col h-screen"> {/* 顶部搜索框 */} <SearchBar /> {/* 分栏内容区 */} <div className="flex flex-1 overflow-hidden"> {children} </div> </div> ); }
步骤3:搜索框组件(客户端)
处理输入和 URL 导航,确保查询词同步到 URL 的 q 参数:
// app/search/components/SearchBar.tsx 'use client'; import { useSearchParams, useRouter } from 'next/navigation'; import { useState } from 'react'; export function SearchBar() { const searchParams = useSearchParams(); const router = useRouter(); const [query, setQuery] = useState(searchParams.get('q') || ''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!query.trim()) return; router.push(`/search?q=${encodeURIComponent(query)}`); }; return ( <form onSubmit={handleSubmit} className="p-4 border-b"> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="输入搜索词..." className="w-full p-2 border rounded" /> </form> ); }
步骤4:搜索列表页(SSR 初始数据)
在 Page 组件中服务器端拉取第一页结果和首个详情,初始化 Context:
// app/search/page.tsx import { SearchProvider } from './context'; import { ResultsList } from './components/ResultsList'; import { ResultDetail } from './components/ResultDetail'; import { fetchSearchResults, fetchResultDetail } from '@/lib/api'; type Props = { searchParams: { q?: string }; }; export default async function SearchPage({ searchParams }: Props) { const query = searchParams.q?.trim() || ''; let initialResults = []; let selectedResult = null; if (query) { initialResults = await fetchSearchResults(query, 1); if (initialResults.length > 0) { selectedResult = await fetchResultDetail(initialResults[0].slug); } } return ( <SearchProvider query={query} initialResults={initialResults} selectedResult={selectedResult} > <div className="w-1/3 border-r overflow-y-auto"> <ResultsList /> </div> <div className="w-2/3 overflow-y-auto p-4"> <ResultDetail /> </div> </SearchProvider> ); }
步骤5:动态详情页(SSR 详情数据)
当 URL 包含 slug 时,服务器端拉取对应详情和第一页列表,保持左侧列表选中状态:
// app/search/[slug]/page.tsx import { SearchProvider } from '../context'; import { ResultsList } from '../components/ResultsList'; import { ResultDetail } from '../components/ResultDetail'; import { fetchSearchResults, fetchResultDetail } from '@/lib/api'; type Props = { params: { slug: string }; searchParams: { q?: string }; }; export default async function SearchDetailPage({ params, searchParams }: Props) { const query = searchParams.q?.trim() || ''; const slug = params.slug; let initialResults = []; let selectedResult = null; if (query) { initialResults = await fetchSearchResults(query, 1); selectedResult = await fetchResultDetail(slug); } return ( <SearchProvider query={query} initialResults={initialResults} selectedResult={selectedResult} > <div className="w-1/3 border-r overflow-y-auto"> <ResultsList defaultSelectedSlug={slug} /> </div> <div className="w-2/3 overflow-y-auto p-4"> <ResultDetail /> </div> </SearchProvider> ); }
步骤6:客户端列表组件(无限滚动)
用 SWR 处理后续分页请求,基于初始 SSR 数据无缝衔接,同时处理选中项导航:
// app/search/components/ResultsList.tsx 'use client'; import { useSearch } from '../context'; import useSWRInfinite from 'swr/infinite'; import { fetchSearchResults } from '@/lib/api'; import { useRouter } from 'next/navigation'; type Props = { defaultSelectedSlug?: string; }; export function ResultsList({ defaultSelectedSlug }: Props) { const { query, initialResults, setSelectedResult, selectedResult } = useSearch(); const router = useRouter(); const getKey = (pageIndex: number, previousPageData: any) => { if (previousPageData && !previousPageData.length) return null; return `/api/search?q=${query}&page=${pageIndex + 1}`; }; const { data, error, size, setSize } = useSWRInfinite( getKey, async (url) => { const res = await fetch(url); return res.json(); }, { initialData: [initialResults], } ); const results = data ? data.flat() : []; const hasMore = data && data[data.length - 1]?.length > 0; const handleSelectResult = async (slug: string) => { router.push(`/search/${slug}?q=${encodeURIComponent(query)}`); const detail = await fetchResultDetail(slug); setSelectedResult(detail); }; const handleLoadMore = () => { if (hasMore) setSize(size + 1); }; if (error) return <div>加载失败</div>; if (!query) return <div>请输入搜索词</div>; return ( <div className="p-4"> <ul> {results.map((result) => ( <li key={result.slug} onClick={() => handleSelectResult(result.slug)} className={`p-2 cursor-pointer rounded ${ result.slug === (selectedResult?.slug || defaultSelectedSlug) ? 'bg-blue-100' : '' }`} > {result.title} </li> ))} </ul> {hasMore && ( <button onClick={handleLoadMore} className="w-full p-2 mt-4 border rounded"> 加载更多 </button> )} </div> ); }
步骤7:详情组件
渲染选中结果的详情,支持从 Context 读取 SSR/CSR 数据:
// app/search/components/ResultDetail.tsx 'use client'; import { useSearch } from '../context'; export function ResultDetail() { const { selectedResult } = useSearch(); if (!selectedResult) { return <div>请选择一个结果查看详情</div>; } return ( <div> <h2 className="text-xl font-bold">{selectedResult.title}</h2> <div className="mt-4">{selectedResult.content}</div> </div> ); }
关键优势
- 首屏全 SSR:第一页搜索结果、默认选中项的详情均由服务器渲染,保证 SEO 和首屏加载速度。
- 客户端无缝交互:无限滚动、选中项导航等逻辑由 SWR 和客户端组件处理,避免全页刷新。
- URL 同步:所有状态变化同步到 URL,支持页面刷新、分享链接,符合浏览器导航习惯。
内容的提问来源于stack exchange,提问作者AmitA
相关产品推荐
相关产品推荐

