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について学ぶ
  • getStaticPropsとgetServerSideProps がなくなった
  • 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,
    });
}

よし。

サイトアイコン
公開日
更新日