🌊

Next.js + MUI で簡単な管理画面テンプレートを作ってみる(その1)

2024/03/17に公開

Next.jsとMaterial UI(以後MUI)を使用して、よくある管理画面テンプレートを使わずに、それなりに見える管理画面を作る方法です。

世の中にはたくさんの管理画面テンプレートがあります。検索すると無料のものから有料のものまで色々あり、どれも見栄えが良く高機能そうです。しかしそれらは以下のような問題があります。

  • 高機能すぎて中で何をやっているのかよくわからない
  • たくさんのテンプレートが生まれては消えていき、選んだテンプレートがいつまでメンテされるのかわからない

だったら、シンプルでいいので中の動きを理解でき、それなりに見栄えの良いテンプレートを自分で作っちゃおうという内容になります。

前提知識:Next.jsの基本的な知識

Material UI

Materialデザインを使用した見栄えの良いコンポーネント集です。サイトを覗くとコンポーネントの表示例とサンプルコードが以下のように参照できます。

サンプルコード
部品のイメージとサンプルコードを確認できる

https://mui.com/material-ui/getting-started/

なのでサンプルコードをコピーしてペタペタ貼っていくと、コツを掴めばCSSやデザインの知識をあまり持っていなくてもそれなりの画面を作ることができます。

開発環境を作る

create-next-app

create-next-appを実行してソースコードの雛形を作ります。ここでは admin-uiという名前で作ることにします。

$ npx create-next-app@latest admin-ui
✔ 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 to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes

完了後、admin-uiフォルダに移動してnpm run devで以下のような画面が出たらOKです。

初期画面
初期画面

初期画面とスタイルをクリアする

src/app/globals.cssの中身をバッサリと消して空にします。続いてpage.tsxの中身もHello worldだけに書き換えます。

src/app/page.tsx
import Image from "next/image";
import styles from "./page.module.css";

export default function Home() {
  return <div>Hello world</div>;
}

以下のような画面に変わります。

hello world

Material UIを入れる

パッケージを追加する

以下のコマンドを実行し、MUIをインストールします。

npm i @mui/material @emotion/react @emotion/styled @mui/material-nextjs @emotion/cache

*インストール後は npm run dev の再実行が必要です。

layoutを書き換える

{children}AppRouterCacheProviderタグで囲ってください。MUIを使うためのおまじないだと思ってください。

src/app/layout.tsx
import type { Metadata } from "next";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v14-appRouter";
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <AppRouterCacheProvider>{children}</AppRouterCacheProvider>
      </body>
    </html>
  );
}

これでMUIを使えるようになったはずです。では先ほどのpage.tsxを書き換えてみましょう。
以下のMUIのページを見ると色々な部品のサンプルコードがあるので適当に並べてみます。
https://mui.com/material-ui/getting-started/

Paper, Checkbox, Buttonのサンプルをコピーして並べてみました。

src/app/page.tsx
import styles from "./page.module.css";
import {
  Button,
  Checkbox,
  Container,
  FormControlLabel,
  FormGroup,
  Paper,
} from "@mui/material";

export default function Home() {
  return (
    <Container>
      <Paper elevation={3} sx={{ p: 5 }}>
        <h1 className={styles.title}>Welcome to Material UI!</h1>
        <FormGroup>
          <FormControlLabel
            control={<Checkbox defaultChecked />}
            label="Label"
          />
          <FormControlLabel required control={<Checkbox />} label="Required" />
          <FormControlLabel disabled control={<Checkbox />} label="Disabled" />
        </FormGroup>
        <hr />
        <Button variant="contained" color="primary">
          ボタン
        </Button>
      </Paper>
    </Container>
  );
}

それっぽい画面が簡単にできました。
MUI 画面
MUI化成功

管理画面を作る

MUIのDrawerというコンポーネントを使って管理画面のレイアウトを作ります。

alt text
Drawerコンポーネント

このサンプルのトップのナビゲーションバーと左のサイドバーをレイアウトに設定し、右側のコンテンツ部分に各ページを表示するようにします。

https://mui.com/material-ui/react-drawer/#clipped-under-the-app-bar

上記MUIのサイトでshow codeボタンを押してコードを表示し、layout.tsxに埋め込みます。

layout.tsxを編集する

準備

アイコンを表示するために、以下を参考に追加のパッケージをインストールします。

https://mui.com/material-ui/icons/

npm install @mui/icons-material

これでアイコンが表示できるようになりました。ではlayout.tsxにDrawerのサンプルコードを埋め込みましょう。

コード編集

以下のようなコードになります。

src/app/layout.tsx
"use client";
import type { Metadata } from "next";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v14-appRouter";
import { Inter } from "next/font/google";
import "./globals.css";
import {
  Box,
  CssBaseline,
  AppBar,
  Toolbar,
  Typography,
  Drawer,
  List,
  ListItem,
  ListItemButton,
  ListItemIcon,
  ListItemText,
  Divider,
} from "@mui/material";
import InboxIcon from "@mui/icons-material/MoveToInbox";
import MailIcon from "@mui/icons-material/Mail";

const inter = Inter({ subsets: ["latin"] });

