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

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传递的方式,让服务端组件先渲染好内容,再传给客户端组件展示。

针对你的场景,正确的实现方式是:

  1. 将app/courses/[name]/page.tsx设为服务端组件,在这里直接从数据库/API获取课程数据(无需使用react-query,服务端可直接发起请求)。
  2. 在服务端Page中,先渲染SEO关键内容(课程标题、描述、评价摘要等),再导入客户端版的CourseLandingPage,把课程数据作为props传递给它处理交互逻辑。
  3. 如果要拆分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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.22 04:55:54