Next.js + Storyblok + Vercelで多言語対応の会社サイトを作った全工程


目次

はじめに:なぜ自社サイトを内製したのか

今回、株式会社Neurosynchの立ち上げにあたり、会社のWebサイトをゼロから設計・開発しました。外注という選択肢は最初から頭にありませんでした。AIエージェント導入支援という事業の性質上「自分たちで最新技術を使って作れることを証明する」こと自体がブランディングになると分かっていました。

この記事では、設計の議論から始まりVercelへのデプロイ完了まで、実際に行った全工程を記録します。同じような構成でサイト制作を検討しているエンジニアの参考になれば幸いです。


技術スタックの選定

最初に検討したのは技術スタックです。要件は以下の通りでした。

  • 日本語・英語の多言語対応
  • コンテンツをCMSで管理(代表者メッセージ等を後から更新できるように)
  • パフォーマンスとSEOを両立
  • 将来的にAIエージェント連携やインタラクティブなデモを追加できる拡張性

検討の結果、以下の構成に決定しました。

フロントエンド:Next.js 16(App Router)
React系フレームワークの中で最もエコシステムが充実しており、サーバーコンポーネントによるパフォーマンス最適化とi18nサポートが標準で使えます。

CMS:Storyblok
Headless CMSの中でもビジュアルエディタが優れており、非エンジニアでもコンテンツ更新ができます。フィールドレベルのi18n対応が多言語サイトに適していました。

ホスティング:Vercel
Next.jsと同一開発元であり、デプロイパイプラインが最もシンプルに組めます。

モノレポ管理:Turborepo
コーポレートサイトとサービスサイトを同一リポジトリで管理するために採用しました。


ウェブサイト設計

開発に入る前に、どのサイトに何を置くかを設計しました。最終的な構成はこうなりました。

neurosynch.co.jp(コーポレート)
会社の信頼性を担保する場。紹介・口コミ経由で来た人が「ちゃんとした会社か」を確認するための情報を置きます。トップページ・サービス一覧・会社概要・採用・お問い合わせの5ページ構成です。

gyomu-system.com(ブログ)
当ブログです。SEO集客を目的としたWordPressブログという位置づけです。中小企業の社長向けにAI活用・業務効率化の情報を発信し、コーポレートサイトへ流入を作ります。

設計段階で特に議論になったのはドメイン構造です。ブログを独立ドメインで運営しながらコーポレートサイトのドメインパワーを活かす方法として、相互リンクとE-E-A-T(経験・専門性・権威性・信頼性)を意識した運営者情報の明示を採用しました。


多言語対応:next-intlの設定

日本語・英語対応のURL構造は以下のサブパス方式を選びました。

neurosynch.co.jp/ja/    ← 日本語
neurosynch.co.jp/en/    ← 英語

サブドメイン方式(ja.neurosynch.co.jp)と比較してSEO的に有利で、Next.js App RouterのルーティングとシームレスにIntegrationできるためです。

実装はnext-intl v4を使いました。App Routerとの組み合わせで注意すべき点が2つあります。

1. サーバーコンポーネントとクライアントコンポーネントでAPIが異なる

asyncなサーバーコンポーネント(page.tsx等)ではgetTranslations'use client'なクライアントコンポーネントではuseTranslationsを使います。混在させるとフックのルール違反エラーが発生します。

TypeScript
// サーバーコンポーネント(page.tsx)
import { getTranslations } from 'next-intl/server'
const t = await getTranslations('hero')

// クライアントコンポーネント
import { useTranslations } from 'next-intl'
const t = useTranslations('nav')

2. next.config.tsへのプラグイン設定が必須

next-intl v3以降はnext.config.tscreateNextIntlPluginを設定しないと動作しません。

TypeScript
import createNextIntlPlugin from 'next-intl/plugin'
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
export default withNextIntl({})

翻訳テキストの管理はJSONとStoryblokで役割を分担しました。ナビゲーションのラベルやボタンテキストなど頻繁に変わらない固定テキストはJSONで管理し、代表者メッセージやサービス説明など更新頻度が高いコンテンツはStoryblokで管理しています。


Storyblok連携:Headless CMSとの接続

Storyblokを選んだ理由

Headless CMSの選択肢はContentful・Sanity・microCMS・Prismicなど多数ありますが、Neurosynchのコーポレートサイトにはいくつかの特定の要件がありました。

