Docs / tech

Next.js との連携

2025-08-04 2025-08-31 |

前回は Sanity Studio の Vision を利用して、GROQ を利用したクエリーから必要となるコンテンツの取得ができました。今回は、Next.js から同様の処理を実行して Web のコンテンツとして利用する方法を紹介します。

Next.js のプロジェクト作成

まず最初に、Next.js のプロジェクトの作成を進めていきます。コマンドとしては以下のようになります。

npx create-next-app

プロジェクトの作成は以下のように進めました。

フォルダの構成は以下のようになります。

- day-one
  - apps
    - studio
    - web
  - data

続いて Next.js で Sanity と連携するためのパッケージをインストールします。Next.js のパスに移動して以下のコマンドを実行します。

npm install next-sanity

これで準備ができました。まだパッケージをインストールしただけですが、以下のコマンドで Next.js を起動します。

npm run dev

Next.js のテンプレートのページが立ち上がりました。

Sanity と連携する

続いて Next.js にコードを追加して、Sanity からデータを取得していきます。

イベント一覧の表示

Sanity からデータを取得するためのクライアントを apps/web/src/sanity/client.ts に作成します。

apps/web/src/sanity/client.ts
import { createClient } from "next-sanity";

export const client = createClient({
  projectId: "REPLACE_WITH_YOUR_PROJECT_ID",
  dataset: "production",
  apiVersion: "2025-07-09",
  useCdn: false,
});

projectId は apps/studio/sanity.cli.ts に記載されているので、参照して書き換えてください。

これだけでも十分ですが、コンテンツの変更を素早く反映させるためのモードを追加します。

apps/web/src/sanity/live.ts
import { defineLive } from "next-sanity";
import { client } from "@/sanity/client";

export const { sanityFetch, SanityLive } = defineLive({
  client: client.withConfig({ apiVersion: "vX" }),
});

これで、Sanity と連携するためのコードの準備ができました。

Next.js 側で利用できるように変更をしていきます。まずは apps/web/src/app/layout.tsx のファイルを以下のように書き換えます。

apps/web/src/app/layout.tsx
import { SanityLive } from "@/sanity/live";
import "./globals.css";

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className="bg-white min-h-screen">
        {children}
        <SanityLive />
      </body>
    </html>
  );
}

続いてトップページを以下のように書き換えます。なお、公式サイトに記載されていませんが、Event の interface を追加、ダークモードでも文字が見えるように少し変更をしています。

apps/web/src/app/page.tsx
import Link from "next/link";
import { defineQuery } from "next-sanity";
import { sanityFetch } from "@/sanity/live";

interface Event {
	_id: string;
	name: string;
	slug: {
		current: string;
	};
	date: string;
}

const EVENTS_QUERY = defineQuery(`*[
  _type == "event"
  && defined(slug.current)
  && date > now()
]|order(date asc){_id, name, slug, date}`);

export default async function IndexPage() {
	const { data: events } = await sanityFetch({ query: EVENTS_QUERY });

	return (
		<main className="flex bg-gray-100 min-h-screen flex-col p-24 gap-12 dark:bg-gray-800">
			<h1 className="text-4xl font-bold tracking-tighter">Events</h1>
			<ul className="grid grid-cols-1 gap-12 lg:grid-cols-2">
				{events.map((event: Event) => (
					<li
						className="bg-white dark:bg-gray-600 p-4 rounded-lg"
						key={event._id}
					>
						<Link
							className="hover:underline"
							href={`/events/${event?.slug?.current}`}
						>
							<h2 className="text-xl font-semibold">{event?.name}</h2>
							{event?.date && (
								<p className="text-gray-500 dark:text-gray-300">
									{new Date(event.date).toLocaleDateString()}
								</p>
							)}
						</Link>
					</li>
				))}
			</ul>
		</main>
	);
}

コードを確認していくと。

  • イベントデータを取得する GROQ のクエリ
  • sanityFetch を利用してデータを取得
  • 取得したデータを表示

結果として、イベントの情報が表示されるようになりました。

イベントのページ作成

一覧から各ページへのリンクを作成するために、events のフォルダの作成、その下に [slug] のフォルダを作成して、page.tsx を作成します。これにより slug を利用した URL を利用できるようになります。

src/app/events/[slug]/page.tsx
import { defineQuery, PortableText } from "next-sanity";
import Link from "next/link";
import { notFound } from "next/navigation";
import { sanityFetch } from "@/sanity/live";

const EVENT_QUERY = defineQuery(`*[
    _type == "event" &&
    slug.current == $slug
  ][0]{
  ...,
  "date": coalesce(date, now()),
  "doorsOpen": coalesce(doorsOpen, 0),
  headline->,
  venue->
}`);

