はじめに

NextJS

Install

・node.jsのインストール

npm install react-icons  <-- 超定番アイコン(NextJSにはデフォルトでアイコンが入っていない)

npx create-next-app@latest

npx create-next-app@latest nextproject  <--- 大文字が使えない
√ Would you like to use TypeScript?
... No(TypeScriptも勉強したければYes)
√ Would you like to use ESLint?
... Yes(ESLintは作成したコードをレビューしてくれるツールです。あって困ることはないのでYes)
√ Would you like to use Tailwind CSS?
... No(最近流行りのCSSだけど学習コストが高いのでNO)
√ Would you like your code inside a `src/` directory?
... Yes(プロジェクト直下にsrcディレクトリを配置するかどうか。基本的にYes)
√ Would you like to use App Router? (recommended)
... No(App Routerの情報が少ないので、とりあえずPages Routerで学習してみる)
√ Would you like to use Turbopack for `next dev`?
... No(Webpackの代わりに現れた高速ビルドツール。不安定なので使わない)
√ Would you like to customize the import alias (`@/*` by default)?
... No(インポートエイリアスとはimportでモジュールを読み込むときの相対パスを短くしてくれます
import {Home} from "../../../Home" が import {Home} from "@/Home" になります
初心者にはどうでもいいところなのでNo)

ローカルサーバーの立ち上げ

cd nextproject
npm run dev  <--- ローカルサーバーの立ち上げ

フォルダ構成

nextproject
|---node_modules
|---package.json
|---package-lock.json
|---public
|   |---favicon.ico
|   |---next.svg
|   |---sample.jpg
|   |---audio
|       |---sample.mp3
|---src
|   |---components
|   |   |---Header.js
|   |   |---Footer.js
|   |---pages
|   |   |---index.js
|   |---styles
|       |---globals.css
|       |---Home.module.css

src/pages/_app.js

import "@/styles/globals.css";  <-- すべてのページに適用したいCSS
import { Noto_Sans_JP } from 'next/font/google';


const NotoSansJP = Noto_Sans_JP({
  weight: ["400", "700"],
  subsets: ["latin"],
  display: "swap",
});

export default function App({ Component, pageProps }) {
  return (
    <div className={NotoSansJP.className}>
      <Component {...pageProps} />
    </div>
  );
}

src/pages/index.js

import Header from "@/component/Header";  <-- エイリアスを使う
import { FaBeer } from "react-icons/fa";  <-- react-icons(※1)
import Image from "next/image";  <-- ビルド時に画像圧縮して最適化してくれる
import style from "@/styles/Home.module.css";


export default function Home() {
  return(
    <>
      <Header />
      <FaBeer />
      <Image
        className={style.img1}
        src="/sample.webp" <-- public/sample.jpgを参照(ビルド時もコレでOK)
        alt=""
        width={1000}  <-- width,heightプロパティは必須
        height={373}  <-- pxで指定する必要あり(100%とかはダメ)
    </>
  );
}

※1 … アイコンの種類【https://react-icons.github.io/react-icons/

src/pages/beginner/question/[id].js

import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState, useEffect } from 'react';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import styles from '@/styles/Question.module.css';
import { beginnerquestions } from '@/data/beginnerquestions';


export default function Question() {
  const [isOpen, setIsOpen] = useState(false);
  const [saved, setSaved] = useState(false);

  // データフォルダからデータを取り出す
  const router = useRouter();
  const { id } = router.query;  <-- query()にすると router.query is not a function というエラーが出る
  if (!id) return null;
  const questionId = parseInt(id, 10);
  const question = beginnerquestions.find(q => q.id === questionId);
  if (!question) return <p>NOT FOUND QUESTION</p>;
  const prevId = questionId > 1 ? questionId - 1 : null;
  const nextId = questionId < beginnerquestions.length ? questionId + 1 : null;

  // ローカルストレージの更新
  useEffect( () => {
    const savedItems = JSON.parse(localStorage.getItem("favorite") || "[]");
    setSaved( savedItems.includes(questionId) );
  }, [questionId]);

  const switchFavorite = () => {
    const savedItems = JSON.parse(localStorage.getItem("favorite") || "[]");
    let newItems;
    if (saved) {
      newItems = saveItems.filter(id => id != questionId);
    } else {
      newItems = [...savedItems, questionId];
    }
    localStorage.setItem("favorite", JSON.stringify(newItems));
    setSaved(!saved);
  }
  
  return (
    <>
      <Header />
      <main>
        <div>
          <span>英文</span>
          <Link href="/beginner">英文一覧に戻る >></Link>
        </div>

        <p>{question.pron}</p>
        <p>{question.eng}</p>
        <p>{question.jpn}</p>

        <div>
          <svg width="30px" height="30px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path /></svg>
          <audio controls src="" ></audio>
        </div>

        <button onClick={() => setIsOpen(!isOpen)}>
          <p>使用例</p>
          <svg width="16px" height="16px" viewBox="0 0 16 16" />
        </button>
        {isOpen && (
          <div>{question.usage}</div>
        )}


        {saved ? (
          <button onClick={switchFavorite}>後で見ない</button>
        ) : (
          <button onClick={switchFavorite}>後で見る</button>
        )}


        <div>
          {prevId ? (
            <Link href={`/beginner/question/${prevId}`}>PREV</Link>
          ) : (
            <button disbled>PREV</button>
          )}
          {nextId ? (
            <Link href={`/beginner/question/${nextId}`}>NEXT</Link>
          ) : (
            <button disbled>NEXT</button>
          )}
        </div>
      </main>
      <Footer />
    </>
  );
}

src/components/Header.js

import style from "@/styles/Header.module.css";  <-- インポートしたファイルにだけ読み込まれるCSS
import { Roboto_Condensed } from 'next/font/google';
import Link from "next/link"; <-- ※2

const RobotoCondensed = Roboto_Condensed({
  weight: ["400", "700", "900"],
  subsets: ["latin"],
  display: "swap",
});

export default function Header() {
  return (
    <>
      <header> <-- ※1
        <p>SITE TITLE</p>
        <nav>
          <ul>
            <li><Link href="/">HOME</Link></li> <-- href属性には相対パスではなく絶対パスを使用
          </ul>
        </nav>
      </header>
      <div className={style.wrap}>
        <p className={RobotoCondensed.className}>xxx</p>
        <br />  <-- スラッシュで閉じないとエラーになります
      </div>
    </>
  );
}

※1…ここを<Header>とすると「RangeError: Maximum call stack size exceeded」というエラーが発生します。Header関数が自分自身を呼び出しているので永遠にループするためです

※2 … あらかじめLinkタグのコンテンツを先読みしてキャッシュに保存することで、ページ遷移の速度を向上させています。prefetch機能と呼びます。

styles/globals.css

div {
  background: url("/sample.jpg");  <-- public/sample.jpgを参照
}

BACK