
React Tailwind 管理画面を短期間で用意したいとき、無料テンプレートのTailAdminは有力な出発点です。
サイドバー、表、チャート、登録画面、ログイン画面まで一式入っており、見た目の土台はすぐ揃います。
ただし、画面にある「Sign Up」「Sign In」は見た目だけです。
押してもユーザーは保存されず、ログイン判定も行われません。
本記事では、最も一般的な構成であるReactフロント+Express API+MySQL+JWTで、用意済みの登録・ログインUIを実認証までつなぎます。
フォルダ構成とソースコードを具体的に示し、処理の流れが追えるようにします。
TailAdmin is a free and open-source admin dashboard template built on React and Tailwind CSS, providing developers with everything they need to create a comprehensive, data-driven back-end, dashboard, or admin panel solution for upcoming web projects.
引用:TailAdmin/free-react-tailwind-admin-dashboard – GitHub
TailAdminで揃うものと、自分で作るもの
TailAdminの無料版は、React 19、TypeScript、Tailwind CSS v4、Viteで動く管理画面です。
認証まわりは /signin と /signup のルート、ページ、フォームコンポーネントまで用意されています。
公式デモでも、同じパスで登録・ログイン画面を確認できます。
一方で、メールアドレスの重複チェック、パスワードのハッシュ化、トークン発行、ログイン後の画面ガードは含まれません。src/components/auth/SignInForm.tsx と SignUpForm.tsx を見ると、<form> に onSubmit がなく、入力欄にも value が付いていません。
UIを残したまま、送信処理と状態管理だけを足すのが正しい進め方です。
TailAdminは管理画面の見た目を担当します。
ユーザー情報の保存とログイン判定は、必ず別プロセスのAPIとデータベースで行います。
React Tailwind管理画面で使う認証構成
ブラウザだけでMySQLに接続してはいけません。
接続情報やパスワード検証をフロントに置くと、改ざんで突破されます。
そのため、画面はTailAdmin、認証処理はAPI、永続化はMySQL、という3層に分けます。
学習記事や案件でいちばんよく見る組み合わせは次のとおりです。
| 層 | 役割 | 採用 |
|---|---|---|
| フロント | 画面・入力・トークン保持 | TailAdmin(React / Tailwind) |
| API | 登録・ログイン・本人確認 | Express |
| DB | ユーザー保存 | MySQL |
| 認証方式 | ログイン状態の証明 | JWT + bcrypt |
パスワードは平文で保存しません。
bcryptjs でハッシュ化し、照合だけ行います。
ログイン成功後は jsonwebtoken でJWTを発行し、以降のAPIはトークンで本人を確認します。
セッションCookie方式もありますが、React管理画面の入門ではJWTの方が手順が追いやすいです。
本番ではHttpOnly Cookieへ移す余地を残しつつ、まずはこの構成で流れを掴みます。
フォルダ構成
TailAdminのリポジトリを壊さないよう、フロントとAPIを兄弟ディレクトリに置きます。
1つのGitリポジトリの中に frontend と backend を並べる形が、初心者にも分かりやすいです。
admin-app/
├── frontend/ # TailAdminをクローンしたReactアプリ
│ ├── src/
│ │ ├── api/
│ │ │ └── auth.ts # 登録・ログインのfetch
│ │ ├── components/auth/
│ │ │ ├── SignInForm.tsx
│ │ │ ├── SignUpForm.tsx
│ │ │ └── ProtectedRoute.tsx
│ │ ├── context/
│ │ │ └── AuthContext.tsx
│ │ └── App.tsx
│ └── vite.config.ts
└── backend/
├── src/
│ ├── config/
│ │ └── db.js
│ ├── middleware/
│ │ └── auth.js
│ ├── routes/
│ │ └── auth.js
│ └── server.js
├── .env
└── package.json
フロントは公式リポジトリをそのまま使います。
追加するのは src/api、AuthContext、ProtectedRoute と、既存フォームへの送信処理だけです。
API側はExpressの定番配置です。
最初からMVCを細かく分けすぎると迷子になるため、認証はルート1ファイルにまとめています。
TailAdminの認証画面の場所
無料版の認証UIは、次のパスにあります。
- ページ:
src/pages/AuthPages/SignIn.tsx/SignUp.tsx - フォーム:
src/components/auth/SignInForm.tsx/SignUpForm.tsx - ルート:
/signinと/signup(src/App.tsx)
登録フォームの項目は First Name、Last Name、Email、Password です。
ログインフォームの項目は Email と Password です。
APIのJSONも、この項目名に合わせると接続が楽です。
環境を用意する
TailAdminは Node.js 20以上を前提にしています。
MySQL 8系をローカル、または開発用サーバーに用意してください。
フロントは次で起動できます。
git clone https://github.com/TailAdmin/free-react-tailwind-admin-dashboard.git frontend
cd frontend
npm install
npm run dev
別ターミナルでAPI用ディレクトリを作ります。
mkdir -p backend/src/{config,middleware,routes}
cd backend
npm init -y
npm install express mysql2 bcryptjs jsonwebtoken cors dotenv
npm install -D nodemon
package.json の scripts に "dev": "nodemon src/server.js" を足します。
APIは http://localhost:3001、Viteは http://localhost:5173 で動かします。
MySQLにユーザーテーブルを作る
認証に必要な列は、氏名、メール、パスワードハッシュ、作成日時です。
メールはログインIDになるため、UNIQUE制約を付けます。
CREATE DATABASE tailadmin_app
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE tailadmin_app;
CREATE TABLE users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
password という列名は使いません。
平文を入れないことを、列名の時点で明示します。
bcryptのハッシュは60文字前後ですが、余裕を見て255にしています。
Express APIを実装する
ここからが認証の本体です。
処理は「登録で行を増やす」「ログインでハッシュを照合する」「JWTを検証する」の3つだけです。
環境変数とDB接続
backend/.env に接続情報を置きます。
このファイルはGitに含めません。
PORT=3001
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=app_user
DB_PASSWORD=change-me
DB_NAME=tailadmin_app
JWT_SECRET=replace-with-long-random-string
JWT_EXPIRES_IN=7d
FRONTEND_ORIGIN=http://localhost:5173
JWT_SECRET は推測できない長い文字列にします。
短かったりサンプルのまま本番に出すと、トークンを偽造されます。
// backend/src/config/db.js
require("dotenv").config();
const mysql = require("mysql2/promise");
const pool = mysql.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
});
module.exports = pool;
mysql2 のPromise版を使うと、async/await でSQLを書けます。
リクエストごとに接続を作り直さず、プールを共有します。
登録とログインのルート
APIの入口は次の3本です。
POST /api/auth/register… ユーザー登録POST /api/auth/login… ログインGET /api/auth/me… トークンから現在のユーザーを返す
// backend/src/routes/auth.js
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const pool = require("../config/db");
const authMiddleware = require("../middleware/auth");
const router = express.Router();
const SALT_ROUNDS = 10;
function createToken(user) {
return jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || "7d" }
);
}
function toPublicUser(row) {
return {
id: row.id,
firstName: row.first_name,
lastName: row.last_name,
email: row.email,
};
}
router.post("/register", async (req, res) => {
try {
const firstName = String(req.body.firstName || "").trim();
const lastName = String(req.body.lastName || "").trim();
const email = String(req.body.email || "").trim().toLowerCase();
const password = String(req.body.password || "");
if (!firstName || !lastName || !email || password.length < 8) {
return res.status(400).json({
message: "氏名・メール・8文字以上のパスワードを入力してください。",
});
}
const [existing] = await pool.query(
"SELECT id FROM users WHERE email = ? LIMIT 1",
[email]
);
if (existing.length > 0) {
return res.status(409).json({ message: "このメールアドレスは登録済みです。" });
}
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
const [result] = await pool.query(
INSERT INTO users (first_name, last_name, email, password_hash)
VALUES (?, ?, ?, ?),
[firstName, lastName, email, passwordHash]
);
const user = {
id: result.insertId,
first_name: firstName,
last_name: lastName,
email,
};
return res.status(201).json({
token: createToken(user),
user: toPublicUser(user),
});
} catch (error) {
console.error(error);
return res.status(500).json({ message: "登録に失敗しました。" });
}
});
router.post("/login", async (req, res) => {
try {
const email = String(req.body.email || "").trim().toLowerCase();
const password = String(req.body.password || "");
const [rows] = await pool.query(
SELECT id, first_name, last_name, email, password_hash
FROM users WHERE email = ? LIMIT 1,
[email]
);
const user = rows[0];
const ok = user ? await bcrypt.compare(password, user.password_hash) : false;
if (!ok) {
return res.status(401).json({
message: "メールアドレスまたはパスワードが正しくありません。",
});
}
return res.json({
token: createToken(user),
user: toPublicUser(user),
});
} catch (error) {
console.error(error);
return res.status(500).json({ message: "ログインに失敗しました。" });
}
});
router.get("/me", authMiddleware, async (req, res) => {
const [rows] = await pool.query(
SELECT id, first_name, last_name, email FROM users WHERE id = ? LIMIT 1,
[req.user.userId]
);
if (rows.length === 0) {
return res.status(404).json({ message: "ユーザーが見つかりません。" });
}
return res.json({ user: toPublicUser(rows[0]) });
});
module.exports = router;
ログイン失敗時は、メールが存在しない場合とパスワード違いを分けません。
存在確認だけ先に返すと、他人のメールが登録済みかどうか探られます。
プレースホルダ ? を使い、文字列結合でSQLを組み立てないでください。
JWTを検証するミドルウェア
ダッシュボード用APIを増やすとき、同じ関数を先頭に置きます。
// backend/src/middleware/auth.js
const jwt = require("jsonwebtoken");
module.exports = function authMiddleware(req, res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
if (!token) {
return res.status(401).json({ message: "ログインが必要です。" });
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (error) {
return res.status(401).json({ message: "トークンが無効です。" });
}
};
サーバー起動とCORS
開発中はViteとAPIのオリジンが違うため、CORSを許可します。
// backend/src/server.js
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const authRouter = require("./routes/auth");
const app = express();
app.use(
cors({
origin: process.env.FRONTEND_ORIGIN,
credentials: true,
})
);
app.use(express.json());
app.use("/api/auth", authRouter);
const port = Number(process.env.PORT || 3001);
app.listen(port, () => {
console.log(API listening on http://localhost:${port});
});
本番で同一ドメインのサブディレクトリにAPIを置く場合、CORSよりリバースプロキシの方が単純です。
同じホストでNode.js環境を分ける手順は、同じドメインでサブディレクトリごとにNode.js環境を分けて運用する方法で解説しています。
TailAdminのログイン画面をAPIにつなぐ
フロントは既存コンポーネントを活かし、通信と状態だけを足します。
開発時はViteのプロキシで /api をAPIへ転送すると、CORSを気にせず fetch("/api/...") と書けます。
// frontend/vite.config.ts の defineConfig に追加
server: {
proxy: {
"/api": "http://localhost:3001",
},
},
APIクライアント
fetchを各画面に散らさず、1ファイルにまとめます。
// frontend/src/api/auth.ts
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
export type AuthUser = {
id: number;
firstName: string;
lastName: string;
email: string;
};
type AuthResponse = {
token: string;
user: AuthUser;
};
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem("token") ?? sessionStorage.getItem("token");
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
if (token) {
headers.set("Authorization", Bearer ${token});
}
const res = await fetch(${API_BASE}${path}, { ...options, headers });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || "通信に失敗しました。");
}
return data as T;
}
export function registerUser(body: {
firstName: string;
lastName: string;
email: string;
password: string;
}) {
return request<AuthResponse>("/api/auth/register", {
method: "POST",
body: JSON.stringify(body),
});
}
export function loginUser(body: { email: string; password: string }) {
return request<AuthResponse>("/api/auth/login", {
method: "POST",
body: JSON.stringify(body),
});
}
export function fetchMe() {
return request<{ user: AuthUser }>("/api/auth/me");
}
ログイン状態をContextで持つ
TailAdminにはもともと src/context があります。
ここに認証用Contextを足すと、ヘッダーやガードから同じ状態を読めます。
// frontend/src/context/AuthContext.tsx
import { fetchMe, type AuthUser } from "@/api/auth";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
type AuthContextValue = {
user: AuthUser | null;
token: string | null;
ready: boolean;
setSession: (token: string, user: AuthUser, persist: boolean) => void;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
const saved =
localStorage.getItem("token") ?? sessionStorage.getItem("token");
if (!saved) {
setReady(true);
return;
}
setToken(saved);
fetchMe()
.then((data) => setUser(data.user))
.catch(() => {
localStorage.removeItem("token");
sessionStorage.removeItem("token");
setToken(null);
})
.finally(() => setReady(true));
}, []);
const value = useMemo(
() => ({
user,
token,
ready,
setSession: (nextToken, nextUser, persist) => {
const storage = persist ? localStorage : sessionStorage;
localStorage.removeItem("token");
sessionStorage.removeItem("token");
storage.setItem("token", nextToken);
setToken(nextToken);
setUser(nextUser);
},
logout: () => {
localStorage.removeItem("token");
sessionStorage.removeItem("token");
setToken(null);
setUser(null);
},
}),
[user, token, ready]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within AuthProvider");
}
return ctx;
}
「Keep me logged in」にチェックがあれば localStorage、なければ sessionStorage にトークンを置きます。
タブを閉じたら消したい場合は、チェックを外したログインにします。localStorage はXSSで読まれる可能性があるため、公開サイトではCookie方式を検討してください。
SignInFormに送信処理を足す
見た目のJSXは残し、状態と onSubmit だけを足します。
既存の Input は value と onChange を受け取れます。
// SignInForm.tsx の追加分
import { loginUser } from "@/api/auth";
import { useAuth } from "@/context/AuthContext";
import { useNavigate } from "react-router";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { setSession } = useAuth();
const navigate = useNavigate();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const data = await loginUser({ email, password });
setSession(data.token, data.user, isChecked);
navigate("/");
} catch (err) {
setError(err instanceof Error ? err.message : "ログインに失敗しました。");
} finally {
setLoading(false);
}
}
フォーム側は次のように結びます。
<form onSubmit={handleSubmit}>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="info@gmail.com"
/>
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
/>
{error ? <p className="text-error-500 text-sm">{error}</p> : null}
<Button className="w-full" size="sm" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
TailAdminの Button は type を渡しません。
HTMLの初期値は submit なので、form の onSubmit で受け取れます。
SignUpFormに送信処理を足す
登録画面は fname / lname / email / password をstateにします。
APIへはキャメルケースで送り、サーバー側でスネークケースの列へ変換します。
// SignUpForm.tsx の追加分
import { registerUser } from "@/api/auth";
import { useAuth } from "@/context/AuthContext";
import { useNavigate } from "react-router";
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!isChecked) {
setError("利用規約への同意が必要です。");
return;
}
const data = await registerUser({ firstName, lastName, email, password });
setSession(data.token, data.user, true);
navigate("/");
}
GoogleとXのボタンは、無料版では見た目だけです。
OAuthまで必要なら、別途Google CloudのクライアントIDとコールバックAPIが要ります。
最初の認証ではメール+パスワードに集中した方が迷いません。
未ログインなら管理画面を開けなくする
今の App.tsx は、認証なしで / のダッシュボードを表示します。
これではログインの意味がありません。
ダッシュボード配下をガードで包みます。
// frontend/src/components/auth/ProtectedRoute.tsx
import { useAuth } from "@/context/AuthContext";
import { Navigate, Outlet } from "react-router";
export default function ProtectedRoute() {
const { token, ready } = useAuth();
if (!ready) {
return null;
}
if (!token) {
return <Navigate to="/signin" replace />;
}
return <Outlet />;
}
App.tsx では、AppLayout の外側に ProtectedRoute を置きます。
現行のTailAdminは main.tsx で ThemeProvider と LanguageProvider に包まれているので、その内側に AuthProvider を足します。
// frontend/src/main.tsx の一部
import { AuthProvider } from "./context/AuthContext.tsx";
<ThemeProvider>
<LanguageProvider>
<AuthProvider>
<AppWrapper>
<App />
</AppWrapper>
</AuthProvider>
</LanguageProvider>
</ThemeProvider>
ルートは次のように、認証が必要な画面だけをガードします。
<Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}>
<Route index path="/" element={<Home />} />
{/* 既存のダッシュボード配下ルート */}
</Route>
</Route>
<Route path="/signin" element={<SignIn />} />
<Route path="/signup" element={<SignUp />} />
これで未ログインのアクセスは /signin へ戻ります。
トークンがあってもAPIが401を返せば、AuthContext の初期化でログアウト扱いになります。
動きを確認する順番
一度に全部つなぐと、失敗箇所が分かりません。
次の順で確かめると切り分けやすいです。
- MySQLで
usersテーブルがあることを確認する npm run devでAPIを起動し、POST /api/auth/registerをcurlやRESTクライアントで叩く- 同じメールで再登録し、409になることを確認する
POST /api/auth/loginでトークンが返ることを確認する- TailAdminの
/signupから画面登録し、ダッシュボードへ遷移することを確認する - ログアウト相当でトークンを消し、直接
/を開いて/signinへ戻ることを確認する
API単体の確認例です。
curl -X POST http://localhost:3001/api/auth/register \
-H "Content-Type: application/json" \
-d '{"firstName":"Taro","lastName":"Yamada","email":"taro@example.com","password":"password1"}'
DBの password_hash が $2a$ や $2b$ で始まっていれば、ハッシュ化は成功しています。
画面からログインできないときは、まずこのcurlが通るかを見てください。
実装後に足すとよいもの
ここまでで「登録してログインし、管理画面を開く」は完成します。
運用に乗せる前に、次を検討します。
- パスワードリセット(メール送信が必要)
- レート制限(ログイン連続失敗の制限)
- JWTをHttpOnly Cookieへ移す
- HTTPS終端と証明書更新
同一ドメイン配下へAPIを置く話は、前述のサブディレクトリでNode.js環境を分ける記事が近いです。
ユーザー登録のあと、注文や記事など別テーブルを結合して取る段階では、Node.jsとMongoDBで複数のデータを結びつけて取得する方法の考え方が参考になります。
データベースはMySQLでも、関連データをAPIでまとめて返す設計は共通です。
まとめ
React Tailwind 管理画面としてTailAdminを使う場合、登録・ログインの見た目は最初からあります。
足りないのは、MySQLへの保存と、APIでの照合です。
フロントとExpressを分け、JWTとbcryptでつなぐ構成が、いちばん説明しやすく再利用しやすいです。
まずはcurlでAPIを通し、そのあと既存の SignInForm / SignUpForm に onSubmit を足してください。
画面を作り直す必要はありません。
公式テンプレートの入手と画面構成は、TailAdminのドキュメントも合わせて確認すると安心です。
- Original:https://minory.org/tailadmin-mysql-auth.html
- Source:minory
- Author:管理者
Amazonベストセラー
Now loading...