ビジュアルエディタが使えること
将来的に非エンジニアがコンテンツを更新する可能性があります。Storyblokのビジュアルエディタはページの見た目を確認しながら編集できるため、技術的な知識がなくても更新できます。

フィールドレベルのi18n対応
多言語サイトでCMSを使う場合、言語ごとにStorを分けるか、フィールドレベルで翻訳を管理するかという選択があります。Storyblokはフィールド単位で翻訳を持てるため、管理が一元化できます。

Next.jsとの公式SDK
@storyblok/reactという公式SDKがあり、App Routerへの対応も進んでいます。


Storyblokのプロジェクト構成

Spaceの作成

StoryblokではプロジェクトのことをSpaceと呼びます。無料プランでは2つのSpaceが作れます。コーポレートサイト用に1つ作成しました。

Block Libraryの設計

StoryblokではページのコンテンツをBlockという単位で管理します。Blockには3種類あります。

Block Type用途
Content Type Blockページ全体のルートコンポーネント
Nestable BlockContent Typeの中に入れ子で使うパーツ
Universal Blockどちらでも使える汎用パーツ

今回はページごとにContent Type Blockを1つ作る構成にしました。

page_home      ← トップページ用
page_services  ← サービスページ用
page_company   ← 会社概要用
page_careers   ← 採用ページ用

フィールドの設計

各BlockにはFieldを追加します。フィールドタイプの選択が重要です。

フィールドタイプ用途注意点
Text1行のテキスト改行不可
Textarea複数行のテキスト改行可能・文字列として返る
Richtextリッチテキストオブジェクトとして返る(後述)
Asset画像・動画URLとして返る

Translatableフィールドの設定
多言語対応するには各フィールドの設定で「Translatable field」を有効にする必要があります。これを忘れると日英で同じコンテンツが表示されます。

ただしこの設定は、Storyblokの管理画面でSettings → Internationalizationで言語を追加してからでないと表示されません。先に言語設定を済ませてください。

Settings → Internationalization
Default language: ja
Additional languages: en

Next.jsとの接続実装

クライアントの初期化

storyblok-js-clientを直接使う方法が最もシンプルで安定していました。

TypeScript
// src/lib/storyblok.ts
import StoryblokClient from 'storyblok-js-client'

const client = new StoryblokClient({
  accessToken: process.env.STORYBLOK_ACCESS_TOKEN,
  cache: {
    clear: 'auto',
    type: 'memory',
  },
})

export default client

@storyblok/reactstoryblokInitを使う方法もありますが、Next.js 16のサーバーコンポーネントでは毎回初期化が走るため、シングルトンのクライアントを直接使う方がパフォーマンス面で有利です。

データ取得ユーティリティ

TypeScript
// src/lib/getStory.ts
import client from './storyblok'

type StoryVersion = 'draft' | 'published'

export async function getStory(slug: string, locale: string) {
  try {
    const { data } = await client.get(`cdn/stories/${slug}`, {
      version: (process.env.NEXT_PUBLIC_STORYBLOK_VERSION ?? 'draft') as StoryVersion,
      language: locale,
    })
    return data.story
  } catch (error) {
    console.error(`Storyblok: "${slug}" の取得に失敗しました`, error)
    return null
  }
}

export async function getStories(startsWith: string, locale: string) {
  try {
    const { data } = await client.get('cdn/stories', {
      version: (process.env.NEXT_PUBLIC_STORYBLOK_VERSION ?? 'draft') as StoryVersion,
      starts_with: startsWith,
      language: locale,
    })
    return data.stories
  } catch (error) {
    console.error(`Storyblok: "${startsWith}" の一覧取得に失敗しました`, error)
    return []
  }
}

try-catchでエラーをハンドリングしているのがポイントです。APIキーが間違っている・Storyが存在しないなどの場合にビルドが落ちないようにしています。

localeの渡し方

Storyblokのi18n APIはlanguageパラメータでロケールを指定します。

TypeScript
const { data } = await client.get(`cdn/stories/${slug}`, {
  language: locale,  // 'ja' または 'en'
})

