Next.js on Vercelを試す(App Router, Prisma, Vercel Postgres)
タイムラインがCloudflare WorkersやらVercelやらで賑わっている。そろそろ試しておく…。
VercelはGitHubを連携させておけば、PRのプレビュー、自動デプロイやらがセッティングされた状態で開始出来て便利。
Next.jsはv13.4でApp RouterというのがStableとなり、デフォルトになっているようだ。
ルーティングはこれまではpagesディレクトリが司っていたけど、今はappディレクトリになっている。ルーティングの仕様も変更されているようす。ファイル・ディレクトリ名に一定の規約があり(特定の文字列が特別な意味を持つ)、これは覚えなきゃならないけど難しくない。
App Routerでは、プロジェクトルートのapp
ディレクトリが「ルート」になる。app
ディレクトリ以下にもディレクトリを作成出来て、その構造によりルーティング出来るというわけ。これはPages Routerの時代と同じだが、App Routerでは、page.tsx
という名前のコンポーネントがそのディレクトリの「page」として扱われるようになる(他にも既定のファイル名がいくつかある)。
app
├── favicon.ico
├── globals.css
├── layout.tsx
├── map
│ └── [id]
│ ├── page.tsx
│ └── styledMap.tsx
└── page.tsx
この場合、/
と/map/:id
というパスが作成される(後者をdynamic routingという、Pages Routerにもあったけど)。
規約がありすぎると辛いけど、Next.jsはこの辺の塩梅がとても良いと思った。
いつからかは知らないけど、Next.jsはデフォルトでSSRをするようになっているらしい。
(このへん、普段はVue3でCSRしかほぼしていないので隔世の感がある、これはいけない)
デフォルトであるSSRでは、クライアント側でJavaScriptを実行するuseEffectなどのフックが使えないため、明示的に宣言する必要がある(CSRしたいコンポーネントには、そのファイルの冒頭で'use client';
と唱える)。
ただし、CSRコンポーネントからSSRコンポーネントは使えないとかいう制限があるので、設計には注意が必要。CSRは末端ですれば困らないし、このために無理な設計になることはないと思う。
以下に沿ってVercel Postgresを作成+Prisma連携
すっごい簡単
いや、DBを作成すること自体はAWSでも簡単だし、Next.jsからも繋げられるので、別に必ずしもVercel Postgresである必要はない。
しかしVercel Postgresだと、そのconnection stringなどがVercel上で管理される環境変数群に自動的に追加されるのがとても良いデヴェロッパーエクスペリエンス
Prismaでは下記のようなモデルを書いた(位置情報屋さんなので地図データを保存するモデル)
generator client {
provider = "prisma-client-js"
previewFeatures = ["jsonProtocol"]
}
datasource db {
provider = "postgresql"
url = env("POSTGRES_PRISMA_URL")
directUrl = env("POSTGRES_URL_NON_POOLING")
shadowDatabaseUrl = env("POSTGRES_URL_NON_POOLING")
}
model StyledMap {
id String @id @default(uuid())
author String
title String
style Json
}
下記はDBのStyledMapを全て取得して、リンクを作成するコンポーネント。Async Functionとして書くことで、関数コンポーネント内でデータフェッチが可能となる。この処理自体はサーバーサイドで実行され、HTMLがクライアントへレスポンスされる。
export const revalidate = 10;
import { prisma } from '../prisma';
export default async function Home() {
const data = await prisma.styledMap.findMany();
return (
<main>
<div>
{data.map((item) => {
return (
<div>
<a href={`/map/${item.id}`} key={item.id}>
{item.title}
</a>
</div>
);
})}
</div>
</main>
);
}
PHPの再来…なんて言われているけど、バックエンドコードとUIがシームレスなので、ずいぶん開発しやすくなっているのではないか。TypeScriptを書けるフロントエンドエンジニアがフルスタックアプリケーションを開発出来るようになると思う(フロントエンドエンジニアとは)。
メモ
Vercel Postgresの宣伝ツイートとして、sql関数を使ったサンプルコードが紹介されていて、話題になっていた。正直(関数でラップしているにせよ)生SQLを書くのは厳しいのでは〜と思う。エッジで動くORMが求められていると思う(エッジでのNode.js互換が進んでいる気配はする)。
Next.jsサーバーで動作するAPIを書く
おそらく、これはいまだに/pages
以下に書く必要がある。
StyledMapのCRUDを書いてみる。検証用の割とひどい実装なので参考にしないでください。
たとえば/pages/api/styledMap/create.ts
と置いとくと、/api/styledMap/create
というエンドポイントでAPIが生える。
Create
import { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
const { title, author } = request.body;
try {
const data = await prisma.styledMap.create({
data: {
title,
author,
style: JSON.stringify({
version: 8,
sources: {
'simple-tiles': {
type: 'raster',
tiles: [
'https://cyberjapandata.gsi.go.jp/xyz/dem_png/{z}/{x}/{y}.png',
],
tileSize: 256,
},
},
layers: [
{
id: 'simple-tiles',
type: 'raster',
source: 'simple-tiles',
minzoom: 0,
},
],
}),
},
});
return response.status(200).json({ data });
} catch (error) {
return response.status(500).json({ error });
}
}
Read
import { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
try {
const data = await prisma.styledMap.findMany();
return response.status(200).json({ data });
} catch (error) {
return response.status(500).json({ error });
}
}
Update
import { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
const { id, title, style } = request.body;
const data = await prisma.styledMap.findFirst({
where: {
id,
},
});
if (data === null) return response.status(404).json({ error: 'Not Found' });
try {
const updatedData = await prisma.styledMap.update({
where: {
id,
},
data: {
title: title || data.title,
style: style || data.style,
},
});
return response.status(200).json({ updatedData });
} catch (error) {
return response.status(500).json({ error });
}
}
Delete
import { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
const { id } = request.body;
try {
const data = await prisma.styledMap.delete({
where: {
id,
},
});
return response.status(200).json({ data });
} catch (error) {
return response.status(500).json({ error });
}
}
CSRコンポーネントは末端に配置する話
私がよく作る地図ライブラリは、既存のDiv要素に対して操作を行う「副作用」が前提となっているものばかり。なのでCSRは避けられない。ただしNext.jsではSSRが基本で、SSRの子要素でCSRしても良いけど、逆はダメ。
たとえば下記のようなSSRコンポーネントを書く。
export const revalidate = 10;
import StyledMap from './styledMap';
import { prisma } from '../../../prisma';
import { StyleSpecification } from 'maplibre-gl';
export default async function Map({ params }: { params: any }) {
const { id } = params;
if (id === null) throw new Error('pathname is null');
const data = await prisma.styledMap.findUnique({
where: {
id,
},
});
if (data === null) throw new Error('mapStyleJson is null');
const mapStyle = data.style! as StyleSpecification;
return <StyledMap id={id} title={data.title} mapStyle={mapStyle} />;
}
StyledMap
がCSRコンポーネントである。下記のように書ける。
'use client';
import { useEffect, useRef, useState } from 'react';
import { Map as MapLibre, type StyleSpecification } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
const update = async (id: string, title: string, style: StyleSpecification) => {
await fetch('/api/styledMap/update', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id,
title,
style,
}),
});
};
export default function StyledMap({
id,
title,
mapStyle,
}: {
id: string;
title: string;
mapStyle: StyleSpecification;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const map = new MapLibre({
container: containerRef.current!,
style: mapStyle,
center: [0, 0],
zoom: 2,
});
return () => {
map.remove();
};
}, []);
const [newTitle, setNewTitle] = useState(title);
return (
<div>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
/>
<button
onClick={() => {
update(id, newTitle, mapStyle);
}}
>
Click me
</button>
<div className="h-screen w-full" ref={containerRef}></div>
</div>
);
}
このように、親側で、サーバーでフェッチしたデータを子要素であるCSRコンポーネントに流せばよい。