You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.23 23:27:48