(从零到一)快速搭建博客网站:使用Next.js、Strapi和Shadcn-UI构建CMS管理后台和SEO友好的前端界面
开发背景:
最近在群里看到有人说如何快速开发一个博客网站,那我们先拆解一下开发需求。
- 博客的管理就是需要个CMS的管理后台。
- 展示就是需要一个对SEO友好的界面。
框架选择
SEO友好的前端框架-NextJS
CMS管理后台-Strapi(Open source Node.js Headless CMS)
最近很火的UI集合-shadcn-ui
家喻户晓的CSS框架-Tailwind CSS
包管理工具-pnpm
保姆级开发步骤
创建项目文件夹,创建workspace环境
mkdir blog-project # 创建目录 /Users/luke/Desktop/course/blog-project cd blog-project pnpm init
打开自己习惯用的IDE,执行命令 code . 或者 webstorm .
创建创建文件夹apps和文件pnpm-workspace.yaml
packages: - "apps/*"
在apps的目录执行命令创建NextJS的web项目
cd web pnpm run dev # 打开链接http://127.0.0.1:3000/,这个时候就可以打开我们启动的页面了
添加CMS管理后台
切换到apps的目录执行安装strapi命令,演示作用我就没展示mysql的链接了,大家有兴趣我可以再下一个文章去写一下,或者去strapi官网看一下如何使用别的数据。
pnpx create-strapi-app@latest cms --quickstart --ts
进入apps/cms的目录,拷贝一下src/admin/app.example.tsx文件为app.tsx,然后再配置那里把中文简体的配置打开注释,你的文件就像下面一样
把cms的develop命令改成dev,然后启动看一下,pnpm run dev
这个时候我们会看到启动报错,遇到困难别怕,看一下报错提示,File '@strapi/typescript-utils/tsconfigs/server' not found.很简单,自己在安装一下这个包就行了
你可以在项目根目录执行pnpm install --filter也可以在cms目录直接执行pnpm install.
我是在根目录执行 pnpm install @strapi/typescript-utils --filter -D
重新启动后你还是会发现一个报错,因为typescript的报错。没有找到node模块
还需要安装一下pnpm install @types/node --filter -D
这个时候重新启动一下,我们就会成功进到一个注册的超级管理员的页面,我们根据提示填写自己的账号密码就可以了。
登录成功后我们会进到这里的一个管理后台,至此我们的工作已经完成一半了。
简单对cms的后端面板写一下解释,大家可以看这个图,后面会有别的机会给大家讲解的
点击市场找到CKeditor5插件,我们现在来安装一下,执行以下命令
pnpm install @_sh/strapi-plugin-ckeditor -S --filter=cms # 然后重新启动一下
进去后我们点击Content-type Builder这个左侧导航,然后点击COLLECTION TYPES下面那个创建,我们来创建文章实体
创建以下基本字段title,desc,cover,content。由于strapi可以用草稿发布模式,我们文章就使用这个模式,你点击创建实体的时候会有让你选择的,默认是选择上的。新增完之后会重启服务,帮我们创建好实体
我们继续创建标签实体,定义这个实体跟我们的文章是多对多关系,下面我们先创建标签实体,这个我们不需要用发布模式,然后只需要一个短文本的name字段。
后面我们去创建内容,然后把这两个实体关联,我们可以随便创建一点内容,点击内容管理器
创建完之后我们需要做下一步,把它们关联起来,关系如何
文章可以有多个标签,标签也属于多个文章,我们得出个关系,就是多对多。
好了我们去添加关系,这个时候添加完之后还是会重启服务。我们点击Contenty-type builder 去给article添加一个新的字段。也就是引用字段,添加完之后去article添加一下标签
这个时候我们最简单的博客管理后端已经做好了。我们现在需要把这个服务变成api接口访问就行。这对于strapi来说也是超级简单的。下面我们来设置api访问。
添加api访问
strapi是一个集成api访问和后台管理的headLess CMS开源框架。我们只需要配置一下实体的权限就可以实现api访问控制,默认情况下strapi的接口入口是/api开始,我们刚才创建了article的实体(要加复数),那么我们可以访问http://127.0.0.1:1337/api/arciels,第一次访问的时候会返回403,这个时候是 因为我们没打开我们的公共访问。
我们现在去打开公共访问
再次看一下我们的接口请求
到这里strapi搭建的cms管理后台已经接近完成,我们可以整合前端项目去做我们的前端展示了。
整合项目
现在我们两个项目都是用typescript,我们需要使用paths去定义一下项目的引用别名,以备之后会用上
我们在项目的根目录创建tsconfig.json
{ "compilerOptions": { "baseUrl": "./", "experimentalDecorators": true, "emitDecoratorMetadata": true, "incremental": true, "skipLibCheck": true, "strictNullChecks": true, "noImplicitAny": true, "strictBindCallApply": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "paths":{ "web/*":["./apps/web/*"], "cms/*":["./apps/cms/*"] } } }
然后我们可以把nextjs的项目的tsconfig.json文件修改一下
{ "extends": "../../tsconfig.json", "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ] }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] }
现在我们内容已经有了,我们可以去做前端网站的开发了,上面我们说到我们在nextjs中需要引入shadcn/ui,这个是最近势头很猛的一个组件集合。它不称自己为组件库,而是叫集合。全部的代码开源,也可以直接拷贝进去进行使用。现在我们就去我们的next14那里去集成一下这个ui。
首先我们去到web目录里执行命令
pnpx shadcn-ui@latest init
按上图所示先选择类型,然后我们引入一下button组件试一下,我们使用pmpm dlx 命令可以在web的项目目录下载button组件到web/src/components
然后修改一下app/page.tsx文件,我们直接使用button组件看看
import {Button} from "web/src/components/ui/button"; export default function Home() { return ( <main className="flex min-h-screen flex-col items-center justify-between p-24"> <Button>测试一波</Button> </main> ) }
看到这个页面证明我们的shadcn/ui组件已经引入成功,现在我们尝试一下修改主题,打开官网,点击theme这个选项卡。这里我就不去操作了,直接贴一个主题吧,把下面的代码拷贝到app/globals.css文件上
@layer base { :root { --background: 0 0% 100%; --foreground: 240 10% 3.9%; --card: 0 0% 100%; --card-foreground: 240 10% 3.9%; --popover: 0 0% 100%; --popover-foreground: 240 10% 3.9%; --primary: 346.8 77.2% 49.8%; --primary-foreground: 355.7 100% 97.3%; --secondary: 240 4.8% 95.9%; --secondary-foreground: 240 5.9% 10%; --muted: 240 4.8% 95.9%; --muted-foreground: 240 3.8% 46.1%; --accent: 240 4.8% 95.9%; --accent-foreground: 240 5.9% 10%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; --border: 240 5.9% 90%; --input: 240 5.9% 90%; --ring: 346.8 77.2% 49.8%; --radius: 0.3rem; } .dark { --background: 20 14.3% 4.1%; --foreground: 0 0% 95%; --card: 24 9.8% 10%; --card-foreground: 0 0% 95%; --popover: 0 0% 9%; --popover-foreground: 0 0% 95%; --primary: 346.8 77.2% 49.8%; --primary-foreground: 355.7 100% 97.3%; --secondary: 240 3.7% 15.9%; --secondary-foreground: 0 0% 98%; --muted: 0 0% 15%; --muted-foreground: 240 5% 64.9%; --accent: 12 6.5% 15.1%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 85.7% 97.3%; --border: 240 3.7% 15.9%; --input: 240 3.7% 15.9%; --ring: 346.8 77.2% 49.8%; } }
我们这个时候看到页面的按钮变成红色,则意味着主题已经应用上了,下面我们可以开发页面了
博客默认布局以及暗黑模式开发
代补匆页面
我们先把真个页面布局弄出来。然后第一步我们完成头部导航。我们第一步需要安装next-theme,然后创建layout组件,创建header组件,创建暗黑模式切换组件(modeToogle),切换组件我们使用点击切换,所以需要使用到了shadcn/ui 里面的dropdown-menu组件。
# 切换到web目录,首先添加next-theme包 pnpm install next-themes -S # 然后添加shadcn/ui 的 dropdown-menu pnpm dlx shadcn-ui@latest add dropdown-menu
// web/src/components/layout/default.tsx import React, { CSSProperties, PropsWithChildren } from 'react'; import { clsx } from 'clsx'; import { Header } from 'web/src/components/ui/header'; export interface IDefaultLayoutProps extends PropsWithChildren { className?: string; headerPosition?: CSSProperties['position']; } export const DefaultLayout: React.FC<IDefaultLayoutProps> = ({ headerPosition = 'relative', className, children, }) => { return ( <div className={clsx( 'full flex flex-col min-h-screen relative', className, 'default-layout', )} > <div style={{ position: headerPosition }} className="z-10 w-full"> <Header></Header> </div> <main className="flex flex-col flex-1 container z-20 min-h-0 min-w-0 basis-auto w-full h-full"> {children} </main> <footer className="flex-shrink-0 z-10 "> <div className="container flex justify-center items-center py-4 text-base"> footer </div> </footer> </div> ); };
// web/src/components/ui/header.tsx 'use client'; import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from 'web/src/lib/utils'; import Link from 'next/link'; import { ModeToggle } from 'web/src/components/ui/modeToggle'; const headerVariants = cva('flex w-full', { variants: { variant: { default: 'text-2xl', }, }, defaultVariants: { variant: 'default', }, }); export interface HeaderProps extends React.BaseHTMLAttributes<HTMLDivElement>, VariantProps<typeof headerVariants> {} const Header: React.FC<HeaderProps> = ({ className, variant, ...props }) => { return ( <div className={cn(headerVariants({ variant, className }))} {...props}> <div className="container mx-auto h-14 flex items-center justify-between"> <div> My First <span className="text-primary dark:text-pink-300">BLOG</span> </div> <div className="flex text-base gap-6 items-center"> <Link className="text-primary hover:text-primary/90" href="/"> 首页 </Link> <Link className="text-primary hover:text-primary/90" href="/posts"> 博客 </Link> <ModeToggle /> </div> </div> </div> ); }; export { Header, headerVariants };
'use client'; import * as React from 'react'; import { MoonIcon, SunIcon } from '@radix-ui/react-icons'; import { useTheme } from 'next-themes'; import { Button } from 'web/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from 'web/components/ui/dropdown-menu'; export function ModeToggle() { const { setTheme } = useTheme(); return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="icon"> <SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <span className="sr-only">Toggle theme</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onClick={() => setTheme('light')}> Light </DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('dark')}> Dark </DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('system')}> System </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); }
在src/app/layout.tsx上添加我们的layout
点击主题切换的时候我们会发现没有作用,这个时候我们要往layout.tsx添加一个theme-provier
// web/src/components/provider/theme-provider.tsx 'use client'; import * as React from 'react'; import { ThemeProvider as NextThemesProvider } from 'next-themes'; import { type ThemeProviderProps } from 'next-themes/dist/types'; export function ThemeProvider({ children, ...props }: ThemeProviderProps) { return <NextThemesProvider {...props}>{children}</NextThemesProvider>; }
// web/src/web/layuot.tsx import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; import { DefaultLayout } from 'web/src/components/layout/default'; import { ThemeProvider } from 'web/src/components/provider/theme-provider'; const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { title: 'Create Next App', description: 'Generated by create next app', }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html suppressHydrationWarning> <body> <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange > <DefaultLayout>{children}</DefaultLayout> </ThemeProvider> </body> </html> ); }
现在我们再点击一下切换dark主题就可以切换成暗黑模式了,至此ui组件以及主题切换也完成了。下面我们可以开始完成主要页面的开发了,因为nextjs已经集成了路由模式,创建文件就能创建页面
下面我们先看一下我们的首页设置成什么样子,然后分析一下页面需要什么组件
从图中可以看出,我们需要新增一个article-long-item.tsx组件,并且安装dayjs来显示发布时间到现在的时间。那我们下面看一下详细代码。
// 安装dayjs pnpm install dayjs -S
创建overlayLink组件
# overlayLink组件,因为MDN规范里,不推荐使用<a><a><a/><a/>模式,nextjs直接就会报错了,我们需要添加一个overlayLink组件,让这个卡片全局可以点击跳转到文章详情 import Link from 'next/link'; import { LinkProps } from 'next/dist/client/link'; import * as React from 'react'; import { cn } from 'web/src/lib/utils'; type OverlayLinkProps = LinkProps & { className?: string; children: React.ReactNode; }; export const OverlayLink: React.FC<OverlayLinkProps> = ({ children, className, ...rest }) => { return ( <Link {...rest} className={cn('plumjs-linkbox__overlay', className)}> {children} </Link> ); }; export const LinkBox: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className, ...rest }) => { return ( <div {...rest} className={cn('plumjs-linkbox', className)}> {children} </div> ); };
在components/ui创建article-long-item.tsx组件
'use client'; import { LinkBox, OverlayLink } from 'web/src/components/ui/overlayLink'; import { AspectRatio } from 'web/src/components/ui/aspect-ratio'; import Image from 'next/image'; import { addImageDomain } from 'web/src/lib/utils'; import Link from 'next/link'; import React from 'react'; import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; dayjs.locale('zh-cn'); import relativeTime from 'dayjs/plugin/relativeTime'; import { IArticleDatum } from 'web/types'; dayjs.extend(relativeTime); export const ArticleLongItem: React.FC<IArticleDatum> = (props) => { return ( <LinkBox key={props.id} className="group shadow dark:shadow-primary text-md" > <div className="flex justify-items-stretch p-0 cursor-pointer"> <div className="flex-shrink-0 basis-[300px] 2xl:basis-[400px]"> <AspectRatio ratio={16 / 9} className="overflow-hidden "> <Image className="object-cover w-full h-full transition ease-in-out duration-500 group-hover:scale-[1.2]" width={props.attributes.cover.data.attributes.width} height={props.attributes.cover.data.attributes.height} src={addImageDomain(props.attributes.cover.data.attributes.url)} alt={props.attributes.cover.data.attributes.name} /> </AspectRatio> </div> <div className="ml-4 flex-1 py-4 flex flex-col justify-between"> <div className="flex flex-col gap-2 "> <OverlayLink href={`/posts/${props.id}`}> <div className="line-clamp-2 group-hover:text-primary text-2xl font-medium"> {props.attributes.title} </div> </OverlayLink> <div className="line-clamp-2 text-[#999]"> {props.attributes.desc} </div> </div> <div className="flex justify-between pr-4 items-center text-[#999] text-xs"> <div>{dayjs(props.attributes.createdAt).fromNow()}</div> {props.attributes?.tags?.data && props.attributes?.tags?.data.length ? ( <div className="flex gap-4"> {props.attributes.tags.data.map((tagItem: any) => { return ( <div className="px-2 shadow py-1 bg-primary rounded-2xl text-secondary transition hover:bg-primary/90 hover:text-secondary/50" key={tagItem.id} > <Link href={`/posts/tag/${tagItem.id}`}> {tagItem.attributes.name} </Link> </div> ); })} </div> ) : null} </div> </div> </div> </LinkBox> ); };
现在我们开始对接strapi的接口,我们开始完成首页,我们的接口域名跟图片域名都是可以配置的,因此我们创建一个.env文件,重启服务
APIBASEURL=http://127.0.0.1:1337 IMAGEDOMAIN=http://127.0.0.1:1337
修改next.config.js文件,创建global.d.ts
/** @type {import('next').NextConfig} */ const nextConfig = { env: { IMAGEDOMAIN: process.env.IMAGEDOMAIN, APIBASEURL: process.env.APIBASEURL, }, eslint: { ignoreDuringBuilds: true, }, images: { remotePatterns: [ { protocol: 'http', hostname: '127.0.0.1', port: '1337', }, ], }, }; module.exports = nextConfig;
declare namespace NodeJS { export interface ProcessOptions { browser: boolean; } export interface Global { PORT: any; } export interface ProcessEnv { APIBASEURL: string; IMAGEDOMAIN: string; } }
然后修改page.tsx文件,我们的首页就制作完成
接下来我们继续创建博客列表页和博客详情页,博客列表
// web/src/components/ui/article-short-item.tsx import { LinkBox, OverlayLink } from 'web/src/components/ui/overlayLink'; import { AspectRatio } from 'web/src/components/ui/aspect-ratio'; import Image from 'next/image'; import { addImageDomain } from 'web/src/lib/utils'; import Link from 'next/link'; import React from 'react'; import { IArticleDatum } from 'web/types'; import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; dayjs.locale('zh-cn'); import relativeTime from 'dayjs/plugin/relativeTime'; dayjs.extend(relativeTime); export const ArticleShortItem: React.FC<IArticleDatum> = (props) => { return ( <LinkBox key={props.id} className="group shadow dark:shadow-primary rounded-2xl overflow-hidden text-md" > <div className="flex justify-items-stretch p-0 cursor-pointer flex-col"> <AspectRatio ratio={16 / 9} className="overflow-hidden rounded-2xl"> <Image className="object-cover w-full h-full transition ease-in-out duration-500 group-hover:scale-[1.2] " width={props.attributes.cover.data.attributes.width} height={props.attributes.cover.data.attributes.height} src={addImageDomain(props.attributes.cover.data.attributes.url)} alt={props.attributes.cover.data.attributes.name} /> </AspectRatio> <div className="ml-4 flex-1 py-4 flex flex-col justify-between"> <div className="flex flex-col gap-2 "> <OverlayLink className="flex-shrink-0" href={`/posts/${props.id}`}> <div className="line-clamp-2 group-hover:text-primary text-2xl font-medium"> {props.attributes.title} </div> </OverlayLink> <div className="line-clamp-2 text-[#999] group-hover:text-[#999/90]"> {props.attributes.desc} </div> </div> <div className="flex justify-between pr-4 items-center text-[#999] text-xs"> <div>{dayjs(props.attributes.createdAt).fromNow()}</div> {props.attributes?.tags?.data && props.attributes?.tags?.data.length ? ( <div className="flex gap-4"> {props.attributes.tags.data.map((tagItem) => { return ( <div className="px-2 shadow py-1 bg-primary rounded-2xl text-secondary transition hover:bg-primary/90 hover:text-secondary/50" key={tagItem.id} > <Link href={`/posts/tag/${tagItem.id}`}> {tagItem.attributes.name} </Link> </div> ); })} </div> ) : null} </div> </div> </div> </LinkBox> ); };
// web/src/app/posts/page.tsx import { addApiDomain } from 'web/src/lib/utils'; import { ArticleShortItem } from 'web/src/components/ui/article-short-item'; import { Metadata } from 'next'; import { IArticleData } from 'web/types'; export const metadata: Metadata = { title: '博客列表', description: '博客列表', }; const getData: () => Promise<IArticleData> = async () => { const result = await fetch( addApiDomain('/api/articles?populate=*&sort[0]=publishedAt:desc'), ); return await result.json(); }; const Home = async () => { const result = await getData(); return ( <> <div className="h-20 flex justify-center items-center"> <div className="px-24 text-[40px] bg-gradient-to-r from-primary to-secondary text-transparent bg-clip-text"> My First BLOG </div> </div> <div className="mt-8 grid grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-3"> {result.data.map((item) => { return <ArticleShortItem key={item.id} {...item} />; })} </div> </> ); }; export default Home;
// 博客详情页 // web/src/app/posts/[id]/page.tsx import { IArticleItemData } from 'web/types'; import { Metadata } from 'next'; import { addApiDomain } from 'web/src/lib/utils'; import { redirect } from 'next/navigation'; export interface IPageParams { params: { id: string; }; } export const generateMetadata = async ({ params }: IPageParams) => { const resp = await getData(params?.id || ''); const { data: { attributes }, } = resp; const metadata: Metadata = { title: attributes.title, description: attributes.desc, }; return metadata; }; const getData: (id: string) => Promise<IArticleItemData> = async ( id: string, ) => { const result = await fetch(addApiDomain(`/api/articles/${id}?populate=*`)); if (result.status !== 200) { return redirect('/404'); } return await result.json(); }; const PostPage = async ({ params }: IPageParams) => { let result = await getData(params.id); return ( <> <div className="h-20 flex justify-center items-center"> <div className="px-24 text-[40px] bg-gradient-to-r from-primary to-secondary text-transparent bg-clip-text"> {result.data.attributes.title} </div> </div> <div className="w-full max-w-full prose lg:prose-lg h-full prose-img:rounded-xl prose-img:w-full prose-img:object-cover" dangerouslySetInnerHTML={{ __html: result.data.attributes.content }} ></div> </> ); }; export default PostPage;
// web/src/app/posts/tag/[id]/page.tsx // 页面是此标签下的所有文章 import { Metadata } from 'next'; import { addApiDomain } from 'web/src/lib/utils'; import { ArticleShortItem } from 'web/src/components/ui/article-short-item'; import { ITagArticleItemData } from 'web/types'; export interface IPageParams { params: { id: string; }; } export const generateMetadata = async ({ params }: IPageParams) => { const resp = await getData(params?.id || ''); const { data: { attributes }, } = resp; const metadata: Metadata = { title: attributes.name, description: '标签列表', }; return metadata; }; const getData: (id: string) => Promise<ITagArticleItemData> = async (id) => { const result = await fetch( addApiDomain( `/api/tags/${id}?populate[articles][populate][0]=cover&populate[articles][populate][1]=tags`, ), ); return await result.json(); }; const TagPostPage = async ({ params }: IPageParams) => { let result = await getData(params.id); return ( <> <div className="h-20 flex justify-center items-center"> <div className="px-24 text-[40px] bg-gradient-to-r from-primary to-secondary text-transparent bg-clip-text"> {result.data.attributes.name} </div> </div> <div className="mt-8 grid grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-3"> {result.data.attributes.articles.data.map((item) => { return <ArticleShortItem key={item.id} {...item} />; })} </div> </> ); }; export default TagPostPage;
至此我们的博客网站已经开发完成,可以愉快的添加文章了
项目链接