はじめに:なぜ自社サイトを内製したのか
今回、株式会社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を使います。混在させるとフックのルール違反エラーが発生します。
// サーバーコンポーネント(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.tsにcreateNextIntlPluginを設定しないと動作しません。
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 Block | Content Typeの中に入れ子で使うパーツ |
| Universal Block | どちらでも使える汎用パーツ |
今回はページごとにContent Type Blockを1つ作る構成にしました。
page_home ← トップページ用
page_services ← サービスページ用
page_company ← 会社概要用
page_careers ← 採用ページ用
フィールドの設計
各BlockにはFieldを追加します。フィールドタイプの選択が重要です。
| フィールドタイプ | 用途 | 注意点 |
|---|---|---|
| Text | 1行のテキスト | 改行不可 |
| Textarea | 複数行のテキスト | 改行可能・文字列として返る |
| Richtext | リッチテキスト | オブジェクトとして返る(後述) |
| Asset | 画像・動画 | URLとして返る |
Translatableフィールドの設定
多言語対応するには各フィールドの設定で「Translatable field」を有効にする必要があります。これを忘れると日英で同じコンテンツが表示されます。
ただしこの設定は、Storyblokの管理画面でSettings → Internationalizationで言語を追加してからでないと表示されません。先に言語設定を済ませてください。
Settings → Internationalization
Default language: ja
Additional languages: en
Next.jsとの接続実装
クライアントの初期化
storyblok-js-clientを直接使う方法が最もシンプルで安定していました。
// 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/reactのstoryblokInitを使う方法もありますが、Next.js 16のサーバーコンポーネントでは毎回初期化が走るため、シングルトンのクライアントを直接使う方がパフォーマンス面で有利です。
データ取得ユーティリティ
// 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パラメータでロケールを指定します。
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オブジェクトとして返ってきます。
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "本文テキスト"
}
]
}
]
}このため、文字列として扱おうとすると以下のエラーが発生します。
TypeError: content?.representative_message.split is not a function
解決:renderRichTextを使う
@storyblok/reactのrenderRichText関数でHTML文字列に変換します。
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で別途スタイルを定義します。
/* 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の恩恵を受けられます。
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 | nullISbRichtextはstoryblok-js-clientからエクスポートされているRichtextフィールド専用の型です。バージョンによってエクスポート名が変わることがあるため、見つからない場合はanyで代替するか、自前で型を定義します。
// 自前の型定義(フォールバック)
type StoryblokRichtext = {
type: string
content?: StoryblokRichtext[]
text?: string
marks?: Array<{ type: string }>
}Storyblokとの接続で注意が必要だったのは型の扱いです。StoryblokのRichtextフィールドは文字列ではなくオブジェクトとして返ってくるため、renderRichText関数とあわせて以下のように実装しました。
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に切り替えました。
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(サーバー)← 静的なコンテンツ
スライドショーのようにuseStateやuseEffectが必要なコンポーネントだけ'use client'にします。不必要にクライアントコンポーネントにするとバンドルサイズが増加するため注意が必要です。
翻訳テキストの取得方法の違い
サーバーコンポーネントとクライアントコンポーネントで翻訳APIが異なります。これはnext-intlの仕様です。
// サーバーコンポーネント(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あります。シンプルなフェードインアニメーションのためだけに追加するのは過剰です。
サーバーコンポーネントとの相性
多くのアニメーションライブラリはクライアントコンポーネントでのみ動作します。ラッパーコンポーネントが必要になり、設計が複雑になります。
// 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プロパティで複数の要素を順番にアニメーションさせることができます。
// カードを順番にフェードイン
{barriers.map((barrier, i) => (
<Reveal key={barrier.num} delay={i * 100}>
<div>...</div>
</Reveal>
))}observer.unobserve(el)で一度表示されたら監視を解除しているのがポイントです。スクロールのたびにアニメーションが繰り返されないようにします。
Hero.tsxのスライドショー実装
ヒーローセクションは業種ごとに3枚のスライドが5秒ごとに切り替わります。
'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でクリーンアップしているのも重要です。コンポーネントがアンマウントされたときにタイマーが残り続けることを防ぎます。
ドットをクリックして手動で切り替えた場合も、自動切り替えのタイマーをリセットしています。
const goToSlide = (index: number) => {
setCurrent(index)
setVisible(true)
// タイマーのリセットはuseEffectの依存配列で管理するか
// clearInterval → setIntervalで再設定する
}レスポンシブ対応の戦略
Tailwind CSSのブレークポイントを活用してモバイル対応しました。基本方針はモバイルファーストです。
デフォルト(モバイル): 1カラム、小さめのパディング
md(768px以上): 中間レイアウト
lg(1024px以上): PCレイアウト(2カラム等)
// 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はモバイルでハンバーガーメニューに変わります。
{/* PC:ナビリンク */}
<ul className="hidden md:flex gap-10 list-none">
{/* モバイル:ハンバーガーボタン */}
<button className="flex md:hidden ...">モバイルメニューを開いたときはdocument.body.style.overflow = 'hidden'でスクロールを無効化しています。メニューが開いた状態で背景がスクロールされるのを防ぐためです。
next.config.tsの最終形
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

コメント