language: 'ja'を指定すると、Translatableフィールドの日本語版が返ります。language: 'en'を指定すると英語版が返ります。デフォルト言語(ja)のコンテンツはフィールドにそのまま入り、追加言語(en)のコンテンツはfield__i18n__enというキーで返ってきますが、SDKが自動的に変換してくれるため意識する必要はありません。


Richtextフィールドの落とし穴

実装中に最も詰まったのがRichtextフィールドの扱いです。

問題:Richtextは文字列ではない

StoryblokのRichtextフィールドはHTML文字列ではなく、独自のJSONオブジェクトとして返ってきます。

JSON
{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "content": [
        {
          "type": "text",
          "text": "本文テキスト"
        }
      ]
    }
  ]
}

このため、文字列として扱おうとすると以下のエラーが発生します。

TypeError: content?.representative_message.split is not a function

解決:renderRichTextを使う

@storyblok/reactrenderRichText関数でHTML文字列に変換します。

TypeScript
import { renderRichText } from '@storyblok/react'

<div
  className="richtext"
  dangerouslySetInnerHTML={{
    __html: content?.representative_message
      ? String(renderRichText(content.representative_message))
      : ''
  }}
/>

String()でラップしているのはrenderRichTextの戻り値がTrustedHTML | undefinedになる場合があり、TypeScriptの型エラーを防ぐためです。

Richtextのスタイリング

dangerouslySetInnerHTMLで出力したHTMLにはTailwindのクラスが当たりません。CSSで別途スタイルを定義します。

CSS
/* globals.css */
.richtext p {
  margin-bottom: 1.25rem;
  line-height: 2;
  color: #4a5568;
  font-weight: 300;
  font-size: 0.875rem;
}

.richtext strong {
  font-weight: 500;
  color: #0f1923;
}

.richtext ul {
  list-style: disc;
  padding-left: 1.5rem;
  margin-bottom: 1.25rem;
}

.richtext li {
  margin-bottom: 0.5rem;
  line-height: 1.8;
}

.richtext a {
  color: #00a87a;
  text-decoration: underline;
}

TextareaとRichtextの使い分け

実装を通じて感じた使い分けの基準はこうです。

Textareaが適しているケース

  • 段落区切りだけで十分なテキスト
  • 箇条書きや太字が不要なコンテンツ
  • Next.js側でsplit('\n')して柔軟にレンダリングしたい場合

Richtextが適しているケース

  • 太字・リンク・箇条書きなど書式が必要なコンテンツ
  • ブログ記事や長文のコンテンツ
  • 将来的にエディタ機能を活用したい場合

代表者メッセージのような長文はRichtextが適していますが、サービスの説明文程度であればTextareaの方が扱いやすいです。


型の整理

Storyblokのコンテンツに型を付けることでTypeScriptの恩恵を受けられます。

TypeScript
import { ISbRichtext } from 'storyblok-js-client'

type HomeContent = {
  hero_headline1: string
  hero_headline2: string
  hero_sub: string
  mission_headline: string
  mission_body: string
  vision_headline: string
  vision_body: string
  representative_message: ISbRichtext
}

// ページコンポーネントでの使用
const content = story?.content as HomeContent | null

ISbRichtextstoryblok-js-clientからエクスポートされているRichtextフィールド専用の型です。バージョンによってエクスポート名が変わることがあるため、見つからない場合はanyで代替するか、自前で型を定義します。

TypeScript
// 自前の型定義(フォールバック)
type StoryblokRichtext = {
  type: string
  content?: StoryblokRichtext[]
  text?: string
  marks?: Array<{ type: string }>
}

Storyblokとの接続で注意が必要だったのは型の扱いです。StoryblokのRichtextフィールドは文字列ではなくオブジェクトとして返ってくるため、renderRichText関数とあわせて以下のように実装しました。

TypeScript
import { renderRichText } from '@storyblok/react'

<div
  className="richtext"
  dangerouslySetInnerHTML={{
    __html: content?.representative_message
      ? String(renderRichText(content.representative_message))
      : ''
  }}
/>

またdynamic importでのJSON読み込みがNext.js 16で動作しなくなったため、静的importに切り替えました。

TypeScript
import ja from '../../messages/ja.json'
import en from '../../messages/en.json'
const messages = { ja, en } as const

Next.js: コンポーネント設計

コンポーネント設計の方針

サーバーコンポーネントとクライアントコンポーネントの分離

