Building a Scalable URL Shortener with Node.js (Part 2/2)
Joan Roucoux
Posted on November 8, 2024
Introduction
In the first part of this tutorial, we built the backend services for our URL shortener application using using Node.js, Redis, MongoDB, Apache ZooKeeper. Now, in this second part, we will focus on developing the frontend, making it easy for users to shorten and access URLs. We will use the following stack:
- React: A JavaScript library for building the user interface.
- RTK Query: A data-fetching and state management tool for making API requests to our backend.
- Material UI: A component library for creating our responsive, modern UI.
Here is a preview of what our application will look like:
So let's jump into it π
Setting up React and RTK
From the root directory url-shortener-demo-app
, run the following command to initialize the client using the official Redux Toolkit + TS template for Vite:
npx degit reduxjs/redux-templates/packages/vite-template-redux client
cd client
Then, install all the required dependencies:
npm install react-router-dom @mui/material @emotion/react @emotion/styled @mui/icons-material @mui/lab
The packages you installed above include:
- react-router-dom: Enables client side routing.
- @mui/material @emotion/react @emotion/styled: Adds Material UI to our project.
-
@mui/icons-material: Adds
Icon
components. - @mui/lab: Components that are not yet available in the MUI core.
Then, because Material UI uses the Roboto font by default, add the following code inside the <head />
tag in index.html
to install the font with the Google Fonts CDN:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/>
Now, to make sure our application works with Docker, we need to modify vite.config.ts
and add host:true
like below:
...
server: {
open: true,
host:true,
},
...
And finally, remove some of the generated files to clean up the src
folder before adding our pages and components:
- Remove
features
folder. - Remove
App.css
,App.test.tsx
,App.tsx
,index.css
,logo.svg
files.
Setting Up the Theme
We will custom the default theme used by Material UI using createTheme()
to enable the dark mode and change some of the properties like colors, shape and typography.
In a src/theme/theme.ts
file, add the following code:
import { createTheme } from '@mui/material/styles';
import type { Theme } from '@emotion/react';
const theme: Theme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#6200EE',
dark: '#3700B3',
},
secondary: {
main: '#03DAC6',
dark: '#018786',
},
},
shape: {
borderRadius: 8,
},
typography: {
h1: {
fontWeight: 600,
letterSpacing: '-0.24px',
},
h4: {
fontWeight: 600,
letterSpacing: '-0.06px',
},
},
});
export default theme;
Then, create a layout component that will wrap up our pages in src/components/Layout.tsx
:
import { Outlet } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles';
import { Container, Paper } from '@mui/material';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '../theme/theme';
const Layout = (): JSX.Element => (
<ThemeProvider theme={theme}>
<CssBaseline />
<Container component="main" maxWidth="sm" sx={{ mb: 4 }}>
<Paper
variant="outlined"
sx={{ my: { xs: 3, md: 6 }, p: { xs: 2, md: 3 } }}
>
<Outlet />
</Paper>
</Container>
</ThemeProvider>
);
export default Layout;
We will use this layout later, in the "Setting Up the Router" section.
Setting Up the URLs API Slice
Next, we will set up our URL API slice using RTK Query to interact with our servers through Nginx, implementing the following query operations:
-
getUrl
: Retrieves a specific URL based on its shortened key. It will redirect the user to the original URL withwindow.location.replace(originalUrl);
. -
submitUrl
: Submits an original URL and returns its shortened key.
In src/slices/urlsApiSlice.ts
, add the following code:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
interface SubmitUrlBodyProps {
originalUrl: string;
}
export const urlsApiSlice = createApi({
reducerPath: 'urlsApi',
baseQuery: fetchBaseQuery({
baseUrl: import.meta.env.VITE_API_BASE_URL,
}),
endpoints: (builder) => ({
getUrl: builder.query<void, string>({
query: (shortenUrlKey: string) => ({
url: `/urls/${encodeURIComponent(shortenUrlKey)}`,
responseHandler: async (response: Response) => {
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
}
const originalUrl = await response.text();
window.location.replace(originalUrl);
},
}),
}),
submitUrl: builder.mutation<string, SubmitUrlBodyProps>({
query: (body: SubmitUrlBodyProps) => ({
url: '/urls',
method: 'POST',
body,
responseHandler: 'text',
}),
}),
}),
});
export const { useGetUrlQuery, useSubmitUrlMutation } = urlsApiSlice;
We're loading our baseUrl
value from an environment variable VITE_API_BASE_URL
, so make sure to add it to the existing .env
file as well as some service configuration:
VITE_APP_LOCAL_PORT=5173
VITE_APP_DOCKER_PORT=5173
VITE_API_BASE_URL=http://${NGINX_HOST}:${NGINX_DOCKER_PORT}/api
Finally, we need to make one more change in src/app/store.ts
to register our URL API slice in the Redux store:
import type { Action, ThunkAction } from '@reduxjs/toolkit';
import { combineSlices, configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { urlsApiSlice } from '../slices/urlsApiSlice';
const rootReducer = combineSlices(urlsApiSlice);
export type RootState = ReturnType<typeof rootReducer>;
export const makeStore = (preloadedState?: Partial<RootState>) => {
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) => {
return getDefaultMiddleware().concat(urlsApiSlice.middleware);
},
preloadedState,
});
setupListeners(store.dispatch);
return store;
};
export const store = makeStore();
export type AppStore = typeof store;
export type AppDispatch = AppStore['dispatch'];
export type AppThunk<ThunkReturnType = void> = ThunkAction<
ThunkReturnType,
RootState,
unknown,
Action
>;
Setting Up the Home Page
Let's create our home page, where users can enter a URL to shorten and easily copy the generated link. This page will feature a simple form component and a data component to manage the request success and error states.
But first, just like we did in the first part of this tutorial, we need to implement a function to validate user input and a function to copy the result to the clipboard.
In src/utils/index.ts
, add both methods:
export const isValidUrl = (value: string): boolean => {
const pattern: RegExp = new RegExp(
'^https?:\\/\\/' + // Protocol (http or https)
'(?:www\\.)?' + // Optional www.
'[-a-zA-Z0-9@:%._\\+~#=]{1,256}' + // Domain name characters
'\\.[a-zA-Z0-9()]{1,6}\\b' + // Top-level domain
'(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$', // Optional query string
'i' // Case-insensitive flag
);
return pattern.test(value);
};
export const copyToClipboard = (text: string): void => {
navigator.clipboard.writeText(text);
};
Next, let's create the form component:
- It will contain an input field and a button, both wrapped in a
<form />
tag. - On submit, it will validate the inserted URL. If the URL is invalid, an error message will be displayed; otherwise it will call the
trigger
method with the provided input.
In src/components/UrlForm.tsx
, add the following code:
import { type ChangeEvent, useEffect, useState } from 'react';
import { Grid2, TextField } from '@mui/material';
import { LoadingButton } from '@mui/lab';
import { Box } from '@mui/system';
import { isValidUrl } from '../utils/Utils';
interface UrlFormType {
isSuccess: boolean;
isLoading: boolean;
trigger: (originalUrl: string) => void;
}
const UrlForm = ({
isSuccess,
isLoading,
trigger,
}: UrlFormType): JSX.Element => {
const [originalUrl, setOriginalUrl] = useState<string>('');
const [onError, setOnError] = useState<boolean>(false);
useEffect(() => {
if (isSuccess) {
setOriginalUrl('');
}
}, [isSuccess]);
const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
setOriginalUrl(event.target?.value);
setOnError(false);
};
const handleSubmit = (event: ChangeEvent<HTMLFormElement>): void => {
event.preventDefault();
if (isValidUrl(originalUrl)) {
trigger(originalUrl);
} else {
setOnError(true);
}
};
return (
<Box component="form" noValidate autoComplete="off" onSubmit={handleSubmit}>
<Grid2 container spacing={2} justifyContent="center" alignItems="top">
<Grid2 size={{ xs: 12, md: 8 }}>
<TextField
id="outlined-basic"
placeholder="Enter a link to shorten it"
variant="outlined"
error={onError}
helperText={onError ? 'Please enter a valid link' : ''}
fullWidth
required
onChange={handleChange}
value={originalUrl}
/>
</Grid2>
<Grid2 size={{ xs: 12, md: 4 }}>
<LoadingButton
type="submit"
variant="contained"
loading={isLoading}
loadingIndicator="Loading..."
fullWidth
sx={{ height: '56px' }}
>
Shorten URL
</LoadingButton>
</Grid2>
</Grid2>
</Box>
);
};
export default UrlForm;
Then, let's create the data component to display both success and error messages once the request is completed.
In src/components/UrlData.tsx
, add the following code:
import { useEffect, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Alert, IconButton, Link } from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DoneIcon from '@mui/icons-material/Done';
import { copyToClipboard } from '../utils/Utils';
interface UrlDataType {
isSuccess: boolean;
isError: boolean;
shortenUrlKey: string | undefined;
}
const UrlData = ({
isSuccess,
isError,
shortenUrlKey,
}: UrlDataType): JSX.Element => {
const [isCopied, setIsCopied] = useState<boolean>(false);
const shortenUrl = `${window.location.origin}/${shortenUrlKey}`;
const handleOnClick = (): void => {
copyToClipboard(shortenUrl);
setIsCopied(true);
};
useEffect(() => {
const timer = setTimeout(() => setIsCopied(false), 1300);
return () => {
clearTimeout(timer);
};
}, [isCopied]);
return (
<>
{(isSuccess && (
<Alert
variant="outlined"
severity="success"
sx={{
mt: 2,
}}
action={
<IconButton
aria-label="copy"
color="inherit"
onClick={handleOnClick}
>
{isCopied ? <DoneIcon /> : <ContentCopyIcon />}
</IconButton>
}
>
<Link
target='_blank'
color="inherit"
component={RouterLink}
to={`/${shortenUrlKey}`}
>
{shortenUrl}
</Link>
</Alert>
)) ||
(isError && (
<Alert
severity="error"
sx={{
mt: 2,
}}
>
Oops, something went wrong.. Please try again.
</Alert>
))}
</>
);
};
export default UrlData;
Finally, letβs create the home page, using useSubmitUrlMutation()
to handle the POST
request and integrating both the form and data components we just built.
In src/page/HomePage.tsx
, add the following code:
import { Typography } from '@mui/material';
import { useSubmitUrlMutation } from '../slices/urlsApiSlice';
import UrlData from '../components/UrlData';
import UrlForm from '../components/UrlForm';
const HomePage = (): JSX.Element => {
const [
trigger,
{
data,
isLoading,
isSuccess,
isError,
},
] = useSubmitUrlMutation();
return (
<>
<Typography
component="h1"
variant="h4"
color="primary"
align="center"
gutterBottom
>
Create Short URLs
</Typography>
<Typography variant="subtitle1" align="center" sx={{ mb: 4 }}>
This application makes long links look cleaner and easier to share!
</Typography>
<UrlForm
isSuccess={isSuccess}
isLoading={isLoading}
trigger={(originalUrl: string) => trigger({ originalUrl })}
/>
<UrlData
isSuccess={isSuccess}
isError={isError}
shortenUrlKey={data}
/>
</>
);
};
export default HomePage;
Great, our home page is now done!
Setting Up the Url Page
Next, let's set up the URL page, which will handle requests when a user visits with a shortened key. As mentioned earlier, the redirect is handled directly in the API slice. Here, we only need to manage the loading and error states after making the GET
request to retrieve the original URL.
In src/page/UrlPage.tsx
, add the following code:
import { Link, useParams } from 'react-router-dom';
import { Box, Button, CircularProgress, Typography } from '@mui/material';
import { useGetUrlQuery } from '../slices/urlsApiSlice';
const UrlPage = (): JSX.Element => {
const { shortenUrlKey } = useParams();
const {
isError,
} = useGetUrlQuery(shortenUrlKey || '');
return (
<>
{(isError && (
<>
<Typography
component="h1"
variant="h4"
color="primary"
align="center"
gutterBottom
>
Oops, something went wrong
</Typography>
<Typography variant="subtitle1" align="center" sx={{ mb: 4 }}>
The link has expired or is no longer available.
</Typography>
<Box textAlign='center'>
<Button variant="contained" component={Link} to={'/'}>
Back home
</Button>
</Box>
</>
)) || (
<Box textAlign='center'>
<CircularProgress />
</Box>
)}
</>
);
};
export default UrlPage;
Awesome, both pages are now ready to use!
Setting Up the Router
We will initialize the routing for our application using react-router-dom
, with lazy-loaded pages for better performance. The setup will include:
- A base route
/
that wraps the pages inside theLayout
component, rendering theHomePage
by default. - A dynamic route
:shortenUrlKey
that displays theUrlPage
. - All other routes will be redirected to the index.
In src/router.tsx
, add the code below:
import { lazy } from 'react';
import { Navigate, createBrowserRouter } from 'react-router-dom';
import Layout from './components/Layout';
const HomePage = lazy(() => import('./pages/HomePage'));
const UrlPage = lazy(() => import('./pages/UrlPage'));
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ index: true, element: <HomePage /> },
{ path: ':shortenUrlKey', element: <UrlPage /> },
],
},
{
path: '*',
element: <Navigate to="/" />,
},
]);
export default router;
And finally, change the application entry point in src/main.tsx
to implement our router
:
import React, { Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { RouterProvider, } from 'react-router-dom';
import { CircularProgress } from '@mui/material';
import { store } from './app/store';
import router from './router';
const container = document.getElementById("root")
if (container) {
const root = createRoot(container)
root.render(
<React.StrictMode>
<Provider store={store}>
<Suspense fallback={<CircularProgress />}>
<RouterProvider router={router} />
</Suspense>
</Provider>
</React.StrictMode>,
)
} else {
throw new Error(
"Root element with ID 'root' was not found in the document. Ensure there is a corresponding HTML element with the ID 'root' in your HTML file.",
)
}
That's it, we now have our application (almost) ready!
Setting Up Nginx
We used Nginx in the first part as a load balancer and reverse proxy to distribute traffic across server instances, and we can also use it to serve the frontend, so the user doesn't need to know the port on which the client is running.
Let's add in the Nginx configuration nginx/nginx.conf
file:
...
# Serve frontend
location / {
proxy_pass http://client:$VITE_APP_DOCKER_PORT;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
}
# Serve backend
...
Don't forget to add the key VITE_APP_DOCKER_PORT=${VITE_APP_DOCKER_PORT}
in the environment
section of the service nginx
in the docker-compose.yml
file.
Containerization with Docker
Just like in the first part of this tutorial, we will use Docker to containerize our client application. Create a Dockerfile
in the /client
folder that sets up the Node environment, installs dependencies, and starts the app like below:
# Use an official Node runtime as the base image
FROM node:22.11.0
# Set the working directory
WORKDIR /usr/src/client
# Copy package.json and package-lock.json to the container
COPY package*.json ./
# Install application dependencies
RUN npm install
# Copy the rest of the application code
COPY . .
# Run the application
CMD [ "npm", "run", "start" ]
Then update the existing docker-compose.yml
file at the project root to build the client using its Dockerfile:
...
client:
depends_on:
- nginx
environment:
- VITE_API_BASE_URL=${VITE_API_BASE_URL}
build:
context: ./client
dockerfile: Dockerfile
volumes:
- ./client:/usr/src/client
- /usr/src/client/node_modules
ports:
- ${VITE_APP_LOCAL_PORT}:${VITE_APP_DOCKER_PORT}
Testing and Deployment
Run docker compose up -d --build
to rebuild the images and start all the containers again.
Then simply go to http://localhost/, you should the see the client running:
That's it! You can now interact with the UI, create short URLs, and even test accessing an invalid URL like http://localhost/wrongToken to see the result.
Conclusion
You have reached the end this tutorial! I hope you enjoyed and learned a few things along the way π
In this second part of this tutorial, we learned how to build the frontend of our URL shortener application using React and RTK Query and serving it with Nginx. You can find the complete code for this project here.
By combining the backend services from the first part with the frontend, we've created a fully functional and scalable URL shortener application, allowing users to easily shorten URLs.
As mentioned before, if you're interested in going further, check out my other repository here. In this version, I've added extra features like a visit counter and a purge system to clean all expired URLs, all managed through a task queue service.
Posted on November 8, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.