Closed7

Next.js on Vercelを試す(App Router, Prisma, Vercel Postgres)

IGUCHI KanahiroIGUCHI Kanahiro

タイムラインがCloudflare WorkersやらVercelやらで賑わっている。そろそろ試しておく…。

VercelはGitHubを連携させておけば、PRのプレビュー、自動デプロイやらがセッティングされた状態で開始出来て便利。

IGUCHI KanahiroIGUCHI Kanahiro

Next.jsはv13.4でApp RouterというのがStableとなり、デフォルトになっているようだ。

https://zenn.dev/hidaka/articles/nextjs-app-router-blog

ルーティングはこれまでは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はこの辺の塩梅がとても良いと思った。

IGUCHI KanahiroIGUCHI Kanahiro

いつからかは知らないけど、Next.jsはデフォルトでSSRをするようになっているらしい。
(このへん、普段はVue3でCSRしかほぼしていないので隔世の感がある、これはいけない)

デフォルトであるSSRでは、クライアント側でJavaScriptを実行するuseEffectなどのフックが使えないため、明示的に宣言する必要がある(CSRしたいコンポーネントには、そのファイルの冒頭で'use client';と唱える)。

ただし、CSRコンポーネントからSSRコンポーネントは使えないとかいう制限があるので、設計には注意が必要。CSRは末端ですれば困らないし、このために無理な設計になることはないと思う。

IGUCHI KanahiroIGUCHI Kanahiro

以下に沿ってVercel Postgresを作成+Prisma連携

https://zenn.dev/chot/articles/8d991c79b739aa

すっごい簡単
いや、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
}

IGUCHI KanahiroIGUCHI Kanahiro

下記は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互換が進んでいる気配はする)。

IGUCHI KanahiroIGUCHI Kanahiro

Next.jsサーバーで動作するAPIを書く

おそらく、これはいまだに/pages以下に書く必要がある。
StyledMapのCRUDを書いてみる。検証用の割とひどい実装なので参考にしないでください。
たとえば/pages/api/styledMap/create.tsと置いとくと、/api/styledMap/createというエンドポイントでAPIが生える。

Create
/pages/api/styledMap/create.ts
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
/pages/api/styledMap/get.ts
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
/pages/api/update.ts
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
/pages/api/styledMap/delete.ts
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 });
    }
}

IGUCHI KanahiroIGUCHI Kanahiro

CSRコンポーネントは末端に配置する話

私がよく作る地図ライブラリは、既存のDiv要素に対して操作を行う「副作用」が前提となっているものばかり。なのでCSRは避けられない。ただしNext.jsではSSRが基本で、SSRの子要素でCSRしても良いけど、逆はダメ。

たとえば下記のようなSSRコンポーネントを書く。

/app/map/[id]/pages.tsx
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コンポーネントである。下記のように書ける。

/app/map/[id]/styledMap.tsx
'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コンポーネントに流せばよい。

このスクラップは2023/05/25にクローズされました