Next.js App Routerでは、データ取得はサーバーコンポーネントで行い、インタラクションが必要な部分だけクライアントコンポーネントにするのが基本方針です。

page.tsx(サーバーコンポーネント)
  ↓ Storyblokからデータ取得
  ↓ propsとしてデータを渡す
  ├── Hero.tsx('use client')← スライドショーがあるため
  ├── Stats.tsx(サーバー)← 静的なコンテンツ
  ├── Problem.tsx(サーバー)← 静的なコンテンツ
  ├── Solution.tsx(サーバー)← CSSアニメーションのみ
  └── CtaSection.tsx(サーバー)← 静的なコンテンツ

スライドショーのようにuseStateuseEffectが必要なコンポーネントだけ'use client'にします。不必要にクライアントコンポーネントにするとバンドルサイズが増加するため注意が必要です。

翻訳テキストの取得方法の違い

サーバーコンポーネントとクライアントコンポーネントで翻訳APIが異なります。これはnext-intlの仕様です。

TypeScript
// サーバーコンポーネント(asyncな関数)
import { getTranslations } from 'next-intl/server'
const t = await getTranslations('hero')

// クライアントコンポーネント('use client')
import { useTranslations } from 'next-intl'
const t = useTranslations('nav')

asyncな関数コンポーネントでuseTranslations(フック)を使うと以下のエラーが発生します。

Error: `useTranslations` is not callable within an async component.

実装初期によく遭遇するエラーです。asyncがついているページにはgetTranslations'use client'がついているコンポーネントにはuseTranslationsと機械的に使い分けると覚えておくとよいです。

Revealコンポーネントの設計

スクロールアニメーションはIntersection Observer APIを使ったカスタムコンポーネントで実装しました。外部ライブラリ(Framer Motionなど)を使わない理由は2つです。

バンドルサイズの最小化
Framer Motionは約100KBあります。シンプルなフェードインアニメーションのためだけに追加するのは過剰です。

サーバーコンポーネントとの相性
多くのアニメーションライブラリはクライアントコンポーネントでのみ動作します。ラッパーコンポーネントが必要になり、設計が複雑になります。

TypeScript
// src/components/ui/Reveal.tsx
'use client'

import { useEffect, useRef, useState, ReactNode } from 'react'

type RevealProps = {
  children: ReactNode
  className?: string
  delay?: number
}

export default function Reveal({ children, className = '', delay = 0 }: RevealProps) {
  const ref = useRef<HTMLDivElement>(null)
  const [visible, setVisible] = useState(false)

  useEffect(() => {
    const el = ref.current
    if (!el) return

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setTimeout(() => setVisible(true), delay)
          observer.unobserve(el)
        }
      },
      { threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
    )

    observer.observe(el)
    return () => observer.disconnect()
  }, [delay])

  return (
    <div
      ref={ref}
      className={`
        transition-all duration-700 ease-out
        ${visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-5'}
        ${className}
      `}
    >
      {children}
    </div>
  )
}

delayプロパティで複数の要素を順番にアニメーションさせることができます。

TypeScript
// カードを順番にフェードイン
{barriers.map((barrier, i) => (
  <Reveal key={barrier.num} delay={i * 100}>
    <div>...</div>
  </Reveal>
))}

observer.unobserve(el)で一度表示されたら監視を解除しているのがポイントです。スクロールのたびにアニメーションが繰り返されないようにします。


Hero.tsxのスライドショー実装

ヒーローセクションは業種ごとに3枚のスライドが5秒ごとに切り替わります。

TypeScript
'use client'

import { useState, useEffect } from 'react'

const slides = [
  {
    key: 'office',
    industry: '士業・専門職',
    pain: '書類作業に追われて、本来の仕事に集中できていますか?',
    bg: 'from-[#0d1e35] to-[#102a45]',
  },
  {
    key: 'care',
    industry: '介護・医療',
    pain: '人手不足の中で、スタッフの負担は限界に近づいていませんか?',
    bg: 'from-[#0d1e2e] to-[#0e2438]',
  },
  {
    key: 'construction',
    industry: '建設・製造業',
    pain: '現場と事務所の情報共有に、毎日時間をとられていませんか?',
    bg: 'from-[#1a1208] to-[#221808]',
  },
]

