Build a URL Shortener: Frontend với React

Sáu bài trước chúng ta đã build một backend chắc chắn — API endpoints, database, caching, analytics, authentication. Tất cả đều test được qua curl và Postman, nhưng chẳng ai muốn shorten URL từ terminal mãi cả.
Đã đến lúc cho URL shortener một giao diện xịn. Trong bài này, chúng ta sẽ build một React frontend hoàn chỉnh với Vite, Tailwind CSS, Recharts cho analytics charts, và QR code generation. Kết thúc bài, bạn sẽ có một dashboard tương tác đầy đủ, nơi users có thể shorten URLs, track clicks, và manage links của mình.
Thời gian: 2–3 giờ
Yêu cầu: Phase 6: User Authentication & URL Management
Những gì chúng ta sẽ build:
✅ React + Vite + TypeScript project với Tailwind CSS
✅ API client với axios và JWT interceptors
✅ Auth pages — login, register, protected routes
✅ URL shortening form với validation và copy-to-clipboard
✅ Dashboard với URL list — edit, delete, share
✅ Analytics charts với Recharts — clicks theo thời gian, referrers, devices
✅ QR code generation cho mọi shortened URL
✅ Responsive layout cho mobile và desktop
Project Setup
Chúng ta sẽ tạo frontend như một project riêng bên cạnh backend. Trong monorepo bạn sẽ đặt cả hai dưới packages/, nhưng cho đơn giản ta để chúng cùng cấp:
url-shortener/
├── backend/ # Express API (đã có)
├── frontend/ # React app (mới)Khởi tạo với Vite
cd url-shortener
npm create vite@latest frontend -- --template react-ts
cd frontend
npm installCài Dependencies
# UI và routing
npm install react-router-dom axios
# Charts và QR codes
npm install recharts qrcode.react
# Tailwind CSS
npm install -D tailwindcss @tailwindcss/viteCấu hình Tailwind CSS
Thêm Tailwind plugin vào vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});Cấu hình proxy sẽ forward các request /api tới Express backend khi develop — không lo CORS issues khi chạy local.
Thay thế src/index.css bằng:
@import "tailwindcss";Cấu trúc Project
Trước khi viết components, hãy plan folder structure:
frontend/src/
├── api/
│ └── client.ts # Axios instance với JWT interceptors
├── components/
│ ├── Layout.tsx # Shared layout với navbar
│ ├── ProtectedRoute.tsx # Auth guard cho private pages
│ ├── UrlForm.tsx # URL shortening form
│ ├── UrlTable.tsx # URL list với actions
│ ├── QrCodeModal.tsx # QR code display modal
│ └── StatsCharts.tsx # Recharts analytics components
├── context/
│ └── AuthContext.tsx # Auth state management
├── pages/
│ ├── HomePage.tsx # Public landing + shorten form
│ ├── LoginPage.tsx # Login form
│ ├── RegisterPage.tsx # Registration form
│ ├── DashboardPage.tsx # URL management dashboard
│ └── AnalyticsPage.tsx # Per-URL analytics
├── types/
│ └── index.ts # Shared TypeScript interfaces
├── App.tsx # Router setup
├── main.tsx # Entry point
└── index.css # Tailwind importTypeScript Interfaces
Define shared types trong src/types/index.ts:
export interface User {
id: string;
email: string;
name: string;
}
export interface AuthResponse {
token: string;
user: User;
}
export interface ShortenedUrl {
id: string;
shortCode: string;
originalUrl: string;
shortUrl: string;
clicks: number;
createdAt: string;
expiresAt: string | null;
userId: string;
}
export interface ClickEvent {
id: string;
timestamp: string;
referrer: string | null;
userAgent: string | null;
country: string | null;
device: string;
browser: string;
}
export interface UrlStats {
url: ShortenedUrl;
totalClicks: number;
uniqueClicks: number;
clicksByDate: { date: string; clicks: number }[];
topReferrers: { referrer: string; count: number }[];
deviceBreakdown: { device: string; count: number }[];
browserBreakdown: { browser: string; count: number }[];
}
export interface ApiError {
message: string;
statusCode: number;
}API Client với JWT Interceptors
API client xử lý token storage, tự động inject header, và handle token hết hạn. Tạo src/api/client.ts:
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import type { ApiError } from '../types';
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor — gắn JWT token
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('token');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor — xử lý 401 (token hết hạn)
api.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiError>) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;Những quyết định design quan trọng:
- Request interceptor đọc JWT từ
localStoragevà thêm vào mọi request - Response interceptor bắt lỗi 401 globally — nếu token hết hạn, redirect user về login
- baseURL là
/api, Vite proxy sẽ forward tới Express khi develop
API Functions
Thêm typed API functions bên dưới interceptors:
import type {
AuthResponse,
ShortenedUrl,
UrlStats,
} from '../types';
// Auth
export const loginUser = (email: string, password: string) =>
api.post<AuthResponse>('/auth/login', { email, password });
export const registerUser = (
name: string,
email: string,
password: string
) =>
api.post<AuthResponse>('/auth/register', { name, email, password });
// URLs
export const shortenUrl = (
originalUrl: string,
customAlias?: string,
expiresIn?: number
) =>
api.post<ShortenedUrl>('/shorten', {
url: originalUrl,
customAlias,
expiresIn,
});
export const getUserUrls = () =>
api.get<ShortenedUrl[]>('/urls');
export const deleteUrl = (id: string) =>
api.delete(`/urls/${id}`);
export const updateUrl = (
id: string,
data: { originalUrl?: string; expiresAt?: string | null }
) =>
api.patch<ShortenedUrl>(`/urls/${id}`, data);
// Analytics
export const getUrlStats = (shortCode: string) =>
api.get<UrlStats>(`/urls/${shortCode}/stats`);Authentication Context
Chúng ta cần auth state accessible xuyên suốt app. Tạo src/context/AuthContext.tsx:
import {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from 'react';
import type { User } from '../types';
interface AuthContextType {
user: User | null;
token: string | null;
login: (token: string, user: User) => void;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(
undefined
);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
// Khôi phục auth state từ localStorage khi mount
useEffect(() => {
const savedToken = localStorage.getItem('token');
const savedUser = localStorage.getItem('user');
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
}
}, []);
const login = (newToken: string, newUser: User) => {
localStorage.setItem('token', newToken);
localStorage.setItem('user', JSON.stringify(newUser));
setToken(newToken);
setUser(newUser);
};
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider
value={{
user,
token,
login,
logout,
isAuthenticated: !!token,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}Protected Route Component
Guard private pages để users chưa đăng nhập bị redirect. Tạo src/components/ProtectedRoute.tsx:
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function ProtectedRoute({
children,
}: {
children: React.ReactNode;
}) {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}Layout Component
Shared layout với navigation bar. Tạo src/components/Layout.tsx:
import { Link, Outlet, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function Layout() {
const { user, isAuthenticated, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/');
};
return (
<div className="min-h-screen bg-gray-50">
{/* Navbar */}
<nav className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16 items-center">
<Link
to="/"
className="text-xl font-bold text-indigo-600"
>
🔗 ShortURL
</Link>
<div className="flex items-center gap-4">
{isAuthenticated ? (
<>
<Link
to="/dashboard"
className="text-gray-700 hover:text-indigo-600"
>
Dashboard
</Link>
<span className="text-sm text-gray-500">
{user?.email}
</span>
<button
onClick={handleLogout}
className="text-sm text-red-600 hover:text-red-800"
>
Logout
</button>
</>
) : (
<>
<Link
to="/login"
className="text-gray-700 hover:text-indigo-600"
>
Login
</Link>
<Link
to="/register"
className="bg-indigo-600 text-white px-4 py-2
rounded-lg hover:bg-indigo-700 text-sm"
>
Sign Up
</Link>
</>
)}
</div>
</div>
</div>
</nav>
{/* Nội dung trang */}
<main className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Outlet />
</main>
</div>
);
}Router Setup
Kết nối tất cả lại trong src/App.tsx:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import Layout from './components/Layout';
import ProtectedRoute from './components/ProtectedRoute';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import DashboardPage from './pages/DashboardPage';
import AnalyticsPage from './pages/AnalyticsPage';
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route element={<Layout />}>
{/* Public routes */}
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* Protected routes */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/analytics/:shortCode"
element={
<ProtectedRoute>
<AnalyticsPage />
</ProtectedRoute>
}
/>
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
);
}Auth Pages
Login Page
Tạo src/pages/LoginPage.tsx:
import { useState, FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { loginUser } from '../api/client';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { data } = await loginUser(email, password);
login(data.token, data.user);
navigate('/dashboard');
} catch (err: any) {
setError(
err.response?.data?.message || 'Login failed'
);
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md mx-auto mt-16">
<h1 className="text-2xl font-bold text-center mb-8">
Chào Mừng Trở Lại
</h1>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-2 border border-gray-300
rounded-lg focus:ring-2 focus:ring-indigo-500
focus:border-transparent"
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-2 border border-gray-300
rounded-lg focus:ring-2 focus:ring-indigo-500
focus:border-transparent"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 text-white py-2
rounded-lg hover:bg-indigo-700 disabled:opacity-50
transition-colors"
>
{loading ? 'Đang đăng nhập...' : 'Đăng Nhập'}
</button>
</form>
<p className="text-center mt-4 text-sm text-gray-600">
Chưa có account?{' '}
<Link
to="/register"
className="text-indigo-600 hover:underline"
>
Đăng ký
</Link>
</p>
</div>
);
}Register Page
Tạo src/pages/RegisterPage.tsx — cấu trúc tương tự với thêm trường name:
import { useState, FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { registerUser } from '../api/client';
export default function RegisterPage() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Password không khớp');
return;
}
if (password.length < 8) {
setError('Password phải ít nhất 8 ký tự');
return;
}
setLoading(true);
try {
const { data } = await registerUser(name, email, password);
login(data.token, data.user);
navigate('/dashboard');
} catch (err: any) {
setError(
err.response?.data?.message || 'Đăng ký thất bại'
);
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md mx-auto mt-16">
<h1 className="text-2xl font-bold text-center mb-8">
Tạo Account
</h1>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tên
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full px-4 py-2 border border-gray-300
rounded-lg focus:ring-2 focus:ring-indigo-500
focus:border-transparent"
placeholder="Nguyễn Văn A"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-2 border border-gray-300
rounded-lg focus:ring-2 focus:ring-indigo-500
focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-2 border border-gray-300
rounded-lg focus:ring-2 focus:ring-indigo-500
focus:border-transparent"
placeholder="Ít nhất 8 ký tự"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Xác nhận Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full px-4 py-2 border border-gray-300
rounded-lg focus:ring-2 focus:ring-indigo-500
focus:border-transparent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 text-white py-2
rounded-lg hover:bg-indigo-700 disabled:opacity-50
transition-colors"
>
{loading ? 'Đang tạo account...' : 'Tạo Account'}
</button>
</form>
<p className="text-center mt-4 text-sm text-gray-600">
Đã có account?{' '}
<Link
to="/login"
className="text-indigo-600 hover:underline"
>
Đăng nhập
</Link>
</p>
</div>
);
}URL Shortening Form
Phần tương tác cốt lõi — paste URL vào, nhận short link ra. Tạo src/components/UrlForm.tsx:
import { useState, FormEvent } from 'react';
import { shortenUrl } from '../api/client';
import type { ShortenedUrl } from '../types';
interface UrlFormProps {
onUrlCreated?: (url: ShortenedUrl) => void;
}
export default function UrlForm({ onUrlCreated }: UrlFormProps) {
const [originalUrl, setOriginalUrl] = useState('');
const [customAlias, setCustomAlias] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [result, setResult] = useState<ShortenedUrl | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setResult(null);
setLoading(true);
try {
const { data } = await shortenUrl(
originalUrl,
customAlias || undefined
);
setResult(data);
onUrlCreated?.(data);
setOriginalUrl('');
setCustomAlias('');
} catch (err: any) {
setError(
err.response?.data?.message || 'Không thể shorten URL'
);
} finally {
setLoading(false);
}
};
const copyToClipboard = async () => {
if (!result) return;
await navigator.clipboard.writeText(result.shortUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Dán URL dài của bạn
</label>
<input
type="url"
value={originalUrl}
onChange={(e) => setOriginalUrl(e.target.value)}
required
placeholder="https://example.com/very/long/url/that/needs/shortening"
className="w-full px-4 py-3 border border-gray-300
rounded-lg focus:ring-2 focus:ring-indigo-500
focus:border-transparent text-lg"
/>
</div>
{/* Toggle tùy chọn nâng cao */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-sm text-indigo-600 hover:underline"
>
{showAdvanced ? 'Ẩn' : 'Hiện'} tùy chọn nâng cao
</button>
{showAdvanced && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Custom alias (tùy chọn)
</label>
<div className="flex items-center gap-2">
<span className="text-gray-500 text-sm">
short.ly/
</span>
<input
type="text"
value={customAlias}
onChange={(e) => setCustomAlias(e.target.value)}
placeholder="my-custom-link"
pattern="[a-zA-Z0-9_-]+"
className="flex-1 px-4 py-2 border border-gray-300
rounded-lg focus:ring-2 focus:ring-indigo-500
focus:border-transparent"
/>
</div>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 text-white py-3
rounded-lg hover:bg-indigo-700 disabled:opacity-50
transition-colors font-medium text-lg"
>
{loading ? 'Đang shorten...' : 'Shorten URL'}
</button>
</form>
{/* Hiển thị lỗi */}
{error && (
<div className="mt-4 bg-red-50 text-red-600 p-3 rounded-lg">
{error}
</div>
)}
{/* Hiển thị kết quả */}
{result && (
<div className="mt-6 bg-green-50 border border-green-200
rounded-lg p-4"
>
<p className="text-sm text-green-700 mb-2">
URL đã được shorten thành công!
</p>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={result.shortUrl}
className="flex-1 px-3 py-2 bg-white border
border-green-300 rounded-lg font-mono text-sm"
/>
<button
onClick={copyToClipboard}
className="px-4 py-2 bg-green-600 text-white
rounded-lg hover:bg-green-700 text-sm
whitespace-nowrap transition-colors"
>
{copied ? 'Đã copy!' : 'Copy'}
</button>
</div>
</div>
)}
</div>
);
}Home Page
Tạo src/pages/HomePage.tsx:
import UrlForm from '../components/UrlForm';
export default function HomePage() {
return (
<div className="max-w-2xl mx-auto mt-16">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Rút Gọn Link Của Bạn
</h1>
<p className="text-lg text-gray-600">
Tạo short links có thể track trong vài giây.
Theo dõi clicks, referrers, và devices.
</p>
</div>
<UrlForm />
<div className="mt-12 grid grid-cols-1 sm:grid-cols-3 gap-6
text-center"
>
<div className="p-4">
<div className="text-3xl mb-2">⚡</div>
<h3 className="font-semibold text-gray-900">Nhanh</h3>
<p className="text-sm text-gray-600">
Redis-cached redirects dưới 1ms
</p>
</div>
<div className="p-4">
<div className="text-3xl mb-2">📊</div>
<h3 className="font-semibold text-gray-900">Analytics</h3>
<p className="text-sm text-gray-600">
Track clicks, referrers, và devices
</p>
</div>
<div className="p-4">
<div className="text-3xl mb-2">📱</div>
<h3 className="font-semibold text-gray-900">QR Codes</h3>
<p className="text-sm text-gray-600">
Tạo QR codes cho mọi short link
</p>
</div>
</div>
</div>
);
}Dashboard — Quản Lý URL
Dashboard hiển thị tất cả URLs mà user đã tạo, với các actions: copy, xem analytics, tạo QR codes, và delete.
URL Table Component
Tạo src/components/UrlTable.tsx:
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { deleteUrl } from '../api/client';
import type { ShortenedUrl } from '../types';
import QrCodeModal from './QrCodeModal';
interface UrlTableProps {
urls: ShortenedUrl[];
onUrlDeleted: (id: string) => void;
}
export default function UrlTable({ urls, onUrlDeleted }: UrlTableProps) {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [qrUrl, setQrUrl] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const copyToClipboard = async (url: ShortenedUrl) => {
await navigator.clipboard.writeText(url.shortUrl);
setCopiedId(url.id);
setTimeout(() => setCopiedId(null), 2000);
};
const handleDelete = async (id: string) => {
if (!confirm('Bạn có chắc muốn xóa URL này không?')) return;
setDeletingId(id);
try {
await deleteUrl(id);
onUrlDeleted(id);
} catch (err) {
alert('Không thể xóa URL');
} finally {
setDeletingId(null);
}
};
const formatDate = (dateStr: string) =>
new Date(dateStr).toLocaleDateString('vi-VN', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
if (urls.length === 0) {
return (
<div className="text-center py-12 bg-white rounded-xl
border border-gray-200"
>
<p className="text-gray-500 text-lg">
Chưa có URL nào. Hãy shorten link đầu tiên phía trên!
</p>
</div>
);
}
return (
<>
<div className="bg-white rounded-xl shadow-sm border
border-gray-200 overflow-hidden"
>
{/* Desktop table */}
<div className="hidden md:block overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs
font-medium text-gray-500 uppercase"
>
Short URL
</th>
<th className="px-6 py-3 text-left text-xs
font-medium text-gray-500 uppercase"
>
URL Gốc
</th>
<th className="px-6 py-3 text-left text-xs
font-medium text-gray-500 uppercase"
>
Clicks
</th>
<th className="px-6 py-3 text-left text-xs
font-medium text-gray-500 uppercase"
>
Ngày Tạo
</th>
<th className="px-6 py-3 text-right text-xs
font-medium text-gray-500 uppercase"
>
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{urls.map((url) => (
<tr key={url.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<span className="font-mono text-sm text-indigo-600">
{url.shortCode}
</span>
</td>
<td className="px-6 py-4 max-w-xs truncate text-sm
text-gray-600"
>
{url.originalUrl}
</td>
<td className="px-6 py-4 text-sm font-semibold">
{url.clicks}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDate(url.createdAt)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => copyToClipboard(url)}
className="text-xs px-3 py-1 rounded-md
bg-gray-100 hover:bg-gray-200
text-gray-700 transition-colors"
>
{copiedId === url.id ? 'Đã copy!' : 'Copy'}
</button>
<button
onClick={() => setQrUrl(url.shortUrl)}
className="text-xs px-3 py-1 rounded-md
bg-gray-100 hover:bg-gray-200
text-gray-700 transition-colors"
>
QR
</button>
<Link
to={`/analytics/${url.shortCode}`}
className="text-xs px-3 py-1 rounded-md
bg-indigo-100 hover:bg-indigo-200
text-indigo-700 transition-colors"
>
Stats
</Link>
<button
onClick={() => handleDelete(url.id)}
disabled={deletingId === url.id}
className="text-xs px-3 py-1 rounded-md
bg-red-100 hover:bg-red-200
text-red-700 transition-colors
disabled:opacity-50"
>
Xóa
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card layout */}
<div className="md:hidden divide-y divide-gray-200">
{urls.map((url) => (
<div key={url.id} className="p-4 space-y-2">
<div className="flex justify-between items-start">
<span className="font-mono text-sm text-indigo-600">
{url.shortCode}
</span>
<span className="text-sm font-semibold">
{url.clicks} clicks
</span>
</div>
<p className="text-sm text-gray-600 truncate">
{url.originalUrl}
</p>
<div className="flex gap-2 pt-2">
<button
onClick={() => copyToClipboard(url)}
className="text-xs px-3 py-1 rounded-md
bg-gray-100 text-gray-700"
>
{copiedId === url.id ? 'Đã copy!' : 'Copy'}
</button>
<button
onClick={() => setQrUrl(url.shortUrl)}
className="text-xs px-3 py-1 rounded-md
bg-gray-100 text-gray-700"
>
QR
</button>
<Link
to={`/analytics/${url.shortCode}`}
className="text-xs px-3 py-1 rounded-md
bg-indigo-100 text-indigo-700"
>
Stats
</Link>
<button
onClick={() => handleDelete(url.id)}
className="text-xs px-3 py-1 rounded-md
bg-red-100 text-red-700"
>
Xóa
</button>
</div>
</div>
))}
</div>
</div>
{/* QR Code Modal */}
{qrUrl && (
<QrCodeModal url={qrUrl} onClose={() => setQrUrl(null)} />
)}
</>
);
}Chú ý responsive pattern: <table> trên desktop (hidden md:block) và card layout trên mobile (md:hidden). Đây là kỹ thuật Tailwind phổ biến cho các component có nhiều data.
Dashboard Page
Tạo src/pages/DashboardPage.tsx:
import { useState, useEffect } from 'react';
import { getUserUrls } from '../api/client';
import type { ShortenedUrl } from '../types';
import UrlForm from '../components/UrlForm';
import UrlTable from '../components/UrlTable';
export default function DashboardPage() {
const [urls, setUrls] = useState<ShortenedUrl[]>([]);
const [loading, setLoading] = useState(true);
const fetchUrls = async () => {
try {
const { data } = await getUserUrls();
setUrls(data);
} catch (err) {
console.error('Failed to fetch URLs:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUrls();
}, []);
const handleUrlCreated = (newUrl: ShortenedUrl) => {
setUrls((prev) => [newUrl, ...prev]);
};
const handleUrlDeleted = (id: string) => {
setUrls((prev) => prev.filter((url) => url.id !== id));
};
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600 mt-1">
Quản lý các shortened URLs của bạn
</p>
</div>
{/* Form shorten */}
<UrlForm onUrlCreated={handleUrlCreated} />
{/* Tổng quan stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<p className="text-sm text-gray-500">Tổng URLs</p>
<p className="text-3xl font-bold text-gray-900">
{urls.length}
</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<p className="text-sm text-gray-500">Tổng Clicks</p>
<p className="text-3xl font-bold text-gray-900">
{urls.reduce((sum, url) => sum + url.clicks, 0)}
</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<p className="text-sm text-gray-500">TB Clicks/URL</p>
<p className="text-3xl font-bold text-gray-900">
{urls.length > 0
? Math.round(
urls.reduce((sum, url) => sum + url.clicks, 0) /
urls.length
)
: 0}
</p>
</div>
</div>
{/* URL table */}
{loading ? (
<div className="text-center py-12">
<p className="text-gray-500">Đang tải URLs của bạn...</p>
</div>
) : (
<UrlTable urls={urls} onUrlDeleted={handleUrlDeleted} />
)}
</div>
);
}QR Code Modal
Tạo QR codes có thể download cho mọi short URL. Tạo src/components/QrCodeModal.tsx:
import { useRef } from 'react';
import { QRCodeSVG } from 'qrcode.react';
interface QrCodeModalProps {
url: string;
onClose: () => void;
}
export default function QrCodeModal({ url, onClose }: QrCodeModalProps) {
const svgRef = useRef<HTMLDivElement>(null);
const downloadQrCode = () => {
const svg = svgRef.current?.querySelector('svg');
if (!svg) return;
// Convert SVG sang PNG để download
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const svgData = new XMLSerializer().serializeToString(svg);
const img = new Image();
canvas.width = 512;
canvas.height = 512;
img.onload = () => {
ctx?.drawImage(img, 0, 0, 512, 512);
const link = document.createElement('a');
link.download = 'qrcode.png';
link.href = canvas.toDataURL('image/png');
link.click();
};
img.src =
'data:image/svg+xml;base64,' +
btoa(unescape(encodeURIComponent(svgData)));
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center
justify-center z-50"
onClick={onClose}
>
<div
className="bg-white rounded-2xl p-8 max-w-sm w-full mx-4
shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold text-gray-900 mb-4
text-center"
>
QR Code
</h2>
<div ref={svgRef} className="flex justify-center mb-4">
<QRCodeSVG
value={url}
size={256}
level="H"
includeMargin
/>
</div>
<p className="text-sm text-gray-500 text-center mb-6
font-mono break-all"
>
{url}
</p>
<div className="flex gap-3">
<button
onClick={downloadQrCode}
className="flex-1 bg-indigo-600 text-white py-2
rounded-lg hover:bg-indigo-700 transition-colors"
>
Tải PNG
</button>
<button
onClick={onClose}
className="flex-1 bg-gray-100 text-gray-700 py-2
rounded-lg hover:bg-gray-200 transition-colors"
>
Đóng
</button>
</div>
</div>
</div>
);
}Component QRCodeSVG từ qrcode.react render SVG trực tiếp trên browser. Cho tính năng download, ta convert SVG sang canvas rồi export PNG ở kích thước 512x512 để có hình ảnh rõ nét, chất lượng cao.
Analytics Charts với Recharts
Đây là phần trực quan nhất. Chúng ta sẽ build ba loại charts: clicks theo thời gian (line chart), top referrers (bar chart), và device breakdown (pie chart).
Stats Charts Component
Tạo src/components/StatsCharts.tsx:
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
PieChart,
Pie,
Cell,
Legend,
} from 'recharts';
import type { UrlStats } from '../types';
const COLORS = [
'#6366f1', '#ec4899', '#f59e0b', '#10b981',
'#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6',
];
interface StatsChartsProps {
stats: UrlStats;
}
export default function StatsCharts({ stats }: StatsChartsProps) {
return (
<div className="space-y-8">
{/* Summary cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard label="Tổng Clicks" value={stats.totalClicks} />
<StatCard label="Unique Clicks" value={stats.uniqueClicks} />
<StatCard
label="Top Referrer"
value={stats.topReferrers[0]?.referrer || 'Direct'}
isText
/>
<StatCard
label="Top Device"
value={stats.deviceBreakdown[0]?.device || 'Unknown'}
isText
/>
</div>
{/* Clicks theo thời gian — Line chart */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Clicks Theo Thời Gian
</h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={stats.clicksByDate}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
stroke="#94a3b8"
/>
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" />
<Tooltip
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
}}
/>
<Line
type="monotone"
dataKey="clicks"
stroke="#6366f1"
strokeWidth={2}
dot={{ fill: '#6366f1', r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* Hai charts cạnh nhau */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Referrers — Bar chart */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Top Referrers
</h3>
{stats.topReferrers.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={stats.topReferrers.slice(0, 5)}
layout="vertical"
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#f1f5f9"
/>
<XAxis type="number" stroke="#94a3b8" />
<YAxis
type="category"
dataKey="referrer"
width={120}
tick={{ fontSize: 12 }}
stroke="#94a3b8"
/>
<Tooltip />
<Bar
dataKey="count"
fill="#6366f1"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-gray-500 text-center py-8">
Chưa có dữ liệu referrer
</p>
)}
</div>
{/* Device Breakdown — Pie chart */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Phân Bố Device
</h3>
{stats.deviceBreakdown.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={stats.deviceBreakdown}
dataKey="count"
nameKey="device"
cx="50%"
cy="50%"
outerRadius={100}
label={({ device, percent }) =>
`${device} ${(percent * 100).toFixed(0)}%`
}
>
{stats.deviceBreakdown.map((_, index) => (
<Cell
key={index}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-gray-500 text-center py-8">
Chưa có dữ liệu device
</p>
)}
</div>
</div>
</div>
);
}
// Stat card tái sử dụng
function StatCard({
label,
value,
isText = false,
}: {
label: string;
value: string | number;
isText?: boolean;
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<p className="text-sm text-gray-500">{label}</p>
<p
className={`mt-1 font-bold ${
isText
? 'text-lg text-gray-900 truncate'
: 'text-3xl text-gray-900'
}`}
>
{value}
</p>
</div>
);
}Analytics Page
Tạo src/pages/AnalyticsPage.tsx:
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { getUrlStats } from '../api/client';
import type { UrlStats } from '../types';
import StatsCharts from '../components/StatsCharts';
export default function AnalyticsPage() {
const { shortCode } = useParams<{ shortCode: string }>();
const [stats, setStats] = useState<UrlStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchStats = async () => {
if (!shortCode) return;
try {
const { data } = await getUrlStats(shortCode);
setStats(data);
} catch (err: any) {
setError(
err.response?.data?.message || 'Không thể tải analytics'
);
} finally {
setLoading(false);
}
};
fetchStats();
}, [shortCode]);
if (loading) {
return (
<div className="text-center py-16">
<p className="text-gray-500 text-lg">Đang tải analytics...</p>
</div>
);
}
if (error || !stats) {
return (
<div className="text-center py-16">
<p className="text-red-600 text-lg">{error}</p>
<Link
to="/dashboard"
className="text-indigo-600 hover:underline mt-4 inline-block"
>
Quay lại Dashboard
</Link>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<Link
to="/dashboard"
className="text-sm text-indigo-600 hover:underline"
>
← Quay lại Dashboard
</Link>
<h1 className="text-2xl font-bold text-gray-900 mt-2">
Analytics: {shortCode}
</h1>
<p className="text-sm text-gray-500 mt-1 truncate max-w-lg">
{stats.url.originalUrl}
</p>
</div>
</div>
{/* Charts */}
<StatsCharts stats={stats} />
</div>
);
}Luồng Dữ Liệu
Cấu Hình CORS Trên Backend
Trước khi chạy frontend, cần cấu hình CORS trên Express backend để chấp nhận requests từ Vite dev server.
Cài package CORS:
cd backend
npm install cors
npm install -D @types/corsUpdate Express app:
// backend/src/app.ts
import cors from 'cors';
const app = express();
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
}));
app.use(express.json());
// ... phần middleware và routes còn lạiThêm vào .env:
CORS_ORIGIN=http://localhost:5173Tại sao CORS quan trọng ở đây: Khi develop, React app chạy trên
localhost:5173còn Express chạy trênlocalhost:3000. Chúng là different origins, nên browser block API requests mặc định. Vite proxy đã xử lý điều này khi develop, nhưng CORS configuration vẫn cần cho production deployments khi frontend có thể ở domain khác hoặc được serve từ CDN.
Chạy Full Stack
Mở hai terminals:
# Terminal 1 — Backend
cd url-shortener/backend
npm run dev
# Terminal 2 — Frontend
cd url-shortener/frontend
npm run devTruy cập http://localhost:5173 — bạn sẽ thấy home page với URL shortening form. Register account, shorten vài URLs, rồi click qua dashboard và analytics pages.
Tổng Quan Component Architecture
Responsive Design Patterns
Tailwind làm cho responsive design trở nên đơn giản với mobile-first breakpoints. Đây là các patterns chúng ta dùng xuyên suốt:
Pattern 1: Desktop Table → Mobile Cards
{/* Chỉ desktop */}
<div className="hidden md:block">
<table>...</table>
</div>
{/* Chỉ mobile */}
<div className="md:hidden">
{items.map(item => <Card key={item.id} />)}
</div>Pattern 2: Grid Column Adjustments
// 1 cột trên mobile, 2 trên tablet, 3 trên desktop
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">Pattern 3: Responsive Typography
// Text nhỏ hơn trên mobile, lớn hơn trên desktop
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold">Pattern 4: Flex Wrapping cho Action Buttons
// Buttons xếp dọc trên màn hình nhỏ
<div className="flex flex-wrap gap-2">
<button>Copy</button>
<button>QR</button>
<button>Stats</button>
</div>Những Lỗi Thường Gặp
1. Token Lưu Trong localStorage
Cho project này, localStorage là lựa chọn thực tế. Nó tồn tại qua page refreshes và đơn giản để implement. Trong production app, hãy cân nhắc:
- httpOnly cookies — an toàn hơn trước XSS attacks
- Token rotation — refresh tokens trước khi hết hạn
- Secure flag — đảm bảo cookies chỉ gửi qua HTTPS
2. Thiếu Loading States
Mọi API call đều nên có loading state. Không có nó, users click buttons nhiều lần, gây duplicate requests:
// Luôn track loading state
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
setLoading(true);
try {
await apiCall();
} finally {
setLoading(false);
}
};
// Disable button khi đang loading
<button disabled={loading}>
{loading ? 'Đang xử lý...' : 'Submit'}
</button>3. Không Handle Empty States
Khi không có data, hiển thị message hữu ích thay vì trang trống:
if (urls.length === 0) {
return <p>Chưa có URL nào. Tạo link đầu tiên nào!</p>;
}4. Quên Proxy API Requests
Nếu bạn thấy CORS errors khi develop, check ba thứ:
- Vite proxy trong
vite.config.tstrỏ đúng backend port - Express app có CORS middleware được cấu hình
- API calls dùng relative paths (
/api/shorten) chứ không phải absolute URLs
Tiếp Theo Là Gì?
URL shortener giờ đã có frontend hoàn chỉnh — users có thể register, shorten URLs, manage links trong dashboard, xem analytics với interactive charts, và generate QR codes. Nhưng làm sao biết tất cả hoạt động đúng?
Trong Phase 8: Testing Strategy, chúng ta sẽ thêm comprehensive testing:
- Unit tests với Vitest cho services và utilities
- Integration tests với Supertest cho API endpoints
- Frontend component tests với React Testing Library
- End-to-end testing strategy
- Load testing với k6 cho redirect performance
Testing biến "nó chạy trên máy tôi" thành "nó chạy mọi nơi, mọi lúc."
Series: Build a URL Shortener
Previous: Phase 6: User Authentication & URL Management
Next: Phase 8: Testing Strategy
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.