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 installInstall 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/viteConfigure 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 importTypeScript 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
localStorageand adds it to every request - Response interceptor catches 401 errors globally — if the token expires, the user is redirected to login
- baseURL is
/apiwhich 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/corsUpdate 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 routesAdd to .env:
CORS_ORIGIN=http://localhost:5173Why CORS matters here: In development, the React app runs on
localhost:5173while Express runs onlocalhost: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 devVisit 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:
- The Vite proxy in
vite.config.tspoints to the correct backend port - The Express app has CORS middleware configured
- 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.