スニペット

DBいらず!JSON+PHPで作る「ブログ記事一覧」

静的サイトに「お知らせ一覧」を追加

カテゴリ切替・年別アーカイブ・前へ/次へ付き(コピペで動く)

「お知らせ一覧だけ欲しいのに、WordPressを入れるほどじゃない」
「でもHTMLを毎回コピペして並べ替えるのはしんどい」

そんなときに便利なのが、JSON+PHPで“DBなし記事一覧”を作る方法です。

posts.json に記事データを追加するだけで、一覧は 新着順に自動整列
さらに 下書き(visible=false)未来日時は非表示(予約運用) を最初から入れておくことで、運用事故も防げます。

この記事で作るもの(完成イメージ)

jsonでのデータ管理サンプル
  • 年別アーカイブ?year=2023 で切り替え
  • カテゴリタブ:CSS / JavaScript / WordPress / Laravel / SEO / その他
  • 前へ/次へ:カテゴリで絞った後でもページ送りできる
  • visible=false は非表示(下書き扱い)
  • 未来日時は非表示(公開事故防止・予約運用が可能)

「会社サイトのお知らせ一覧」「特典ページの更新一覧」などにご使用いただけます。

こんなときにJSON+PHPが強い

向いてる

  • 静的寄りのサイトで「更新一覧だけ」欲しい
  • CMSを入れるほどじゃない(運用・セキュリティ・重さ)
  • 更新はエンジニアがやる(またはGit運用できる)
  • “いつ何を追加したか”を差分で追いたい

向いてない

  • 非エンジニアが頻繁に更新する(JSON編集はミスりやすい)
  • 検索、タグ、コメント、投稿UIなど“ブログ機能”が欲しい
    → その場合は素直にWordPress/CMSが早い

全体の仕組み

  1. 記事データを posts.json に溜める(DBの代わり)
  2. index.phpposts.json を読み込んで一覧HTMLを出す
  3. カテゴリ切替とページングは JSで表示制御(DB不要)

posts.jsonの設計(ここが一番ミスる)

1記事につき持たせる項目はこのようなものを用意しました。
もちろん、項目の追加・削除はご自由に行ってください。

  • date:公開日(YYYY/MM/DD
  • time:公開時刻(HH:MM
  • visible:表示するか(true/false
  • category:カテゴリID(数値)
  • category_name:カテゴリ名(表示用)
  • title:タイトル
  • url:リンク先(相対URL推奨

なぜ date と time を分ける?

「今日の10:00公開」みたいな実務があるから。
dateだけだと 日付が変わった瞬間に公開される事故が起きがちなので、時間も持たせる方が安全です。

重要:JSONはコメント不可

// メモ を書いた瞬間に壊れて、json_decode() が読めなくなります。

ポイント解説

ここだけ読むと全体が理解できます。

1) 年別アーカイブができる理由(?year=2023

PHP側で $_GET['year'] を拾って、その年の記事だけに絞っています。

$selectYear = isset($_GET['year']) ? (int)$_GET['year'] : $endYear;
if ($selectYear < $startYear) $selectYear = $startYear;
if ($selectYear > $endYear) $selectYear = $endYear;

2) 事故防止①:visible=false は出さない(下書き運用)

公開前は visible:false にしておけば一覧に出ません。

if (!$item['visible']) continue;

3) 事故防止②:未来日時は出さない(予約公開&公開事故防止)

date + timeDateTime にして、今より未来ならスキップします。

$dt = DateTime::createFromFormat('Y/m/d H:i', $item['date'].' '.$item['time'], $tz);
if ($now < $dt) continue;
  • 未来 → 非表示(誤公開を防ぐ)
  • 公開日時を過ぎたら → 自動で表示(予約っぽい運用が可能)

4) 新着順に並ぶ理由(公開日時でソート)

JSONの並び順に関係なく、一覧は常に新着順になります。

$item['_dt'] = $dt;
usort($items, function($a, $b) {
  return $b['_dt'] <=> $a['_dt'];
});

5) XSS対策:HTML出力は必ずエスケープ

