Freshで作ったブログをNext.jsに移行する
作成日
更新日
背景
おお涙
移行手順
- Next.jsのプロジェクトを作成
- Fresh->Next.jsへファイル構造を変換するスクリプトを用意して実行
- Next.js 用に中身を修正する
やる
Next.jsのプロジェクトを作成
$ npx create-next-app@latest
Need to install the following packages:
create-next-app@15.2.4
Ok to proceed? (y) y
✔ What is your project named? … uruly.xyz
✔ Would you like to use TypeScript? … No / [Yes]
✔ Would you like to use ESLint? … No / [Yes]
✔ Would you like to use Tailwind CSS? … No / [Yes]
✔ Would you like your code inside a `src/` directory? … No / [Yes]
✔ Would you like to use App Router? (recommended) … No / [Yes]
✔ Would you like to use Turbopack for `next dev`? … No / [Yes]
✔ Would you like to customize the import alias (`@/*` by default)? … [No] / Yes
uruly.xyz/
├── README.md
├── eslint.config.mjs
├── next-env.d.ts
├── next.config.ts
├── node_modules
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── src
└── tsconfig.json
変換スクリプト
Denoで実行する migrate_fresh_to_nextjs.ts
を用意する
migrate_fresh_to_nextjs.ts
// deno run --allow-read --allow-write migrate_fresh_to_next.ts
import {
join,
relative,
dirname,
basename,
} from "https://deno.land/std@0.224.0/path/mod.ts";
const OUTPUT_ROOT = "output"; // 出力先ルート
async function ensureDir(path: string) {
await Deno.mkdir(dirname(path), { recursive: true });
}
async function copyFile(from: string, to: string) {
await ensureDir(to);
await Deno.copyFile(from, to);
console.log(`✅ Copied: ${from} → ${to}`);
}
async function copyDirContents(from: string, to: string) {
for await (const entry of Deno.readDir(from)) {
const fromPath = join(from, entry.name);
const toPath = join(to, entry.name);
if (entry.isDirectory) {
await copyDirContents(fromPath, toPath);
} else {
await copyFile(fromPath, toPath);
}
}
}
function convertRoutePath(srcPath: string): string | null {
const rel = relative("src/routes", srcPath);
if (rel.startsWith("_") || rel.includes("(deprecated)")) return null;
const parts = rel.split("/");
let file = parts.pop()!;
if (file === "index.tsx") {
parts.push("page.tsx");
} else if (file.startsWith("[...") && file.endsWith("].tsx")) {
parts.push(file.slice(4, -6), "page.tsx");
} else if (file.startsWith("[") && file.endsWith("].tsx")) {
parts.push(file.slice(0, -4), "page.tsx");
} else {
parts.push("page.tsx");
}
return join(OUTPUT_ROOT, "src/app", ...parts);
}
async function migrateRoutes(dir: string) {
for await (const entry of Deno.readDir(dir)) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory) {
await migrateRoutes(fullPath);
} else if (entry.name.endsWith(".tsx")) {
const targetPath = convertRoutePath(fullPath);
if (targetPath) {
await copyFile(fullPath, targetPath);
}
}
}
}
async function main() {
console.log("🚀 Migrating Fresh to Next.js (output to ./output)");
// 1. components & islands → output/src/components
try {
await copyDirContents("src/islands", join(OUTPUT_ROOT, "src/components"));
await copyDirContents("src/components", join(OUTPUT_ROOT, "src/components"));
} catch (_) {
console.warn("⚠️ Skipped islands/components copy (not found)");
}
// 2. static → public
for (const dir of ["static", "src/static"]) {
try {
await copyDirContents(dir, join(OUTPUT_ROOT, "public"));
} catch (_) {
console.warn(`⚠️ Skipped ${dir} → public (not found)`);
}
}
// 3. routes → app
try {
await migrateRoutes("src/routes");
} catch (_) {
console.warn("⚠️ Skipped route migration (src/routes not found)");
}
// 4. special files
const specials: Record<string, string> = {
"src/routes/_404.tsx": "src/app/not-found.tsx",
"src/routes/_app.tsx": "src/app/layout.tsx",
"src/routes/_layout.tsx": "src/app/layout.tsx",
"src/routes/_middleware.tsx": "middleware.ts",
};
for (const [from, relTo] of Object.entries(specials)) {
const to = join(OUTPUT_ROOT, relTo);
try {
await copyFile(from, to);
} catch (_) {
console.warn(`⚠️ Skipped: ${from} (not found)`);
}
}
console.log("✅ Migration complete! Output: ./output");
}
await main();
$ deno run --allow-read --allow-write migrate_fresh_to_nextjs.ts
吐き出された output
ディレクトリの中身をNext.jsのプロジェクトに移す。
import
から.ts
を削除preact
の import を修正
コツコツ置き換える
環境変数
Next.js は NEXT_PUBLIC_ が付いている環境変数のみクライアント側からもアクセス可能
Signal の置き換え
@preact/signals
これは使えない。
zustandを使った方法に置き換える。
$ npm install zustand
import { create } from "zustand";
type LoadingStore = {
isLoading: boolean;
setLoading: (value: boolean) => void;
};
export const useLoadingStore = create<LoadingStore>((set) => ({
isLoading: false,
setLoading: (value) => set({ isLoading: value }),
}));
HEAD の置き換え
import { Head } from "$fresh/runtime";
import Head from "next/head";
_layout.tsx
の置き換え
_layout.tsx
-> layout.tsx
へ。Next.jsではApp Router の役割になる。
@deno/gfm
の置き換え
github-markdown-css
を使う
npm install github-markdown-css
import "github-markdown-css/github-markdown-light.css";
リダイレクト処理
// リダイレクト専用
import { Handlers } from "$fresh/server.ts";
// routesに存在しないURLでアクセスされた場合に
// 記事のリダイレクトが可能か試みる.
export const handler: Handlers = {
GET(_req, ctx) {
// 301 リダイレクトを設定して /posts/slug へ
return new Response("", {
status: 301,
headers: { Location: `/posts/${ctx.params.slug}` },
});
},
};
↓
import { redirect } from "next/navigation";
type Props = {
params: {
slug: string;
};
};
export default function RedirectSlugPage({ params }: Props) {
// /post/[slug] → /posts/[slug]
// **HTTP 307 Temporary Redirect**
redirect(`/posts/${params.slug}`);
}
style.css の場所
public
ディレクトリではなく src
の中に入れる。
island
ファイルの最初に "use client";
をつける(useEffectを使うため)
Tailwindのv3.xからv4.xへ
Tailwindをv3.x系からv4.x系へアップグレードする
ChatGPT がなにも役に立たなかった。
api/images/[fileName].tsx
の移行
app/images/[fileName]/route.ts
に移行。
かるくNext.js のお勉強
- AppRouterについて学ぶ
- 昔の PageRouter ってやつとは随分変わったらしい
- ざっくりApp Router入門【Next.js】
- getStaticPropsとgetServerSideProps がなくなった
- なんと。generateStaticParamsを使う
- SEOタグを簡単に設定できるようになった
- なんと。
export const metadata: Metadata = { }
で簡単
- なんと。
use client
を使っていないものはサーバーサイドコンポーネント
SSGに対応する
generateStaticParams
に書けばok
export async function generateStaticParams() {
const categories = ["posts", "map", "notes", "weekly-reports"];
const all = await Promise.all(
categories.map((category) => getAllPostSlugs(category)),
);
return all.flat().map((post) => ({
category: post?.category,
slug: post?.slug,
}));
}
- これもここでok
export default async function Page({ params }: Props) {
const post = await getPost(params.slug);
if (!post) notFound();
return (
<>
<PostContent post={post} />
</>
);
}
- メタデータもこれでok
export async function generateMetadata({
params,
}: {
params: { category: string; slug: string };
}) {
const post = await getPost(params.slug);
if (!post) return {};
return generateOGPMetadata({
title: post.title,
description: extractIntroAndH2sForOGP(post.content, 4),
url: `https://your-domain.com/posts/${post.category}/${post.slug}`,
imageUrl: post.coverImage
? `/api/images/${post.coverImage}`
: undefined,
});
}
よし。

公開日
更新日