export default function Hero({ headline1, headline2, sub }: HeroProps) {
  const [current, setCurrent] = useState(0)
  const [visible, setVisible] = useState(true)

  useEffect(() => {
    const timer = setInterval(() => {
      // フェードアウト
      setVisible(false)
      setTimeout(() => {
        // スライドを進める
        setCurrent(prev => (prev + 1) % slides.length)
        // フェードイン
        setVisible(true)
      }, 400)
    }, 5000)
    return () => clearInterval(timer)
  }, [])
  
  // ...
}

フェードアウト(400ms)→スライド切り替え→フェードインという流れをsetTimeoutでコントロールしています。clearIntervalでクリーンアップしているのも重要です。コンポーネントがアンマウントされたときにタイマーが残り続けることを防ぎます。

ドットをクリックして手動で切り替えた場合も、自動切り替えのタイマーをリセットしています。

TypeScript
const goToSlide = (index: number) => {
  setCurrent(index)
  setVisible(true)
  // タイマーのリセットはuseEffectの依存配列で管理するか
  // clearInterval → setIntervalで再設定する
}

レスポンシブ対応の戦略

Tailwind CSSのブレークポイントを活用してモバイル対応しました。基本方針はモバイルファーストです。

デフォルト(モバイル): 1カラム、小さめのパディング
md(768px以上): 中間レイアウト
lg(1024px以上): PCレイアウト(2カラム等)
TypeScript
// Hero.tsx の例
<section className="
  grid grid-cols-1 lg:grid-cols-2  // モバイル1カラム、PC2カラ
  px-6 md:px-16                    // モバイル24px、PC64px
  pt-24 pb-12 lg:pt-28 lg:pb-16   // モバイルとPCでパディングを調整
  gap-8 lg:gap-12
">

Headerはモバイルでハンバーガーメニューに変わります。

TypeScript
{/* PC:ナビリンク */}
<ul className="hidden md:flex gap-10 list-none">

{/* モバイル:ハンバーガーボタン */}
<button className="flex md:hidden ...">

モバイルメニューを開いたときはdocument.body.style.overflow = 'hidden'でスクロールを無効化しています。メニューが開いた状態で背景がスクロールされるのを防ぐためです。


next.config.tsの最終形

TypeScript
import createNextIntlPlugin from 'next-intl/plugin'

const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')

export default withNextIntl({})

モノレポ環境ではNextConfigの型インポートが競合することがあります。型を明示しないシンプルな形が最も安全です。

Vercelへのデプロイ

モノレポ構成のためRoot Directoryの指定が重要です。

Root Directory: apps/corporate

環境変数は以下の2つを設定します。

STORYBLOK_ACCESS_TOKEN = (Preview token)
NEXT_PUBLIC_STORYBLOK_VERSION = published

本番環境ではpublishedモードにするため、Storyblokの管理画面で各Storyを事前にPublishしておく必要があります。

GitHubのOrganizationリポジトリをVercelの無料プランで使う場合はPublicリポジトリにする必要があります。プライベートリポジトリとOrganizationの組み合わせはProプランが必要です。設立初期のコスト管理の観点から、個人アカウントのプライベートリポジトリへの移管またはPublicリポジトリの運用を検討してください。


まとめ:内製化で得られたもの

今回のサイト制作を通じて、以下の知見が得られました。

Next.js 16のサーバーコンポーネントとクライアントコンポーネントの境界設計、next-intl v4の正しい実装パターン、Storyblokとのi18n連携の具体的な手法を実践で習得しました。

AIエージェント導入支援を行うNeurosynchとして、自社サイト制作そのものが技術力の証明になりました。「作れる人間が作った」というメッセージは、ダミーのデモや派手なUIよりも強い信頼を生みます。


この記事で紹介した技術スタック
Next.js 16 / next-intl v4 / Storyblok / Turborepo / Vercel / Tailwind CSS / TypeScript

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

1981年生まれ、名古屋出身。
2008年より17年間ドイツ・ベルリンに在住。
ドイツの職業訓練プログラムを修了後、複数のIT企業でフルスタックエンジニアとして経験を積む。2022年よりドイツの大手システム開発会社で、リードエンジニアとして勤務する。小売・医療・メディアなど異なる業種に携わる。2026年6月株式会社Neurosynchを設立。2027年、日本に帰国予定。
著書「AI時代の海外移住戦略

コメント

コメントする

目次