export default async function EventPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { data: event } = await sanityFetch({
    query: EVENT_QUERY,
    params: await params,
  });
  if (!event) {
    notFound();
  }
  const {
    name,
    date,
    headline,
    details,
    eventType,
    doorsOpen,
    venue,
    tickets,
  } = event;

  const eventDate = new Date(date).toDateString();
  const eventTime = new Date(date).toLocaleTimeString();
  const doorsOpenTime = new Date(
    new Date(date).getTime() - doorsOpen * 60000
  ).toLocaleTimeString();

  return (
    <main className="container mx-auto grid gap-12 p-12">
      <div className="mb-4">
        <Link href="/">← Back to events</Link>
      </div>
      <div className="grid items-top gap-12 sm:grid-cols-2">
        {/* We'll update this image in the next lesson */}
        <img
          src={"https://placehold.co/550x310/png"}
          alt={name || "Event"}
          className="mx-auto aspect-video overflow-hidden rounded-xl object-cover object-center sm:w-full"
          height="310"
          width="550"
        />
        <div className="flex flex-col justify-center space-y-4">
          <div className="space-y-4">
            {eventType ? (
              <div className="inline-block rounded-lg bg-gray-100 px-3 py-1 text-sm dark:bg-gray-800 capitalize">
                {eventType.replace("-", " ")}
              </div>
            ) : null}
            {name ? (
              <h1 className="text-4xl font-bold tracking-tighter mb-8">
                {name}
              </h1>
            ) : null}
            {headline?.name ? (
              <dl className="grid grid-cols-2 gap-1 text-sm font-medium sm:gap-2 lg:text-base">
                <dd className="font-semibold">Artist</dd>
                <dt>{headline?.name}</dt>
              </dl>
            ) : null}
            <dl className="grid grid-cols-2 gap-1 text-sm font-medium sm:gap-2 lg:text-base">
              <dd className="font-semibold">Date</dd>
              <div>
                {eventDate && <dt>{eventDate}</dt>}
                {eventTime && <dt>{eventTime}</dt>}
              </div>
            </dl>
            {doorsOpenTime ? (
              <dl className="grid grid-cols-2 gap-1 text-sm font-medium sm:gap-2 lg:text-base">
                <dd className="font-semibold">Doors Open</dd>
                <div className="grid gap-1">
                  <dt>Doors Open</dt>
                  <dt>{doorsOpenTime}</dt>
                </div>
              </dl>
            ) : null}
            {venue?.name ? (
              <dl className="grid grid-cols-2 gap-1 text-sm font-medium sm:gap-2 lg:text-base">
                <div className="flex items-start">
                  <dd className="font-semibold">Venue</dd>
                </div>
                <div className="grid gap-1">
                  <dt>{venue.name}</dt>
                </div>
              </dl>
            ) : null}
          </div>
          {details && details.length > 0 && (
            <div className="prose max-w-none">
              <PortableText value={details} />
            </div>
          )}
          {tickets && (
            <a
              className="flex items-center justify-center rounded-md bg-blue-500 p-4 text-white"
              href={tickets}
            >
              Buy Tickets
            </a>
          )}
        </div>
      </div>
    </main>
  );
}

実際に追加したあと、トップページのイベントをクリックすると以下のようにイベントの詳細ページが表示されるようになりました。

このページで重要なコードは以下の部分になります。

apps/web/src/app/events/[slug]/page.tsx
const EVENT_QUERY = defineQuery(`*[
    _type == "event" &&
    slug.current == $slug
  ][0]{
  ...,
  "date": coalesce(date, now()),
  "doorsOpen": coalesce(doorsOpen, 0),
  headline->,
  venue->
}`);

export default async function EventPage({
	params,
}: {
	params: Promise<{ slug: string }>;
}) {
	const { data: event } = await sanityFetch({
		query: EVENT_QUERY,
		params: await params,
	});

以下のような動作になっています。

  • EVENT_QUERY には $slug という形で取得した slug を利用できるようにする
  • [0] ということで slug の値が一致した 1 つ目のデータを利用
  • sanityFetch では query と params を渡して、$slug が [slug] の値として動作するようにしている

取得したイベントのデータをページで簡単に利用できるように以下のように定義しています。

apps/web/src/app/events/[slug]/page.tsx
  const {
    name,
    date,
    headline,
    details,
    eventType,
    doorsOpen,
    venue,
    tickets,
  } = event;

sanityFetch にクエリと値を渡せば、結果となるデータの取得が決まり、ページの中で表示する値も作成できるようになります。

アセットの表示

画像を表示するためのパッケージとして @sanity/image-url が提供されています。これを Next.js のプロジェクトに追加します。

npm install @sanity/image-url

続いて Next.js で外部のドメインの画像を利用する際には、設定にドメインを登録する必要があります。remotePatterns で追加することになります。設定は以下の通りです。

apps/web/next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
	images: {
		remotePatterns: [
			{ protocol: "https", hostname: "cdn.sanity.io" },
			{ protocol: "https", hostname: "placehold.co" },
		],
	},
};

export default nextConfig;

続いて画像を表示するためのコードを追加します。

