Back to blog

Build a URL Shortener: Frontend with React

typescriptreactfrontendtailwindcssfullstack
Build a URL Shortener: Frontend with React

We've spent six posts building a rock-solid backend — API endpoints, database, caching, analytics, authentication. All testable via curl and Postman, but nobody wants to shorten URLs from a terminal forever.

Time to give our URL shortener a face. In this post, we'll build a complete React frontend with Vite, Tailwind CSS, Recharts for analytics charts, and QR code generation. By the end, you'll have a fully interactive dashboard where users can shorten URLs, track clicks, and manage their links.

Time commitment: 2–3 hours
Prerequisites: Phase 6: User Authentication & URL Management

What we'll build in this post:
✅ React + Vite + TypeScript project with Tailwind CSS
✅ API client with axios and JWT interceptors
✅ Auth pages — login, register, protected routes
✅ URL shortening form with validation and copy-to-clipboard
✅ Dashboard with URL list — edit, delete, share
✅ Analytics charts with Recharts — clicks over time, referrers, devices
✅ QR code generation for every shortened URL
✅ Responsive layout for mobile and desktop


Project Setup

We'll create the frontend as a separate project alongside the backend. In a monorepo you'd put both under a packages/ folder, but for simplicity we'll keep them as siblings:

url-shortener/
├── backend/          # Express API (existing)
├── frontend/         # React app (new)

Initialize with Vite

cd url-shortener
npm create vite@latest frontend -- --template react-ts
cd frontend
npm install

Install Dependencies

# UI and routing
npm install react-router-dom axios
 
# Charts and QR codes
npm install recharts qrcode.react
 
# Tailwind CSS
npm install -D tailwindcss @tailwindcss/vite

Configure Tailwind CSS

Add the Tailwind plugin to 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,
      },
    },
  },
});

The proxy configuration forwards /api requests to our Express backend during development — no CORS issues locally.

Replace src/index.css with:

@import "tailwindcss";

Project Structure

Before writing components, let's plan our folder structure:

frontend/src/
├── api/
│   └── client.ts           # Axios instance with JWT interceptors
├── components/
│   ├── Layout.tsx           # Shared layout with navbar
│   ├── ProtectedRoute.tsx   # Auth guard for private pages
│   ├── UrlForm.tsx          # URL shortening form
│   ├── UrlTable.tsx         # URL list with 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 our shared types in 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 with JWT Interceptors

The API client handles token storage, automatic header injection, and token expiry. Create 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 — attach 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 — handle 401 (expired token)
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;

Key design decisions:

  • Request interceptor reads the JWT from localStorage and adds it to every request
  • Response interceptor catches 401 errors globally — if the token expires, the user is redirected to login
  • baseURL is /api which the Vite proxy forwards to Express during development

API Functions

Add typed API functions below the 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

We need auth state accessible throughout the app. Create 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);
 
  // Restore auth state from localStorage on 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 so unauthenticated users get redirected. Create 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

A shared layout with navigation bar. Create 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>
 
      {/* Page content */}
      <main className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
        <Outlet />
      </main>
    </div>
  );
}

Router Setup

Wire everything together in 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

Create 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">
        Welcome Back
      </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 ? 'Signing in...' : 'Sign In'}
        </button>
      </form>
 
      <p className="text-center mt-4 text-sm text-gray-600">
        Don't have an account?{' '}
        <Link
          to="/register"
          className="text-indigo-600 hover:underline"
        >
          Sign up
        </Link>
      </p>
    </div>
  );
}

Register Page

Create src/pages/RegisterPage.tsx — similar structure with an extra name field:

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('Passwords do not match');
      return;
    }
 
    if (password.length < 8) {
      setError('Password must be at least 8 characters');
      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 || 'Registration failed'
      );
    } finally {
      setLoading(false);
    }
  };
 
  return (
    <div className="max-w-md mx-auto mt-16">
      <h1 className="text-2xl font-bold text-center mb-8">
        Create 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">
            Name
          </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="John Doe"
          />
        </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="At least 8 characters"
          />
        </div>
 
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Confirm 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 ? 'Creating account...' : 'Create Account'}
        </button>
      </form>
 
      <p className="text-center mt-4 text-sm text-gray-600">
        Already have an account?{' '}
        <Link
          to="/login"
          className="text-indigo-600 hover:underline"
        >
          Sign in
        </Link>
      </p>
    </div>
  );
}

