Back to blog

Build a URL Shortener: Frontend với React

typescriptreactfrontendtailwindcssfullstack
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 install

Cà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/vite

Cấ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 import

TypeScript 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ừ localStorage và 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/cors

Update 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ại

Thêm vào .env:

CORS_ORIGIN=http://localhost:5173

Tại sao CORS quan trọng ở đây: Khi develop, React app chạy trên localhost:5173 còn Express chạy trên localhost: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 dev

Truy 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ứ:

  1. Vite proxy trong vite.config.ts trỏ đúng backend port
  2. Express app có CORS middleware được cấu hình
  3. 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.