JSONの値をそのままHTMLに出すのは危険なので、必ず htmlspecialchars() します。

function h(string $s): string {
  return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}

6) URL安全策:javascript: を弾く

万が一の事故(危険URL)を防ぐための保険です。

if (preg_match('#^\s*javascript:#i', $url)) return '';

運用のコツ(実務向け)

  • 新記事は posts.json に追加するだけ(新着順で自動整列)
  • 下書きは visible=false
  • 予約公開は date/time を未来にするだけ(未来は非表示→当日出る)
  • Git運用なら posts.json の差分=更新履歴で追える

完成コード(全文:ここからコピペで動く)

posts.jsonサンプル

{
  "posts": [
    {
      "date": "2025/12/20",
      "time": "10:00",
      "visible": true,
      "category": 3,
      "category_name": "WordPress",
      "title": "固定ヘッダーの実装でハマった話",
      "url": "/blog/fixed-header/"
    },
    {
      "date": "2025/11/02",
      "time": "18:30",
      "visible": true,
      "category": 2,
      "category_name": "JavaScript",
      "title": "localStorageとsessionStorageの違いを実務目線で整理",
      "url": "/blog/localstorage-vs-sessionstorage/"
    },
    {
      "date": "2025/12/31",
      "time": "09:00",
      "visible": true,
      "category": 5,
      "category_name": "SEO",
      "title": "(予約)削除すべきAI記事/残すべき記事の判断基準",
      "url": "/blog/seo-ai-cleanup/"
    },
    {
      "date": "2025/10/10",
      "time": "12:00",
      "visible": false,
      "category": 4,
      "category_name": "Laravel",
      "title": "(下書き)Filamentのテーブル表示のコツ",
      "url": "/blog/filament-table/"
    }
  ]
}

index.php(全文)

<?php
$page_title = "ブログ記事一覧(サンプル)";
$perPage = 8;

$tz = new DateTimeZone('Asia/Tokyo');
$now = new DateTime('now', $tz);
$weekdays = ["日", "月", "火", "水", "木", "金", "土"];

$categories = [
  0 => "すべて",
  1 => "CSS",
  2 => "JavaScript",
  3 => "WordPress",
  4 => "Laravel",
  5 => "SEO",
  6 => "その他",
];

$startYear = 2023;
$endYear = (int)date('Y');

$selectYear = isset($_GET['year']) ? (int)$_GET['year'] : $endYear;
if ($selectYear < $startYear) $selectYear = $startYear;
if ($selectYear > $endYear) $selectYear = $endYear;

$jsonPath = __DIR__ . "/posts.json";
$data = null;

