Webサイトの制作案件で「Instagramの投稿をサイトに表示したい」という要望は非常に多いです。公式の埋め込み(oEmbed)でも表示はできますが、デザインの自由度が低く、複数アカウントを並べて表示するようなケースには向きません。

この記事では、Instagram Graph APIとPHP・JavaScriptを組み合わせて、複数アカウントのフィードをグリッド表示する方法を解説します。アクセストークンをサーバー側で安全に管理しつつ、フロント側はdata属性でアカウントを切り替えるだけというシンプルな設計です。

全体の仕組み

今回の構成はサーバーサイド(PHP)とクライアントサイド(JavaScript)の2層に分かれています。

  1. instagram-setting.php:アカウントIDとアクセストークンを管理する設定ファイル。公開ディレクトリの外に配置する
  2. instagram-feed.php:Graph APIにリクエストを送り、結果をJSONで返す中継スクリプト。公開ディレクトリに配置する
  3. フロント側のJS:中継スクリプトからJSONを受け取り、画像・動画をグリッド表示する

ポイントは、アクセストークンを含む設定ファイルをpublic_htmlより上の階層(非公開領域)に置くことです。中継スクリプトはトークンを含まないJSONを返すだけなので、外部に露出するリスクがありません。

プロジェクトルート/
├── instagram-setting.php          ← 非公開領域(public_htmlの外)
└── public_html/
    ├── instagram-feed.php         ← 公開領域(APIの中継役)
    └── your-page.html             ← フロント実装

前提条件

この実装には以下が必要です。

  • Facebookページに紐づいたInstagramビジネスアカウント(またはクリエイターアカウント)
  • Facebook Graph APIの長期アクセストークン
  • PHPが動作するサーバー(cURL拡張が有効であること)

フロント側は外部ライブラリなしのJavaScriptのみで動作します。

アクセストークンの取得手順はMeta公式ドキュメントを参照してください。Facebook Developer Consoleでアプリを作成し、Instagram Graph APIの権限を付与してトークンを発行する流れです。

サーバーサイドの実装

設定ファイル(instagram-setting.php)

表示したいアカウントの情報を連想配列で管理します。キー名は自由に決められるので、案件やブランドに合わせた名前を付けてください。

<?php

$INSTAGRAM_ACCOUNTS = [
  // アカウントキー => 設定値
  'shop_tokyo' => [
    'id'    => 'YOUR_BUSINESS_ACCOUNT_ID',       // InstagramビジネスアカウントID
    'token' => 'YOUR_LONG_LIVED_ACCESS_TOKEN',    // Facebook Graph APIのアクセストークン
    'limit' => 4                                   // 表示件数
  ],
  'shop_osaka' => [
    'id'    => 'YOUR_BUSINESS_ACCOUNT_ID_2',
    'token' => 'YOUR_LONG_LIVED_ACCESS_TOKEN_2',
    'limit' => 4
  ],
  // 必要なだけ追加可能
];

このファイルは必ずpublic_htmlの外に配置してください。URLの直打ちでアクセストークンが漏洩することを防ぐためです。

中継スクリプト(instagram-feed.php)

フロント側からのリクエストを受け取り、指定されたアカウントのフィードをGraph APIから取得してJSONで返します。

<?php
require __DIR__ . '/../instagram-setting.php';

header('Content-Type: application/json; charset=utf-8');

// GETパラメータからアカウントキーを取得(デフォルト値は案件に合わせて変更)
$key = $_GET['account'] ?? 'shop_tokyo';

// 存在しないキーが指定された場合はエラーを返す
if (!isset($INSTAGRAM_ACCOUNTS[$key])) {
  http_response_code(400);
  echo json_encode(['error' => 'Invalid account'], JSON_UNESCAPED_UNICODE);
  exit;
}

$config = $INSTAGRAM_ACCOUNTS[$key];

// 取得するフィールドを組み立て
$fields = sprintf(
  'name,media.limit(%d){caption,like_count,media_type,media_url,permalink,thumbnail_url,timestamp,username}',
  (int)$config['limit']
);

$url = sprintf(
  'https://graph.facebook.com/v10.0/%s?fields=%s&access_token=%s',
  $config['id'],
  $fields,
  rawurlencode($config['token'])
);

$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);

$json = curl_exec($ch);

if ($json === false) {
  http_response_code(500);
  echo json_encode(['error' => curl_error($ch)], JSON_UNESCAPED_UNICODE);
  curl_close($ch);
  exit;
}

$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($httpcode !== 200) {
  http_response_code($httpcode);
}

echo $json;

requireのパスは設定ファイルとの位置関係に応じて調整してください。デフォルトでは1つ上の階層を参照しています。

クライアントサイドの実装

HTML

