カテゴリ切替・年別アーカイブ・前へ/次へ付き(コピペで動く)
「お知らせ一覧だけ欲しいのに、WordPressを入れるほどじゃない」
「でもHTMLを毎回コピペして並べ替えるのはしんどい」
そんなときに便利なのが、JSON+PHPで“DBなし記事一覧”を作る方法です。
posts.json に記事データを追加するだけで、一覧は 新着順に自動整列。
さらに 下書き(visible=false) と 未来日時は非表示(予約運用) を最初から入れておくことで、運用事故も防げます。
この記事で作るもの(完成イメージ)
https://sample.web-create-kokusyo.com/sample18/

- 年別アーカイブ:
?year=2023で切り替え - カテゴリタブ:CSS / JavaScript / WordPress / Laravel / SEO / その他
- 前へ/次へ:カテゴリで絞った後でもページ送りできる
visible=falseは非表示(下書き扱い)- 未来日時は非表示(公開事故防止・予約運用が可能)
「会社サイトのお知らせ一覧」「特典ページの更新一覧」などにご使用いただけます。
こんなときにJSON+PHPが強い
向いてる
- 静的寄りのサイトで「更新一覧だけ」欲しい
- CMSを入れるほどじゃない(運用・セキュリティ・重さ)
- 更新はエンジニアがやる(またはGit運用できる)
- “いつ何を追加したか”を差分で追いたい
向いてない
- 非エンジニアが頻繁に更新する(JSON編集はミスりやすい)
- 検索、タグ、コメント、投稿UIなど“ブログ機能”が欲しい
→ その場合は素直にWordPress/CMSが早い
全体の仕組み
- 記事データを
posts.jsonに溜める(DBの代わり) index.phpがposts.jsonを読み込んで一覧HTMLを出す- カテゴリ切替とページングは 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 + time を DateTime にして、今より未来ならスキップします。
$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>