apps/web/src/sanity/image.ts
import imageUrlBuilder from "@sanity/image-url";
import type { SanityImageSource } from "@sanity/image-url/lib/types/types";
import { client } from "./client";

const builder = imageUrlBuilder(client);

export function urlFor(source: SanityImageSource) {
	return builder.image(source);
}

上記で追加した apps/web/src/sanity/image.ts を利用して、イベントのページ apps/web/src/app/events/[slug]/page.tsx のコードを更新します。

apps/web/src/app/events/[slug]/page.tsx
import { defineQuery, PortableText } from "next-sanity";
import Link from "next/link";
import { notFound } from "next/navigation";
import Image from "next/image";

import { sanityFetch } from "@/sanity/live";
import { urlFor } from "@/sanity/image";

const EVENT_QUERY = defineQuery(`*[
    _type == "event" &&
    slug.current == $slug
  ][0]{
  ...,
  "date": coalesce(date, now()),
  "doorsOpen": coalesce(doorsOpen, 0),
  headline->,
  venue->
}`);

export default async function EventPage({
	params,
}: {
	params: Promise<{ slug: string }>;
}) {
	const { data: event } = await sanityFetch({
		query: EVENT_QUERY,
		params: await params,
	});
	if (!event) {
		notFound();
	}
	const {
		name,
		date,
		headline,
		details,
		eventType,
		doorsOpen,
		venue,
		tickets,
	} = event;

	const eventDate = new Date(date).toDateString();
	const eventTime = new Date(date).toLocaleTimeString();
	const doorsOpenTime = new Date(
		new Date(date).getTime() - doorsOpen * 60000
	).toLocaleTimeString();

	const imageUrl = headline?.photo
		? urlFor(headline.photo)
				.height(310)
				.width(550)
				.quality(80)
				.auto("format")
				.url()
		: "https://placehold.co/550x310/png";

	return (
		<main className="container mx-auto grid gap-12 p-12">
			<div className="mb-4">
				<Link href="/">← Back to events</Link>
			</div>
			<div className="grid items-top gap-12 sm:grid-cols-2">
				<Image
					src={imageUrl}
					alt={headline?.name ?? "Headliner"}
					className="mx-auto aspect-video overflow-hidden rounded-xl object-cover object-center sm:w-full"
					height="310"
					width="550"
				/>
				<div className="flex flex-col justify-center space-y-4">
					<div className="space-y-4">
						{eventType ? (
							<div className="inline-block rounded-lg bg-gray-100 px-3 py-1 text-sm dark:bg-gray-800 capitalize">
								{eventType.replace("-", " ")}
							</div>
						) : null}
						{name ? (
							<h1 className="text-4xl font-bold tracking-tighter mb-8">
								{name}
							</h1>
						) : null}
						{headline?.name ? (
							<dl className="grid grid-cols-2 gap-1 text-sm font-medium sm:gap-2 lg:text-base">
								<dd className="font-semibold">Artist</dd>
								<dt>{headline?.name}</dt>
							</dl>
						) : null}
						<dl className="grid grid-cols-2 gap-1 text-sm font-medium sm:gap-2 lg:text-base">
							<dd className="font-semibold">Date</dd>
							<div>
								{eventDate && <dt>{eventDate}</dt>}
								{eventTime && <dt>{eventTime}</dt>}
							</div>
						</dl>
						{doorsOpenTime ? (
							<dl className="grid grid-cols-2 gap-1 text-sm font-medium sm:gap-2 lg:text-base">
								<dd className="font-semibold">Doors Open</dd>
								<div className="grid gap-1">
									<dt>Doors Open</dt>
									<dt>{doorsOpenTime}</dt>
								</div>
							</dl>
						) : null}
						{venue?.name ? (
							<dl className="grid grid-cols-2 gap-1 text-sm font-medium sm:gap-2 lg:text-base">
								<div className="flex items-start">
									<dd className="font-semibold">Venue</dd>
								</div>
								<div className="grid gap-1">
									<dt>{venue.name}</dt>
								</div>
							</dl>
						) : null}
					</div>
					{details && details.length > 0 && (
						<div className="prose max-w-none">
							<PortableText value={details} />
						</div>
					)}
					{tickets && (
						<a
							className="flex items-center justify-center rounded-md bg-blue-500 p-4 text-white"
							href={tickets}
						>
							Buy Tickets
						</a>
					)}
				</div>
			</div>
		</main>
	);
}

これで、イベントに画像が設定されている場合はその画像を、そうではない場合は Placeholder の画像を表示するようにしました。

イベントによっては画像が含まれていないアーティストもいるため、一覧から画像が指定されているアーティストのイベントを選択してください。

まとめ

今回は Next.js のプロジェクトの作成、そのプロジェクトに Sanity と連携するためのパッケージをインストールしたあと、パッケージで提供している機能を利用して、かつ GROQ を利用して Web ページを作成しました。また、画像に関する情報も併せて取得してページに表示することができました。

ここまでのコードは以下のリポジトリ / ブランチで参照できます。

Generationg types