const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};
const drawerWidth = 240;

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <AppRouterCacheProvider>
          <Box sx={{ display: "flex" }}>
            <CssBaseline />
            <AppBar
              position="fixed"
              sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
            >
              <Toolbar>
                <Typography variant="h6" noWrap component="div">
                  Clipped drawer
                </Typography>
              </Toolbar>
            </AppBar>
            <Drawer
              variant="permanent"
              sx={{
                width: drawerWidth,
                flexShrink: 0,
                [`& .MuiDrawer-paper`]: {
                  width: drawerWidth,
                  boxSizing: "border-box",
                },
              }}
            >
              <Toolbar />
              <Box sx={{ overflow: "auto" }}>
                <List>
                  {["Inbox", "Starred", "Send email", "Drafts"].map(
                    (text, index) => (
                      <ListItem key={text} disablePadding>
                        <ListItemButton>
                          <ListItemIcon>
                            {index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
                          </ListItemIcon>
                          <ListItemText primary={text} />
                        </ListItemButton>
                      </ListItem>
                    )
                  )}
                </List>
                <Divider />
                <List>
                  {["All mail", "Trash", "Spam"].map((text, index) => (
                    <ListItem key={text} disablePadding>
                      <ListItemButton>
                        <ListItemIcon>
                          {index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
                        </ListItemIcon>
                        <ListItemText primary={text} />
                      </ListItemButton>
                    </ListItem>
                  ))}
                </List>
              </Box>
            </Drawer>
            <Box component="main" sx={{ flexGrow: 1, p: 3 }}>
              <Toolbar />

              {children}
            </Box>
          </Box>
        </AppRouterCacheProvider>
      </body>
    </html>
  );
}

ポイントは以下です。

  • サンプルコードのTSX部分は<AppRouterCacheProvider>タグの内側に入れる
  • コンテンツ部分(<Typography paragraph>の部分)は削除し、{children}に変える(この部分がpage.tsxの内容に置き換わる)
  • zIndexを指定している部分がクライアント側でしか動かないと怒られるので"use client";を入れる(後で対処しますが一旦これに変えます)
  • "use client";を入れるとconst metadataの部分でエラーが出るのでexportを消す(後で対処しますが一旦これに変えます)

ブラウザで確認すると、以下のように表示されます。
layoutを編集
layout.tsxへの埋め込み後

どうですか?
それっぽい画面が表示されました。

ページを追加してみる

src/app/page1/page.tsxというファイルを作り、以下の内容にします。

src/app/page1/page.tsx
import React from "react";

const PageOne = () => {
  return <h1>Page1</h1>;
};

export default PageOne;

http://localhost:3000/page1にアクセスしてみましょう。新しいページがコンテンツ部分に表示されます。

新しいページを作成
新しいページの表示

layout.tsxを整理する

layout.tsxゴチャゴチャしているので整理します。ナビゲーションバー部分はnavigationbar.tsx、サイドバー部分はsidebar.tsxに切り出します。ついでにlayout.tsxの"use client";も削除します。

src/app/_componentsというフォルダを作り、そこにnavigationbar.tsxsidebar.tsxを作りましょう。各ファイルは以下のようになります。

src/app/_components/navigationbar.tsx
"use client";
import { AppBar, Toolbar, Typography } from "@mui/material";
import React from "react";

const NavigationBar = () => {
  return (
    <AppBar
      position="fixed"
      sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
    >
      <Toolbar>
        <Typography variant="h6" noWrap component="div">
          Clipped drawer
        </Typography>
      </Toolbar>
    </AppBar>
  );
};

export default NavigationBar;
src/app/_components/sidebar.tsx
import {
  Box,
  Divider,
  Drawer,
  List,
  ListItem,
  ListItemButton,
  ListItemIcon,
  ListItemText,
  Toolbar,
} from "@mui/material";
import InboxIcon from "@mui/icons-material/MoveToInbox";
import MailIcon from "@mui/icons-material/Mail";
import React from "react";

const drawerWidth = 240;

const SideBar = () => {
  return (
    <Drawer
      variant="permanent"
      sx={{
        width: drawerWidth,
        flexShrink: 0,
        [`& .MuiDrawer-paper`]: {
          width: drawerWidth,
          boxSizing: "border-box",
        },
      }}
    >
      <Toolbar />
      <Box sx={{ overflow: "auto" }}>
        <List>
          {["Inbox", "Starred", "Send email", "Drafts"].map((text, index) => (
            <ListItem key={text} disablePadding>
              <ListItemButton>
                <ListItemIcon>
                  {index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
                </ListItemIcon>
                <ListItemText primary={text} />
              </ListItemButton>
            </ListItem>
          ))}
        </List>
        <Divider />
        <List>
          {["All mail", "Trash", "Spam"].map((text, index) => (
            <ListItem key={text} disablePadding>
              <ListItemButton>
                <ListItemIcon>
                  {index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
                </ListItemIcon>
                <ListItemText primary={text} />
              </ListItemButton>
            </ListItem>
          ))}
        </List>
      </Box>
    </Drawer>
  );
};

export default SideBar;
src/app/layout.tsx
import type { Metadata } from "next";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v14-appRouter";
import { Inter } from "next/font/google";
import "./globals.css";
import { Box, CssBaseline, Toolbar } from "@mui/material";

import NavigationBar from "./_components/navigationbar";
import SideBar from "./_components/sidebar";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Material-UI Admin Page Example",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <AppRouterCacheProvider>
          <Box sx={{ display: "flex" }}>
            <CssBaseline />
            <NavigationBar />
            <SideBar />
            <Box component="main" sx={{ flexGrow: 1, p: 3 }}>
              <Toolbar />
              {children}
            </Box>
          </Box>
        </AppRouterCacheProvider>
      </body>
    </html>
  );
}

いかがでしょうか。layout.tsxがだいぶスッキリしました。zIndexの指定はnavigationbar.tsxの中で行われているので、navigationbar.tsxだけ"use client";を指定しています。

これで見た目は管理画面っぽいものができました。ただしこのままではサイドバーのメニューをクリックしても何も起きません。メニューをクリックしたらページ遷移をするようにしたいです。

長くなったので今回はここまで。次回はページ遷移を実装していきましょう。

次回へ

サンプルコード

今回紹介したコードは以下で公開しています。

https://github.com/haru/admin-ui/releases/tag/article-part1

Discussion