URL Shortening Form

The core interaction — paste a URL, get a short link. Create 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 || 'Failed to 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">
            Paste your long URL
          </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>
 
        {/* Advanced options toggle */}
        <button
          type="button"
          onClick={() => setShowAdvanced(!showAdvanced)}
          className="text-sm text-indigo-600 hover:underline"
        >
          {showAdvanced ? 'Hide' : 'Show'} advanced options
        </button>
 
        {showAdvanced && (
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">
              Custom alias (optional)
            </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 ? 'Shortening...' : 'Shorten URL'}
        </button>
      </form>
 
      {/* Error display */}
      {error && (
        <div className="mt-4 bg-red-50 text-red-600 p-3 rounded-lg">
          {error}
        </div>
      )}
 
      {/* Result display */}
      {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 shortened successfully!
          </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 ? 'Copied!' : 'Copy'}
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

Home Page

Create 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">
          Shorten Your Links
        </h1>
        <p className="text-lg text-gray-600">
          Create short, trackable links in seconds.
          Monitor clicks, referrers, and 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">Fast</h3>
          <p className="text-sm text-gray-600">
            Redis-cached redirects in under 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, and 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">
            Generate QR codes for any short link
          </p>
        </div>
      </div>
    </div>
  );
}

Dashboard — URL Management

The dashboard shows all URLs the user has created, with actions to copy, view analytics, generate QR codes, and delete.

URL Table Component

Create 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('Are you sure you want to delete this URL?')) return;
    setDeletingId(id);
    try {
      await deleteUrl(id);
      onUrlDeleted(id);
    } catch (err) {
      alert('Failed to delete URL');
    } finally {
      setDeletingId(null);
    }
  };
 
  const formatDate = (dateStr: string) =>
    new Date(dateStr).toLocaleDateString('en-US', {
      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">
          No URLs yet. Shorten your first link above!
        </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"
                >
                  Original URL
                </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"
                >
                  Created
                </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 ? 'Copied!' : '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"
                      >
                        Delete
                      </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 ? 'Copied!' : '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"
                >
                  Delete
                </button>
              </div>
            </div>
          ))}
        </div>
      </div>
 
      {/* QR Code Modal */}
      {qrUrl && (
        <QrCodeModal url={qrUrl} onClose={() => setQrUrl(null)} />
      )}
    </>
  );
}

Notice the responsive pattern: a <table> on desktop (hidden md:block) and a card layout on mobile (md:hidden). This is a common Tailwind technique for data-heavy components.

Dashboard Page

Create 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">
          Manage your shortened URLs
        </p>
      </div>
 
      {/* Shorten form */}
      <UrlForm onUrlCreated={handleUrlCreated} />
 
      {/* URL stats summary */}
      <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">Total 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">Total 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">Avg. 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">Loading your URLs...</p>
        </div>
      ) : (
        <UrlTable urls={urls} onUrlDeleted={handleUrlDeleted} />
      )}
    </div>
  );
}

QR Code Modal

Generate downloadable QR codes for any short URL. Create 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 to PNG for 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"
          >
            Download PNG
          </button>
          <button
            onClick={onClose}
            className="flex-1 bg-gray-100 text-gray-700 py-2
              rounded-lg hover:bg-gray-200 transition-colors"
          >
            Close
          </button>
        </div>
      </div>
    </div>
  );
}

The QRCodeSVG component from qrcode.react renders SVG in the browser. For the download feature, we convert SVG to a canvas, then export as PNG at 512x512 for a clean, high-resolution image.


Analytics Charts with Recharts

This is the most visually rewarding part. We'll build three charts: clicks over time (line chart), top referrers (bar chart), and device breakdown (pie chart).

Stats Charts Component

Create 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="Total 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 over time — 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 Over Time
        </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>
 
      {/* Two charts side by side */}
      <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">
              No referrer data yet
            </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">
            Device Breakdown
          </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">
              No device data yet
            </p>
          )}
        </div>
      </div>
    </div>
  );
}
 