try {
  $json = file_get_contents($jsonPath);
  if ($json === false) throw new RuntimeException("posts.json を読み込めませんでした。");
  $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (Throwable $e) {
  error_log("[blog-list] JSON error: " . $e->getMessage());
  $data = null;
}

function h(string $s): string {
  return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}

function starts_with(string $haystack, string $needle): bool {
  if (function_exists('str_starts_with')) return str_starts_with($haystack, $needle);
  return substr($haystack, 0, strlen($needle)) === $needle;
}

function normalize_url(string $url): string {
  $url = trim($url);
  if ($url === '') return '';
  if (preg_match('#^\s*javascript:#i', $url)) return '';
  if (preg_match('#^(https?://)#i', $url)) return $url;
  if (starts_with($url, '/')) return $url;
  return '';
}

$items = [];
if (isset($data['posts']) && is_array($data['posts'])) {
  foreach ($data['posts'] as $item) {
    if (!isset($item['date'], $item['time'], $item['visible'])) continue;
    if (!$item['visible']) continue;

    $dt = DateTime::createFromFormat('Y/m/d H:i', $item['date'].' '.$item['time'], $tz);
    if (!$dt) continue;
    if ($now < $dt) continue;

    $year = (int)$dt->format('Y');
    if ($year !== $selectYear) continue;

    $item['_dt'] = $dt;
    $items[] = $item;
  }
}

usort($items, function($a, $b) {
  return $b['_dt'] <=> $a['_dt'];
});
?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title><?php echo h($page_title); ?></title>
  <script>document.documentElement.classList.add('js');</script>
  <style>
    body{margin:0;background:#f5f5f5;color:#111827;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;}
    .wrap{max-width:960px;margin:40px auto;padding:0 16px 48px;}
    h1{font-size:24px;margin:0 0 18px;font-weight:700;}
    .toolbar{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin:0 0 14px;}
    select{padding:6px 10px;border:1px solid #d1d5db;border-radius:10px;background:#fff;font-size:14px;}
    .yearBtns button{width:30px;height:30px;border-radius:999px;border:1px solid #d1d5db;background:#fff;cursor:pointer;position:relative;}
    .yearBtns #dec::before,.yearBtns #inc::before{content:"";display:block;width:8px;height:8px;border-top:2px solid #4b5563;border-right:2px solid #4b5563;position:absolute;top:50%;left:50%;}
    .yearBtns #dec::before{transform:translate(-40%,-50%) rotate(-135deg);}
    .yearBtns #inc::before{transform:translate(-60%,-50%) rotate(45deg);}

    .tabs{display:flex;flex-wrap:wrap;gap:6px;list-style:none;padding:0;margin:0 0 16px;}
    .tabs button{padding:6px 12px;border:1px solid #d1d5db;border-radius:999px;background:#f9fafb;font-size:13px;color:#4b5563;cursor:pointer;}
    .tabs button.is-active{background:#2563eb;border-color:#2563eb;color:#fff;}

    .list{list-style:none;margin:0;padding:0;background:#fff;border-radius:14px;overflow:hidden;border:1px solid #e5e7eb;}
    .list li{display:flex;gap:12px;padding:12px 14px;border-bottom:1px solid #e5e7eb;align-items:flex-start;}
    .list li:last-child{border-bottom:none;}
    .js .list li{display:none;}
    .js .list li.on{display:flex;}

    .tag{min-width:92px;font-size:12px;padding:3px 8px;border-radius:999px;background:#eff6ff;color:#1d4ed8;text-align:center;white-space:nowrap;}
    a{color:#111827;text-decoration:none;flex:1;}
    a:hover .ttl{text-decoration:underline;}
    .meta{display:block;font-size:12px;color:#6b7280;margin-bottom:4px;}
    .ttl{margin:0;line-height:1.6;font-size:14px;}

    .empty{display:none;padding:14px;color:#6b7280;}
    .empty.on{display:block;}

    .pager{display:flex;justify-content:center;gap:8px;margin-top:12px;}
    .pager button{padding:6px 14px;border-radius:999px;border:1px solid #d1d5db;background:#fff;font-size:13px;cursor:pointer;}
    .pager button:disabled{opacity:.45;cursor:default;}
    html:not(.js) .pager{display:none;}

    @media(max-width:640px){.list li{flex-direction:column}.tag{min-width:auto}}
  </style>
</head>
<body>
  <div class="wrap">
    <h1><?php echo h($page_title); ?></h1>

    <div class="toolbar">
      <select id="yearSelect" aria-label="年を選択">
        <?php for ($y = $startYear; $y <= $endYear; $y++): ?>
          <option value="<?php echo $y; ?>" <?php if ($y === $selectYear) echo 'selected'; ?>>
            <?php echo $y; ?>年
          </option>
        <?php endfor; ?>
      </select>
      <div class="yearBtns">
        <button id="dec" type="button" aria-label="前年"></button>
        <button id="inc" type="button" aria-label="翌年"></button>
      </div>
    </div>

    <div class="tabs" id="tabs">
      <?php foreach ($categories as $id => $label): ?>
        <button type="button" data-cat="<?php echo $id; ?>" class="<?php echo $id === 0 ? 'is-active' : ''; ?>">
          <?php echo h($label); ?>
        </button>
      <?php endforeach; ?>
    </div>

    <ul class="list" id="postList">
      <?php if (count($items) === 0): ?>
        <li><p class="ttl">表示するデータがありません。</p></li>
      <?php else: ?>
        <?php foreach ($items as $it): ?>
          <?php
            $dt = $it['_dt'];
            $weekday = $weekdays[(int)$dt->format('w')];

            $catId = isset($it['category']) ? (int)$it['category'] : 0;
            $catName = $it['category_name'] ?? '';
            $title = $it['title'] ?? '';
            $url = normalize_url($it['url'] ?? '');
          ?>
          <li data-cat="<?php echo $catId; ?>">
            <div class="tag"><?php echo h($catName); ?></div>
            <a href="<?php echo $url !== '' ? h($url) : 'javascript:void(0);'; ?>">
              <span class="meta"><?php echo h($dt->format("Y年n月j日")); ?>(<?php echo h($weekday); ?>)</span>
              <p class="ttl"><?php echo h($title); ?></p>
            </a>
          </li>
        <?php endforeach; ?>
      <?php endif; ?>
    </ul>

    <div class="empty" id="emptyMsg">表示するデータがありません。</div>

    <div class="pager">
      <button id="prev" type="button">前へ</button>
      <button id="next" type="button">次へ</button>
    </div>
  </div>

  <script>
    (function(){
      const sel = document.getElementById('yearSelect');
      const dec = document.getElementById('dec');
      const inc = document.getElementById('inc');
      if(!sel) return;

      const goYear = (value)=> location.href = '?year=' + encodeURIComponent(value);
      sel.addEventListener('change', ()=> goYear(sel.value));

      function move(diff){
        const opts = Array.from(sel.options);
        const idx = opts.findIndex(o => o.selected);
        const next = idx + diff;
        if(next >= 0 && next < opts.length) goYear(opts[next].value);
      }
      dec.addEventListener('click', ()=> move(-1));
      inc.addEventListener('click', ()=> move(1));
    })();

    (function(){
      const perPage = <?php echo (int)$perPage; ?>;
      const tabs = Array.from(document.querySelectorAll('#tabs button[data-cat]'));
      const list = document.getElementById('postList');
      const empty = document.getElementById('emptyMsg');
      const prev = document.getElementById('prev');
      const next = document.getElementById('next');
      if(!list) return;

      const allItems = Array.from(list.querySelectorAll('li[data-cat]'));
      let currentCat = 0;
      let currentPage = 0;

      function filtered(){
        if(currentCat === 0) return allItems;
        return allItems.filter(li => parseInt(li.dataset.cat,10) === currentCat);
      }

      function render(){
        const arr = filtered();
        const maxPage = Math.max(0, Math.ceil(arr.length / perPage) - 1);

        allItems.forEach(li => li.classList.remove('on'));

        if(arr.length === 0){
          empty.classList.add('on');
          prev.disabled = true;
          next.disabled = true;
          return;
        }
        empty.classList.remove('on');

        if(currentPage > maxPage) currentPage = maxPage;

        const start = currentPage * perPage;
        const end = start + perPage;
        arr.slice(start, end).forEach(li => li.classList.add('on'));

        prev.disabled = (currentPage === 0);
        next.disabled = (currentPage >= maxPage);
      }

      tabs.forEach(btn => {
        btn.addEventListener('click', ()=>{
          tabs.forEach(t => t.classList.remove('is-active'));
          btn.classList.add('is-active');
          currentCat = parseInt(btn.dataset.cat, 10);
          currentPage = 0;
          render();
        });
      });

      prev.addEventListener('click', ()=>{ if(currentPage > 0){ currentPage--; render(); } });
      next.addEventListener('click', ()=>{ currentPage++; render(); });

      render();
    })();
  </script>
</body>
</html>

学びを深めたいあなたへ:ガチ無料で学べるスクール紹介

そんなあなたにおすすめなのが、完全無料で受けられるプログラミングスクールです。
完全無料で学べるプログラミングスクールなら、プロ講師のサポート付きでHTMLやCSSを基礎から学べます。
私もこちらの受講生なので、体験レポートを参考にして頂けたらと思います。

✅ 初学者のスタートダッシュに最適
✅ HTML/CSSの基礎が無料で学習できる
✅ オンライン完結&未経験OK
✅ Slackで講師に何回でも質問できる

無料の理由や利用者の声を見るならこちら

-スニペット
-,