🐬

生成AI時代のNext.jsアプリケーション開発実践

2024/09/23に公開

この記事は?

前回のアップデートからだいぶ時間が空いてしまいましたが、著者がこの1年Next.js周りの開発で着目していたのは生成AIによってスピードアップした開発ができないか どうか、ということです。

著者が思う大きなアップデートはv0を使ってのNext.jsの開発が盛んになってきた ことです。

v0を使ったコード生成

最も簡単なアプリケーションとして、掲示板の開発を考えてみましょう。
v0に対しては以下のような指示を与えてみます。

create a bulletin board site. Frontend should include header, and thread title, each thread include user name, comment

この結果、生成されるファイルは以下の通りです:

・prismaに関するファイル群

prisma/schama.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Thread {
  id        Int       @id @default(autoincrement())
  title     String
  createdAt DateTime  @default(now())
  comments  Comment[]
}

model Comment {
  id        Int      @id @default(autoincrement())
  content   String
  userName  String
  createdAt DateTime @default(now())
  thread    Thread   @relation(fields: [threadId], references: [id])
  threadId  Int
}
lib/prisma.ts
import { PrismaClient } from '@prisma/client'

let prisma: PrismaClient

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient()
} else {
  if (!global.prisma) {
    global.prisma = new PrismaClient()
  }
  prisma = global.prisma
}

export default prisma

・CRUD処理

app/api/threads/route.ts
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'

export async function GET() {
  try {
    const threads = await prisma.thread.findMany({
      include: { comments: true },
      orderBy: { createdAt: 'desc' },
    })
    return NextResponse.json(threads)
  } catch (error) {
    return NextResponse.json({ error: 'Failed to fetch threads' }, { status: 500 })
  }
}

export async function POST(request: Request) {
  try {
    const body = await request.json()
    const thread = await prisma.thread.create({
      data: {
        title: body.title,
      },
    })
    return NextResponse.json(thread)
  } catch (error) {
    return NextResponse.json({ error: 'Failed to create thread' }, { status: 500 })
  }
}
app/api/comments/route.ts
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'

export async function POST(request: Request) {
  try {
    const body = await request.json()
    const comment = await prisma.comment.create({
      data: {
        content: body.content,
        userName: body.userName,
        threadId: body.threadId,
      },
    })
    return NextResponse.json(comment)
  } catch (error) {
    return NextResponse.json({ error: 'Failed to create comment' }, { status: 500 })
  }
}

・UI部分

app/page.tsx
'use client'

import { useState, useEffect } from 'react'
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"

interface Thread {
  id: number
  title: string
  comments: Comment[]
}

interface Comment {
  id: number
  content: string
  userName: string
}

