はじめに
Next.jsにチャレンジしていく過程をメモ/備忘録として記録していきます。
同じようにこれから始める方の参考となればと思います。
やりたいこと
Next.jsで学習用としてTODOアプリを作ってみます。
バックエンドはDjangoにて作成しAPIでデータ取得としますが、その部分は別の記事にしていきたいと思います。
内容としては下記のような簡単なものをまずは完成させてみることを目指します。
プロジェクト>リスト>チケット>コメントでそれぞれ1対多の形。
ある程度慣れてきたら、ログイン機能や順番の入れ替えなど出来るようにしたいです。
環境
下記のDocker開発環境にて行います。
トップページ(まずは触ってみる)
今回はNext.js14が入っており、13からはAppRouterとなってますのでappディレクトリ内を触っていきます。
.
├── README.md
├── app
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── next-env.d.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
│ ├── background_neko.jpg
│ ├── next.svg
│ └── vercel.svg
├── tailwind.config.ts
└── tsconfig.json
まずはトップページを簡単に表示してみます。
ディレクトリのツリーがそのままURLとなるようでapp直下にあるpage.jsがトップページに出来そうです。
ホーム画面としてログイン前の簡単なアプリの説明文のみとします。
daisyUIのHEROを使ってみました。
愛猫の画像をpublicディレクトリに置いて、BackgroundImageとして配置。
import Image from "next/image";
import backgroundImage from "../public/background_neko.jpg"
export default function Home() {
return (
<main>
<div className="hero min-h-screen">
<Image
alt="catimage"
src={backgroundImage}
placeholder="blur"
quality={100}
fill={true}
sizes="100vw"
style={{
objectFit: 'cover',
}}
/>
<div className="hero-overlay bg-opacity-60"></div>
<div className="hero-content text-center text-neutral-100">
<div className="max-w-md">
<h1 className="mb-5 text-5xl font-bold">Nextodo</h1>
<p>Welcome! This app is a Todo app developed using Next.js.</p>
<p className="mb-5">It allows manage tasks easily. Give it a try!</p>
<button className="btn btn-secondary btn-lg">Get Started</button>
</div>
</div>
</div>
</main>
);
}
このように簡単なトップページができました。
Next.jsでTodoアプリということで Nextodo としてみました。
あとでもっと手を加えるとして今はこのまま次へ進みます。
![](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F3680973%2Fbb28f21e-3dcd-5be4-facd-135beae819e6.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=5157b9698014659780652d1f2655fa6b)
![](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F3680973%2F8e24f1fb-714b-0987-5eff-75474692e582.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=76d80a1dce243ff66d20ba7e769d100c)
アプリファイル追加
ここから早速アプリのページに入っていきます。
URLとしては下記のようにしていこうと思いますので、こちらに合わせてディレクトリを作っていきます。
No | 内容 | URL |
---|---|---|
1 | トップページ | / |
2 | プロジェクト一覧 | /nextodo |
3 | チケットを扱うメインページ | /nextodo/{projectId} |
4 | 設定ページ | /nextodo/setting |
表3の動的URLに関しては、公式ドキュメントに沿って試してみます。
とりあえずの状態で、このような形を目指します。
.
├── app
│ ├── nextodo
│ │ ├── layout.tsx ← アプリのレイアウト
│ │ ├── page.tsx ← プロジェクトリスト
│ │ ├── [projectId]
│ │ │ └── page.tsx ← メインアプリページ
│ │ └── setting
│ │ └── page.tsx ← 設定ページ
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx ← ルートレイアウト
│ └── page.tsx ← トップページ
├── components
│ └── layout
│ ├── Aside.tsx
│ ├── Footer.tsx
│ └── Header.tsx
...
アプリ用レイアウト作成
layout.tsx
ルートレイアウトはhtmlとbodyだけのデフォルトのままのため、こちらのネストされたlayout.tsxにアプリのレイアウトを記載していきます。
PCでは開いたまま、モバイルではボタンで切り替えるサイドバーを採用したく、daisyUIのDrawerをそのまま使わせてもらいました。
import type { Metadata } from "next";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import Aside from "@/components/layout/Aside";
export const metadata: Metadata = {
title: "Nextodo",
description: "Nextodo is a Todo app built with Next.js and Tailwind CSS",
};
export default function NextodoLayout({
children,
}: {
children: React.ReactNode
}) {
const drawerId = "layout-drawer";
return (
<div>
<div className="drawer lg:drawer-open">
<input id={drawerId} type="checkbox" className="drawer-toggle" />
{/* ===== Drawer content ===== */}
<div className="drawer-content flex flex-col min-h-screen">
<Header drawerId={drawerId} />
<main className="flex-grow">
{children}
</main>
<Footer/>
</div>
{/* ===== Drawer side ===== */}
<div className="drawer-side z-40">
<label htmlFor={drawerId} aria-label="close sidebar" className="drawer-overlay"></label>
<Aside/>
</div>
</div>
</div>
);
}
扱い易くするために、Header/Footer/Asideの3点はコンポーネントとして切り出ししました。
Header.tsx
・モバイルのみサイドバーを開くボタンを一番左に配置
・ロゴもPCは左寄せ、モバイルは中央寄せ
・スクロール時も一番上に固定
import Link from 'next/link'
export default function Header({drawerId}:{drawerId:string}) {
return (
<header className="w-full navbar bg-base-300 min-h-10 px-2 py-0 lg:py-2 sticky top-0">
{/* Left menu / mobile only */}
<div className="flex-none lg:hidden">
<label htmlFor={drawerId} aria-label="open sidebar" className="btn btn-square btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-6 h-6 stroke-current"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</label>
</div>
{/* Logo */}
<Link href="/" className="flex-1 px-2 mx-2 text-xl justify-center lg:justify-start">Nextodo</Link>
{/* Right menu */}
<div className="flex-none">
<Link href="/nextodo/setting" className='btn btn-sm btn-ghost'>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<span className='hidden lg:inline'>Setting</span>
</Link>
</div>
</header>
)
}
Footer.tsx
・フッターはとりあえず枠だけを設置
export default function Footer() {
return (
<footer className="footer footer-center text-base-content p-1">
<p>mkthrkw</p>
</footer>
)
}
Aside.tsx
・サイドメニューも枠だけを設置
import Link from 'next/link'
export default function Aside() {
return (
<aside className="menu px-2 py-4 w-48 min-h-full bg-base-200">
<ul>
<li><Link href="/nextodo/projectId1">project1</Link></li>
<li><Link href="/nextodo/projectId2">project2</Link></li>
</ul>
</aside>
)
}
アイコンはHeroiconsのものを使わせてもらいました。
ルーティングテスト
プロジェクト一覧(表2)
・ディレクトリnextodo配下のpage.tsxを作成
export default function ProjectList() {
return (
<>
<h1 className="text-4xl">This is ProjectList page.tsx</h1>
</>
);
}
![](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F3680973%2Fe31d5245-b0ae-66a1-9d64-a88dcd760a19.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=bcd8b3d660cea045e14e5fad5d35c8c7)
チケットを扱うメインページ(表3)
・ディレクトリnextodo配下にディレクトリ[projectId]を作成
・その配下にpage.tsxを作成
・データは仮のものを配置
import { randomInt } from "crypto";
type Params = {
projectId: string
};
export default function ProjectDetail(
{ params }: { params:Params}
) {
const Ticket = (num:number) => {
return (
<div className="w-full shadow-xl rounded-xl px-2 py-1 bg-base-content text-base-300 h-16">
<h4 className="text-sm border-b-2 border-base-200/20">Ticket title {num}</h4>
<p className="text-xs">Ticket description {num}</p>
</div>
);
}
const TicketList = () => {
const tickets = [];
for (let i = 0; i < randomInt(10); i++) {
tickets.push(Ticket(i));
}
return tickets;
}
const List = () => {
return (
<div className="w-full lg:w-72 bg-base-100 shadow-xl min-h-[90vh] lg:min-h-full rounded-xl px-3 pt-2 pb-3">
<h2 className="text-xl mb-2 border-b-2 border-base-content/50">List title!</h2>
{/* List of tickets */}
<div className="grid grid-cols-1 gap-2">
<TicketList />
</div>
</div>
);
}
const Board = () => {
return (
<div className="lg:inline-flex lg:gap-3 h-full">
<List />
<List />
<List />
<List />
<List />
</div>
);
}
return (
<div className="h-full overflow-auto px-8 lg:px-4 pt-4 pb-2">
<Board />
</div>
);
}
サイドバーのproject1/2をクリックするたびにチケットの数がランダムで変わります。
設定ページ(表4)
・ディレクトリnextodo配下にディレクトリsettingを作成
・その配下にpage.tsxを作成
export default function Setting() {
return (
<>
<h1 className="text-4xl">This is Setting page.tsx</h1>
</>
);
}
ひととおりページは無事開けました。
今回はここまで。次回以降で、中身を作っていこうと思います。
おわりに
・ひとまず触ってみて、ファイルを配置するだけでルーティングまで行ってくれたり、再読み込みなしでホットリロードが効いたりと感動でした!
・daisyUIのコンポーネントを使うだけで簡単に作れたので非常に感謝してます。
・次回は、APIでデータ取得/更新をやりたいと思います。
参考