表示したい場所に<ul>を置き、data-account属性でアカウントキーを指定するだけです。複数アカウントを1ページに並べるのも簡単です。

<!-- アカウントごとにdata-accountを指定 -->
<ul class="g-insta__list" data-account="shop_tokyo"></ul>
<ul class="g-insta__list" data-account="shop_osaka"></ul>

CSS

CSS Gridでレスポンシブなグリッドレイアウトを作ります。PC5列・タブレット4列・スマホ2列の構成です。

.g-insta__list {
  display: grid;
  list-style: none;
  padding: 0;
  margin: 0;
}

.g-insta__list li a {
  display: block;
}

.g-insta__list li a img,
.g-insta__list li a video {
  aspect-ratio: 1 / 1;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* PC: 5列 */
@media screen and (min-width: 951px) {
  .g-insta__list {
    grid-template-columns: repeat(5, 1fr);
    gap: 5px;
  }
}

/* タブレット: 4列(8件まで表示) */
@media screen and (min-width: 768px) and (max-width: 950px) {
  .g-insta__list {
    grid-template-columns: repeat(4, 1fr);
    gap: 5px;
  }
  .g-insta__list li:nth-of-type(n+9) {
    display: none;
  }
}

/* スマホ: 2列 */
@media screen and (max-width: 767px) {
  .g-insta__list {
    grid-template-columns: repeat(2, 1fr);
    gap: 3px;
  }
}

タブレットでは:nth-of-type(n+9)で9件目以降を非表示にしています。4列×2行=8件に収まるため、レイアウトが崩れません。列数や表示件数は案件の要件に合わせて調整してください。

JavaScript

メインの処理です。ページ内の.g-insta__list[data-account]を順に処理し、それぞれのアカウントキーで中継スクリプトにリクエストを送ります。

document.addEventListener('DOMContentLoaded', () => {

  document.querySelectorAll('.g-insta__list[data-account]').forEach((list) => {
    const accountKey = list.dataset.account;

    if (!accountKey) {
      showError(list, '設定エラー:account が未指定です');
      return;
    }

    list.innerHTML = '';
    showLoading(list);

    fetch(`/instagram-feed.php?account=${encodeURIComponent(accountKey)}`, {
      signal: AbortSignal.timeout(15000),
    })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((json) => {
        hideLoading(list);

        if (!json?.media?.data || !Array.isArray(json.media.data)) {
          console.error('Invalid JSON structure for account:', accountKey, json);
          showError(list, '取得データ形式が不正です');
          return;
        }

        const insta = json.media.data;

        if (insta.length === 0) {
          showEmpty(list, '投稿がありません');
          return;
        }

        const fragment = document.createDocumentFragment();

        for (const post of insta) {
          if (!post.permalink) continue;

          let imgUrl = '';
          if (post.media_type === 'VIDEO') {
            imgUrl = post.thumbnail_url || post.media_url;
          } else {
            imgUrl = post.media_url;
          }
          if (!imgUrl) continue;

          const li = document.createElement('li');
          const a = document.createElement('a');
          a.href = post.permalink;
          a.target = '_blank';
          a.rel = 'noopener';

          const img = document.createElement('img');
          img.src = imgUrl;
          img.alt = post.media_type === 'VIDEO' ? 'Instagram Video' : 'Instagram Image';

          // 動画:クリックでインライン再生に切り替え
          if (post.media_type === 'VIDEO' && post.media_url) {
            img.addEventListener('click', (e) => {
              e.preventDefault();
              playVideo(post.media_url, img);
            });
          }

          a.appendChild(img);
          li.appendChild(a);
          fragment.appendChild(li);
        }

        if (!fragment.hasChildNodes()) {
          showEmpty(list, '表示できる投稿がありません');
          return;
        }

        list.appendChild(fragment);
      })
      .catch((err) => {
        hideLoading(list);
        console.error('Failed to load Instagram feed:', accountKey, err);

        let msg = '取得できませんでした';
        if (err.name === 'TimeoutError') msg = 'タイムアウトしました';
        showError(list, msg);
      });
  });

});

document.createElement()DocumentFragmentでDOM要素を生成しているため、srchrefにセットする値はブラウザが自動的にエスケープします。HTML文字列の結合で起きがちなXSSリスクを根本的に排除できる書き方です。

ヘルパー関数

ローディング表示・エラー表示・動画再生をまとめたヘルパー関数群です。

// ===== 表示状態の管理 =====

function showLoading(list) {
  if (list.querySelector('[data-insta-state="loading"]')) return;
  const li = document.createElement('li');
  li.className = 'g-instaState g-instaState--loading';
  li.dataset.instaState = 'loading';
  li.textContent = '読み込み中…';
  list.appendChild(li);
}

function hideLoading(list) {
  list.querySelector('[data-insta-state="loading"]')?.remove();
}

function showError(list, message) {
  removeStates(list);
  const li = document.createElement('li');
  li.className = 'g-instaState g-instaState--error';
  li.dataset.instaState = 'error';
  li.textContent = message;
  list.appendChild(li);
}

function showEmpty(list, message) {
  removeStates(list);
  const li = document.createElement('li');
  li.className = 'g-instaState g-instaState--empty';
  li.dataset.instaState = 'empty';
  li.textContent = message;
  list.appendChild(li);
}

function removeStates(list) {
  list.querySelectorAll('[data-insta-state]').forEach((el) => el.remove());
}

// ===== 動画のインライン再生 =====

function playVideo(videoUrl, element) {
  const video = document.createElement('video');
  video.src = videoUrl;
  video.controls = true;
  video.autoplay = true;
  video.muted = true;
  video.playsInline = true;
  element.parentNode.replaceChild(video, element);
}

document.createElement()でDOM要素を生成し、textContent.src/.hrefで値をセットすれば、ブラウザが自動的にエスケープ処理を行います。そのため、手動のエスケープ関数を用意する必要はありません。

動画投稿の処理について

Instagramの投稿にはIMAGE・VIDEO・CAROUSEL_ALBUMの3種類があります。動画投稿の場合、media_urlには動画ファイルのURLが入りますが、初期表示ではthumbnail_urlのサムネイル画像を使っています。

クリック時にplayVideo()<img><video>に差し替えてインライン再生を開始する仕組みです。mutedplaysInlineを付けることで、iOS Safariでもユーザーの操作なしに自動再生が可能になります。

セキュリティ上の注意点

アクセストークンの管理

もっとも重要なのは、アクセストークンを公開ディレクトリに絶対に置かないことです。instagram-setting.phppublic_htmlの外に配置することで、URLの直打ちやディレクトリトラバーサルによる漏洩を防いでいます。

XSS対策

document.createElement()でDOM要素を生成し、プロパティ経由で値をセットしているため、ブラウザが自動的にエスケープ処理を行います。HTML文字列を組み立ててinnerHTMLで挿入する方式と比べて、XSSリスクを根本的に回避できる構成です。

動作確認の方法

設置後にフィードが表示されない場合は、まずGraph APIが正しく動作しているか確認します。ブラウザで以下の形式のURLにアクセスしてください。

https://graph.facebook.com/v10.0/{ビジネスアカウントID}?fields=name,media.limit({表示件数}){caption,like_count,media_type,media_url,permalink,thumbnail_url,timestamp,username}&access_token={アクセストークン}

JSON形式でフィード情報が返ってくれば、API側は正常です。表示されない場合はブラウザのデベロッパーツールでネットワークタブを確認し、instagram-feed.phpへのリクエストが正しいステータスコードで返ってきているかチェックしてください。

よくあるトラブルとしては以下のようなものがあります。

  • トークンの有効期限切れ:長期トークンでも約60日で失効します。定期的な更新が必要です
  • requireのパスミスinstagram-setting.phpへの相対パスがサーバー環境によって異なる場合があります
  • cURLが無効:レンタルサーバーによってはcURL拡張がデフォルトで無効な場合があります
  • CORS制限:中継スクリプトと表示ページが異なるドメインの場合、CORSヘッダーの追加が必要です

カスタマイズのヒント

この実装をベースに、案件に合わせてカスタマイズできるポイントをいくつか紹介します。

キャプションをホバーで表示する

APIレスポンスにはcaptionが含まれているので、画像のホバー時にキャプションをオーバーレイ表示する実装も簡単に追加できます。

CAROUSEL_ALBUMの対応

カルーセル投稿の場合、media_typeCAROUSEL_ALBUMになります。今回のコードではカルーセルの1枚目(media_url)がそのまま表示されますが、子メディアまで取得したい場合はchildrenフィールドを追加でリクエストする必要があります。

Graph APIのバージョン

今回のコードではv10.0を使用していますが、Graph APIは定期的にバージョンが更新されます。古いバージョンは廃止される可能性があるため、Meta公式のChangelogを確認して、適宜新しいバージョンに更新してください。

まとめ

Instagram Graph APIを使ったフィード表示は、公式埋め込みに比べてデザインの自由度が格段に高く、複数アカウントへの対応も容易です。サーバーサイドでトークンを安全に管理し、フロント側はdata属性で柔軟にアカウントを切り替えるこの構成は、実案件でも再利用しやすい設計になっています。

外部ライブラリに依存せず、fetchdocument.createElement()で実装しているため、パフォーマンス面でも有利です。DOM APIで要素を生成する方式は手動のエスケープ処理も不要になるため、セキュリティ面でもより堅牢な実装になっています。