export default function Home() {
  const [threads, setThreads] = useState<Thread[]>([])
  const [newThreadTitle, setNewThreadTitle] = useState('')
  const [newCommentContent, setNewCommentContent] = useState('')
  const [newCommentUserName, setNewCommentUserName] = useState('')
  const [activeThreadId, setActiveThreadId] = useState<number | null>(null)

  useEffect(() => {
    fetchThreads()
  }, [])

  const fetchThreads = async () => {
    const response = await fetch('/api/threads')
    const data = await response.json()
    setThreads(data)
  }

  const createThread = async (e: React.FormEvent) => {
    e.preventDefault()
    const response = await fetch('/api/threads', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: newThreadTitle }),
    })
    if (response.ok) {
      setNewThreadTitle('')
      fetchThreads()
    }
  }

  const createComment = async (e: React.FormEvent) => {
    e.preventDefault()
    if (activeThreadId === null) return
    const response = await fetch('/api/comments', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        content: newCommentContent,
        userName: newCommentUserName,
        threadId: activeThreadId,
      }),
    })
    if (response.ok) {
      setNewCommentContent('')
      setNewCommentUserName('')
      fetchThreads()
    }
  }

  return (
    <div className="container mx-auto p-4">
      <header className="bg-primary text-primary-foreground py-4 px-6 mb-6">
        <h1 className="text-2xl font-bold">Bulletin Board</h1>
      </header>

      <form onSubmit={createThread} className="mb-6">
        <Input
          type="text"
          placeholder="New Thread Title"
          value={newThreadTitle}
          onChange={(e) => setNewThreadTitle(e.target.value)}
          className="mb-2"
        />
        <Button type="submit">Create Thread</Button>
      </form>

      {threads.map((thread) => (
        <div key={thread.id} className="mb-6 p-4 border rounded">
          <h2 className="text-xl font-semibold mb-2">{thread.title}</h2>
          {thread.comments.map((comment) => (
            <div key={comment.id} className="mb-2 p-2 bg-muted">
              <p className="font-medium">{comment.id}. {comment.userName}</p>
              <p className='bg-white'>{comment.content}</p>
            </div>
          ))}
          {activeThreadId === thread.id && (
            <form onSubmit={createComment} className="mt-4">
              <Input
                type="text"
                placeholder="Your Name"
                value={newCommentUserName}
                onChange={(e) => setNewCommentUserName(e.target.value)}
                className="mb-2"
              />
              <Textarea
                placeholder="Your Comment"
                value={newCommentContent}
                onChange={(e) => setNewCommentContent(e.target.value)}
                className="mb-2"
              />
              <Button type="submit">Post Comment</Button>
            </form>
          )}
          {activeThreadId !== thread.id && (
            <Button onClick={() => setActiveThreadId(thread.id)}>Add Comment</Button>
          )}
        </div>
      ))}
    </div>
  )
}

・成果物

上記のコードをそのままコピーしてみると、新しくスレッドを作成し、名前・コメントをセットで投稿するところまでできるアプリケーションが完成した。以下動かしてみた結果。

このように、v0はNext.jsの開発において使えるレベルまでに成長していると言えます。現場ではスピード感を持って開発しないといけないので、多くのコードを生成してくれるのは非常に役に立ちます。一方、設計の部分についてはエンジニアで判断をできる能力が求められると感じます。そこで、Next.jsで議論になりやすい設計の部分についていくつかテーマを取り上げてみましょう。

cacheについて

著者はMAU1900万/ PV数億規模のアプリケーション開発、負荷対策周りまで経験がありますが、そういったアプリの負荷を支えるにはインフラ側の冗長化に加え、バックエンド側のKVS利用など古典的な対策によってサーバー負荷を抑えることで、十分負荷対策をすることが可能です。

それでもNext.jsがキャッシュを押し進めてきた背景として、パフォーマンス向上とコスト削減 のためと公式も謳っている所です。つまり、速度周りを改善したり主な目的としてUX向上のため にNext.jsがキャッシュを押し進めていると考えるのが賢明でしょう。Amazonの調査でも、サイト表示が0.1秒遅くなると、売り上げが1%減少し、1秒高速化すると10%の売上が向上する という結果があります。

(※ 本記事では触れませんが、Next.jsに実装されたキャッシュのうち、破壊的な変更としてv15からはData CacheとRouter Cacheがデフォルトで無効化されるようになります。)

Server Actionsについて

前回、以下の記事でServer Actionsの使用を推奨する記事を書きました。

https://zenn.dev/rio_dev/articles/eb69fae0557f20

本章では、Server Actionsの是非について 書いていきたいと思います。

Server ActionsはProgressive Enhancementに寄与しますが、一方でNext.jsに密に依存しているため、捨てにくく、テストを書くことが難しい所があります。

一方、API Routesでは他のフロントエンド(Web、モバイルアプリ等)からも利用できるRESTfulなAPIとして設計できるため、捨てやすく、確立された方法でユニットテストを記述することができます。

捨てやすさと品質を保つ観点から、API RoutesでAPIを作ることはまだまだ現役だと言えるでしょうし、著者の感覚として上述の課題を解決できない限り、完全にServer Actionsに寄せるのは懸念を感じます。

終わりに

この記事では最近のNext.js周りの開発事情について検討してきました。
著者は、0-1のスタートアップからメガベンチャーでの開発まで経験があります。
仕事のご依頼など歓迎ですので、フォームまでご連絡をお願いします。

https://tijuana-theta.vercel.app/

Discussion