如何在一周内使用 Next.js 构建 SaaS

2025-05-28

如何在一周内使用 Next.js 构建 SaaS

更新:我写了一本关于使用 Next.js 构建全栈应用程序的小书https://fullstack-nextjs-in-action.taonan.lu/

我在 2021 年 4 月创建了Cusdis,这是一个开源、轻量级、隐私友好的 Disqus 替代品。我只用了一周时间就完成了它,并在HackerNews上引起了大量讨论,甚至登上了 HackerNews 的头版。

在这篇文章中,我想分享我是如何构建 Cusdis 的,以及我在这个项目中学到的东西。特别是一些使用 Next.js 高效构建应用程序的经验。

我怎么会想到这个主意

我在我的个人博客中使用了 Disqus。它运行良好,但有时加载整个评论线程会花费太多时间。有一天,我看到了一篇文章,讨论了 Disqus 的隐私问题和加载速度慢的问题。这让我萌生了构建一个 Disqus 替代方案来解决这些问题的想法。我把它命名为 Disqus 的反面——Cusdis。

系统设计

我认为一个最简约的评论系统至少包含三个部分:

  1. 一个数据库,用于存储线程信息、评论。
  2. 可以嵌入到网站的前端小部件。
  3. 用于管理(批准、回复、删除)评论的仪表板。
  4. 为小部件提供 API 并作为仪表板后端的服务器。

数据库

在我看来(我不是数据库专家),SQLite 足以实现评论系统。但由于我想要一个托管服务,SQLite 并不适合无服务器环境和并发用例。PostgreSQL 虽然适合这种情况,但对于自托管用户来说过于复杂。最终我决定:

  • 对于自托管用户,默认使用 SQLite。他们无需设置 PostgreSQL 实例即可使用 Cusdis
  • 提供使用 PostgreSQL 的选项(适用于自托管用户和我们的托管服务)

了解如何在 Docker 中部署 Cusdis

在 Node.js 中,我选择Prisma来 CURD 数据库。这是因为 Prisma 使用优雅的 schema 语法来描述数据模型,例如:

model User {
  id String @id @default(uuid())
  username String
  password String
  bio String?

  posts Post[]

  createdAt DateTime @default(now())
}

model Post {
  id String
  title String
  userId String
  user User

  createdAt DateTime @default(now())
}
Enter fullscreen mode Exit fullscreen mode

即使你没有听说过 Prisma,你大概也可以通过这个模式文件了解数据库是什么样的。

运行时,Prisma 会生成一个 js 客户端来查询数据库npx prisma generate这个 js 客户端是经过格式化的,以便于你获取智能感知。

Prisma 智能感知

使用 Next.js 作为仪表板和 API 服务器

Next.js 是一个非常优秀的全栈应用构建框架。你无需设置前端和后端,Next.js 会帮你搞定一切。

在 Next.js 中使用 Prisma

在 Next.js 中使用 Prisma 与在 Node.js 中使用 Prisma 并无区别。唯一需要注意的是,在开发环境中,Next.js 在热重载时会清除缓存。New 函数PrismaClient会被多次初始化,这将引发警告:

warn(prisma-client) Already 10 Prisma Clients are actively running.
Enter fullscreen mode Exit fullscreen mode

为了解决这个问题,我创建了一个singleton助手来创建一个单例实例:

export const singleton = <T>(id: string, fn: () => T) => {
  if (process.env.NODE_ENV === 'production') {
    return fn()
  } else {
    if (!global[id]) {
      global[id] = fn()
    }
    return global[id] as T
  }
}
Enter fullscreen mode Exit fullscreen mode

此辅助函数将实例缓存在 上global,这在 Next.js 中不会清晰可见。我使用此辅助函数创建了一个单例prisma,它可以从任何地方导入,而不必担心重复的数据库连接。

export const prisma = singleton('prisma', () => {
  return new PrismaClient()
})
Enter fullscreen mode Exit fullscreen mode

在 API 路由中使用中间件

我使用next-connect来使用类似 connect 的中间件。Next.js 中的传统 API 路由处理程序如下所示:

import { NextApiRequest, NextApiResponse } from "next";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'GET') {
    // ...
  } else if (req.method === 'POST') {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

有了next-connect,它就变成:

import nc from "next-connect"

export default nc()
  .use(someMiddleware())
  .get(async (req, res) => {
    // ...
  })
  .post(async (req, res) => {
    // ...
  })
Enter fullscreen mode Exit fullscreen mode

因此我可以编写一个身份验证中间件,user在其中注入一个对象req

function auth(req, res, next) {
  const user = await getUser(req.cookies.token)
  req.user = user
  next()
}

export default nc()
  .use(auth)
  .post(async (req, res) => {
    console.log(req.user)
    // ...
  })
Enter fullscreen mode Exit fullscreen mode

处理 API 路由错误

我以前觉得 Next.js 的错误处理很困难。但next-connect现在它改变了一切。我可以用一个 catch-all 错误处理程序来处理 api 路由处理程序中的所有错误,onError例如:

// src/pages/api/foo.ts

const handler = nc({
  onError(err, req, res, next) {
    console.error(err)
    next()
  }
});

export handler
  .get(async (req, res) => {
    throw new Error('foo')
  })
Enter fullscreen mode Exit fullscreen mode

它使错误处理更加容易,因为我可以应用异常过滤器模式:

将统一的错误对象抛到任何地方并在一个地方处理它。

我选择@hapi/boom作为统一的 HTTP 异常对象。我可以Boom在 API 路由中抛出异常,并在以下位置进行处理onError

import * as Boom from '@hapi/boom'

export const apiHandler = () => nc({
  onError(err, req, res, next) {
    if (Boom.isBoom(e)) {
      res.status(e.output.payload.statusCode);
      res.json({
        error: e.output.payload.error,
        message: e.output.payload.message,
      });
    } else {
      res.status(500);
      res.json({
        message: "Unexpected error",
      });
      console.error(e);
      // unexcepted error, catch with Sentry, etc.
    }
  }
});

apiHandler()
  .get(async (req, res) => {
    const post = await getPost(req.query.postId)
    if (!post) {
      throw Boom.notFound(`Post not found`)
    }
  })
Enter fullscreen mode Exit fullscreen mode

我编写了一个apiHandler返回next-connect实例的方法。

在上面的例子中,我Boom.notFound在api路由处理程序中抛出了一个异常,这个异常将被捕获onError,并设置相应的http响应状态代码和消息。

一分钟内设置身份验证

在托管服务中,我不想实现自己的用户系统,因为这会很复杂。我更喜欢通过 OAuth 使用第三方登录。

next-auth是 Next.js 的身份验证解决方案。我按照Prisma 适配器指南操作,只需一分钟即可集成 GitHub 提供程序!我只需创建一个 GitHub OAuth 应用程序,并在 中设置 next-auth 提供程序即可src/pages/api/[...nextauth].ts

在 API 路由中,我可以直接调用getSession()来获取当前用户。还记得我们可以在 API 路由中使用中间件吗next-connect?以下是如何通过中间件保护 API 路由的:

// middleware
import { getSession }  from 'next-auth/client'
import * as Boom from '@hapi/boom'

export async function authGuard(req, res, next) {
  const user = await getSession({ req })

  if (!user) {
    throw Boom.forbidden('Please sign in first')
  }

  req.user = user

  next()
}
Enter fullscreen mode Exit fullscreen mode

然后,我可以authGuard在任何需要保护的 API 路由中使用这个中间件:


// src/pages/api/foo.ts

apiHandler()
  .use(authGuard)
  .get(async (req, res) => {
    console.log(req.user)
    res.json({})
  })
Enter fullscreen mode Exit fullscreen mode

安全的前端页面

如果未登录则无法看到整个页面,我会检查 中的会话getServerSideProps。如果sessionnull,则返回重定向到登录页面:

import { getSession } from 'next-auth/client'

export default PageOne() {
  return (
    <div>
      Not visible
    </div>
  )
}

export const getServerSideProps = (ctx) => {
  const session = await getSession({ req: ctx.req })

  if (!session) {
    return {
      redirect: {
        destination: '/api/auth/signin',
        permanent: false,
      }
    }
  }

  return {}
}
Enter fullscreen mode Exit fullscreen mode

评论小工具

Cusdis 的主要目标之一是轻量级。我为这个组件设定了一个包大小“预算”,即嵌入到用户网站的 SDK 以及 SDK 加载的资源必须小于 10kb。

在这种情况下, Svetle是最佳选择。它不附带运行时,而是将组件编译为原生 JavaScript。

令我惊讶的是,最终的全功能 SDK 只有 5kb(gzip 压缩后)。

pic.twitter.com/LgPli2ysdb

— Randy (@randyloop) 2021 年 4 月 28 日

小部件源代码

在第一个 SDK 版本中,SDK 会将小部件元素附加到用户的网站。起初效果不错。但问题是,用户网站的样式表会影响小部件组件中的样式。此外,我们必须在设置元素样式时为类名添加前缀,以防止我们的样式影响用户的网站。

最佳做法是将 Widget 组件放入 iframe 中。一切正常,除了一个问题:

如何根据内容高度自动设置iframe的高度?

iframe 的高度在创建时是固定的,无论其中的文档如何变化。为了使其高度自动设置为文档高度,我MutationObservable在 iframe 中使用了 来观察文档的任何变化,当发生更改时,它postMessage会将新的 通知给父窗口offsetHeight,然后父窗口会更改 iframe 的高度:

// in iframe

function requestResize() {
  window.parent.postMessage({
    event: 'resize',
    offsetHeight: document.documentElement.offsetHeight
  })
}

const resizeObserve = new MutationObserver(() => {
  requestResize()
})

resizeObserve.observe(target, {
  childList: true,
  subtree: true
})
Enter fullscreen mode Exit fullscreen mode
// in main window

window.addEventListener('message', e => {
  if (e.data.event === 'resize') { 
    iframe.style.height = `${e.data.offsetHeight}px`
  }
})
Enter fullscreen mode Exit fullscreen mode

我不确定这是否是最好的实现。如果你有更好的想法,请留言。

处理时区

日期

评论创建时,其创建日期将以当前 UTC 时间的形式保存在数据库中。世界各地的用户在小部件中看到的是相同的时间,但并非他们当地的时间。

例如,当来自中国(UTC+8)的用户看到2021-04-28 10:00(in UTC)时,他以为该评论是在北京时间创建的,2021-04-28 10:00但事实并非如此。该评论是在2021-04-28 18:00北京时间创建的。

这意味着当我们显示时间时,我们需要先根据用户的 UTC 偏移量进行转换。

幸运的是,浏览器中有一个 API getTimezoneOffset(),可以获取 UTC 偏移值。我通过 HTTP 请求头发送了这个值:

import axios from 'axios'

export const apiClient = axios.create({
  headers: {
    'x-timezone-offset': -new Date().getTimezoneOffset()
  }
});
Enter fullscreen mode Exit fullscreen mode

然后在服务器端,我们使用dayjs处理这个偏移量

const timezoneOffset = req.headers['x-timezone-offset']
const parsedCreatedAt = dayjs.utc(comment.createdAt).utcOffset(timezoneOffset).format('YYYY-MM-DD HH:mm')
Enter fullscreen mode Exit fullscreen mode

使用 Vite 捆绑 SDK

SDK 应该打包在一个 JS 文件中。我选择Vite是因为它速度很快,而且内置了 PostCSS 支持。换句话说,我不需要关心打包工具,我唯一使用的插件是rollup-plugin-svelte用于编译 Svelte 组件的:

// vite.config.js

module.exports = {
  build: {
    lib: {
      entry: path.resolve(__dirname, '..', 'widget', 'sdk.js'),
      name: 'cusdis',
    },
    outDir: path.resolve(__dirname, '..', 'widget', 'dist'),
  },
  plugins: [
    require('rollup-plugin-svelte')({
      emitCss: false,
    }),
  ],
}
Enter fullscreen mode Exit fullscreen mode

总结

我花了一周时间才在Cusdis上发布了第一个版本。现在大约有 50 个活跃网站使用 Cusdis 作为他们的评论系统。超过 10 人赞助了这个项目。所有这些工具的结合,让我能够如此快速地构建一个全栈 SaaS 项目:

我还使用这些出色的工具制作了一个样板,以便您可以在一分钟内启动您的下一个 SaaS 项目!

文章来源:https://dev.to/djyde/how-i-built-a-saas-with-next-js-in-a-week-3jli
PREV
5 种代码重构技术可改进您的代码,文章中仅有 4 种技术....?
NEXT
Introduction to Redis Hello Redis Example