// Reusable stat card
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

Create 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 || 'Failed to load analytics'
        );
      } finally {
        setLoading(false);
      }
    };
 
    fetchStats();
  }, [shortCode]);
 
  if (loading) {
    return (
      <div className="text-center py-16">
        <p className="text-gray-500 text-lg">Loading 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"
        >
          Back to 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"
          >
            ← Back to 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>
  );
}

How the Data Flows


Backend CORS Configuration

Before running the frontend, configure CORS on the Express backend to accept requests from Vite's dev server.

Install the CORS package:

cd backend
npm install cors
npm install -D @types/cors

Update the 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());
// ... rest of middleware and routes

Add to .env:

CORS_ORIGIN=http://localhost:5173

Why CORS matters here: In development, the React app runs on localhost:5173 while Express runs on localhost:3000. They're different origins, so the browser blocks API requests by default. Our Vite proxy handles this during development, but the CORS configuration is still needed for production deployments where the frontend might be on a different domain or served from a CDN.


Running the Full Stack

Open two terminals:

# Terminal 1 — Backend
cd url-shortener/backend
npm run dev
 
# Terminal 2 — Frontend
cd url-shortener/frontend
npm run dev

Visit http://localhost:5173 — you should see the home page with the URL shortening form. Register an account, shorten some URLs, and click through to the dashboard and analytics pages.


Component Architecture Overview


Responsive Design Patterns

Tailwind makes responsive design straightforward with mobile-first breakpoints. Here are the patterns we used throughout:

Pattern 1: Desktop Table → Mobile Cards

{/* Desktop only */}
<div className="hidden md:block">
  <table>...</table>
</div>
 
{/* Mobile only */}
<div className="md:hidden">
  {items.map(item => <Card key={item.id} />)}
</div>

Pattern 2: Grid Column Adjustments

// 1 column on mobile, 2 on tablet, 3 on desktop
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">

Pattern 3: Responsive Typography

// Smaller text on mobile, larger on desktop
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold">

Pattern 4: Flex Wrapping for Action Buttons

// Buttons stack vertically on small screens
<div className="flex flex-wrap gap-2">
  <button>Copy</button>
  <button>QR</button>
  <button>Stats</button>
</div>

Common Pitfalls

1. Token Stored in localStorage

For this project, localStorage is a pragmatic choice. It survives page refreshes and is simple to implement. In a production app, consider:

  • httpOnly cookies — safer against XSS attacks
  • Token rotation — refresh tokens before they expire
  • Secure flag — ensure cookies are only sent over HTTPS

2. Missing Loading States

Every API call should have a loading state. Without it, users click buttons multiple times, causing duplicate requests:

// Always track loading state
const [loading, setLoading] = useState(false);
 
const handleSubmit = async () => {
  setLoading(true);
  try {
    await apiCall();
  } finally {
    setLoading(false);
  }
};
 
// Disable the button during loading
<button disabled={loading}>
  {loading ? 'Working...' : 'Submit'}
</button>

3. Not Handling Empty States

When there's no data, show a helpful message instead of a blank page:

if (urls.length === 0) {
  return <p>No URLs yet. Create your first one!</p>;
}

4. Forgetting to Proxy API Requests

If you see CORS errors during development, check two things:

  1. The Vite proxy in vite.config.ts points to the correct backend port
  2. The Express app has CORS middleware configured
  3. API calls use relative paths (/api/shorten) not absolute URLs

What's Next?

Our URL shortener now has a complete frontend — users can register, shorten URLs, manage their links in a dashboard, view analytics with interactive charts, and generate QR codes. But how do we know it all works correctly?

In Phase 8: Testing Strategy, we'll add comprehensive testing:

  • Unit tests with Vitest for services and utilities
  • Integration tests with Supertest for API endpoints
  • Frontend component tests with React Testing Library
  • End-to-end testing strategy
  • Load testing with k6 for redirect performance

Testing transforms "it works on my machine" into "it works everywhere, every time."


Series: Build a URL Shortener
Previous: Phase 6: User Authentication & URL Management
Next: Phase 8: Testing Strategy

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.