Next.js 客户端与服务端组件结合优化课程页面SEO
课程页面SEO优化与Next.js组件渲染方案
问题背景
我想在网站中添加形如/my-website/courses/course-1的课程页面,需要实现SEO友好的course-1内容渲染。当前使用纯客户端渲染组件(代码如下),有两个核心疑问:
- 是否需要将课程描述这类SEO关键内容迁移到服务端组件?
- 服务端组件与客户端组件能否互相嵌套?比如Page作为服务端组件,CourseLandingPage作为客户端组件,在其内部创建服务端渲染的CourseDescription组件来优化SEO,这种思路是否正确?
当前客户端组件代码
'use client'; import { useBoughtCourse, useBuyCourse } from '@/api/course/hooks'; import { useCurrentUser } from '@/api/user/hooks'; import { CourseTree } from '@/app/courses/[name]/tree/course-tree'; import { CourseReviews } from '@/app/courses/course-reviews'; import { VideoPlayer } from '@/components/player/video-player'; import { ReviewsSummary } from '@/components/reviews'; import { RichHtml } from '@/components/rich-html'; import { TargetBadges } from '@/components/target-badges'; import { Button } from '@/components/ui/button'; import { TextGradient } from '@/components/ui/text-gradient'; import { UserCard } from '@/components/user-card'; import { formatDate, msToShortTime, sum } from '@/lib/utils'; import { Course } from '@/types'; import { UseQueryResult } from '@tanstack/react-query'; import { Newspaper, Paperclip, PlaySquare, RefreshCcw } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { GiTwoCoins } from 'react-icons/gi'; export const CourseLandingPage = ({ queryCourse, }: { queryCourse: UseQueryResult<Course>; }) => { const queryCurrentUser = useCurrentUser(); const pathname = usePathname(); console.log('USER', queryCurrentUser.data); const queryBoughtCourse = useBoughtCourse( { id: queryCourse.data?.id! }, !!queryCurrentUser.data ); const mutationBuyCourse = useBuyCourse({ id: queryCourse.data?.id! }); if (queryBoughtCourse.isLoading || queryCourse.isLoading || !queryCourse.data) return null; const course = queryCourse.data!; const BuySection = () => { return ( <div className="sticky right-[100px] top-[100px] z-[10] flex w-[30rem] flex-col gap-5"> <div className="w-[20rem] rounded-md border-[2px] border-dashed bg-card p-3"> {course.marketingVideo && ( <VideoPlayer src={course.marketingVideo.url} /> )} {queryCurrentUser.data?.role === 'TEACHER' || course.author.id === queryCurrentUser.data?.id ? null : queryBoughtCourse.data ? ( <div className="my-3 w-full"> <p> Ten kurs został zakupiony dnia{' '} {formatDate(new Date(queryBoughtCourse.data.createdAt), 'PPP')}. </p> <Link href={`${pathname}/learn`} className="w-full"> <Button gradient gradientClassName="w-fit"> Przejdź do kursu </Button> </Link> </div> ) : ( <button className="my-3 flex w-full cursor-default items-center justify-center gap-2 rounded-full border border-dashed bg-background p-2 text-xl shadow-none hover:bg-muted" disabled={queryBoughtCourse.isLoading} onClick={() => mutationBuyCourse.mutate(queryCourse.data.id)} > <div className="flex items-center justify-center gap-1.5"> <GiTwoCoins className="h-6 w-6 fill-gold-10" /> {course.price} </div> <span className="">Kup</span> </button> )} <p className="pb-2 text-lg font-semibold"> <TextGradient color="brand">Zawartość kursu</TextGradient> </p> <div className="flex flex-col items-start gap-2"> <span className="flex items-center justify-center gap-2"> <PlaySquare /> Treści video:{' '} <span>{msToShortTime(course.duration * 1000)}</span> </span> <span className="flex items-center justify-center gap-2"> <Newspaper /> <span>Artykuły: {course.articlesCount}</span> </span>{' '} <span className="flex items-center justify-center gap-2"> <Paperclip />{' '} <span> Załączniki:{' '} {sum( course.content.map((item) => item.attachments?.length || 0) )} </span> </span>{' '} {/* <span className="flex items-center justify-center gap-2"> <InfinityIcon /> <span>Dożywotni dostęp</span> </span> */} </div> </div> </div> ); }; return ( <div className="flex gap-10"> <div className="flex max-w-[700px] flex-1 flex-col justify-start gap-2"> <h1 className="text-3xl font-bold"> <TextGradient color="secondary">{course.name}</TextGradient> </h1> <RichHtml html={course.description} /> <div className="flex items-center gap-2"> <ReviewsSummary count={course.ratingsCount} rating={course.ratingsAvg} /> <span className="text-sm"> {queryCourse.data.boughtCoursesCount} uczestników </span> </div> <p className="flex items-center justify-center gap-1.5 self-start text-sm"> <RefreshCcw className="h-4 w-4" /> Ostatnia aktualizacja:{' '} <span>{formatDate(new Date(course.updatedAt), 'PPP pp')}</span> </p> <UserCard user={course.author} /> <div className="mt-10"> <h3 className="text-2xl font-bold">Opis</h3> <RichHtml html={course.description} /> </div> <div className="mt-10 flex flex-col gap-2 "> <h3 className="text-2xl font-bold">Zawartość</h3> <CourseTree queryCourse={queryCourse} type="landing-page" /> </div> <div className="flex flex-wrap items-end gap-1"> <TargetBadges domains={course.domains} theorems={course.theorems} definitions={course.definitions} tags={course.tags} /> </div> <CourseReviews course={course} /> </div> <BuySection /> </div> ); };
解答
1. SEO关键内容必须迁移到服务端组件
是的,课程名称、描述、评分统计、更新时间这类SEO核心内容必须放到服务端组件(SSG/SSR)中渲染。纯客户端渲染时,爬虫无法等待React hydration完成再抓取内容,会导致页面核心内容为空,直接拉低SEO排名。
你可以把当前组件拆分为两部分:
- 服务端组件:负责获取课程数据、渲染所有爬虫需要抓取的静态内容(标题、描述、作者信息、评价摘要等),这部分内容会在服务器生成完整HTML,爬虫能直接读取。
- 客户端组件:保留交互逻辑(购买按钮、视频播放器、用户状态判断等),这些内容不需要被爬虫抓取,只需要在客户端hydration后正常运行。
2. 服务端与客户端组件的嵌套规则
你的理解部分正确,但要遵守Next.js的组件嵌套限制:
- ✅ 服务端组件可以嵌套客户端组件:完全允许,比如服务端Page组件中可以导入并渲染带
'use client'指令的客户端组件。 - ❌ 客户端组件不能直接嵌套服务端组件:客户端组件内部无法直接导入服务端组件,但可以通过props传递的方式,让服务端组件先渲染好内容,再传给客户端组件展示。
针对你的场景,正确的实现方式是:
- 将
app/courses/[name]/page.tsx设为服务端组件,在这里直接从数据库/API获取课程数据(无需使用react-query,服务端可直接发起请求)。 - 在服务端Page中,先渲染SEO关键内容(课程标题、描述、评价摘要等),再导入客户端版的
CourseLandingPage,把课程数据作为props传递给它处理交互逻辑。 - 如果要拆分
CourseDescription这类服务端渲染组件,直接在服务端Page中渲染即可,不需要放到客户端组件内部。
简化示例:
// app/courses/[name]/page.tsx (服务端组件) import { getCourseByName } from '@/api/course/server'; import { CourseLandingPageClient } from './course-landing-client'; import { CourseDescription } from './course-description'; // 服务端组件 export default async function CoursePage({ params }: { params: { name: string } }) { const course = await getCourseByName(params.name); return ( <div className="flex gap-10"> <div className="flex max-w-[700px] flex-1 flex-col justify-start gap-2"> <h1 className="text-3xl font-bold">{course.name}</h1> <CourseDescription description={course.description} /> {/* 服务端渲染其他SEO内容:评价摘要、更新时间、作者信息等 */} <CourseLandingPageClient course={course} /> {/* 客户端组件处理交互 */} </div> {/* 服务端渲染购买区域静态内容,交互部分交给客户端组件 */} </div> ); }
// app/courses/[name]/course-landing-client.tsx (客户端组件) 'use client'; import { useBoughtCourse, useBuyCourse } from '@/api/course/hooks'; import { useCurrentUser } from '@/api/user/hooks'; // 其他客户端依赖... export const CourseLandingPageClient = ({ course }: { course: Course }) => { // 处理购买逻辑、用户状态判断等交互 const queryCurrentUser = useCurrentUser(); const queryBoughtCourse = useBoughtCourse({ id: course.id }, !!queryCurrentUser.data); const mutationBuyCourse = useBuyCourse({ id: course.id }); return ( <> {/* 客户端交互部分:课程树交互、评论区、购买按钮逻辑等 */} <div className="mt-10 flex flex-col gap-2 "> <h3 className="text-2xl font-bold">Zawartość</h3> <CourseTree course={course} type="landing-page" /> </div> <CourseReviews course={course} /> </> ); };
这种方案既保证了SEO关键内容被爬虫正常抓取,又保留了客户端交互功能,完全符合Next.js的组件规则。
内容的提问来源于stack exchange,提问作者Korer
相关产品推荐
相关产品推荐

