Building a Scalable URL Shortener with Node.js (Part 2/2)

joanroucoux

Joan Roucoux

Posted on November 8, 2024

Building a Scalable URL Shortener with Node.js (Part 2/2)

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:

URL shortener client preview

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
Enter fullscreen mode Exit fullscreen mode

Then, install all the required dependencies:

npm install react-router-dom @mui/material @emotion/react @emotion/styled @mui/icons-material @mui/lab
Enter fullscreen mode Exit fullscreen mode

The packages you installed above include:

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"
/>
Enter fullscreen mode Exit fullscreen mode

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,
},
...
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 with window.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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
>;
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 the Layout component, rendering the HomePage by default.
  • A dynamic route :shortenUrlKey that displays the UrlPage.
  • 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;
Enter fullscreen mode Exit fullscreen mode

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.",
  )
}
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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" ]
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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:

URL shortener app 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.

πŸ’– πŸ’ͺ πŸ™… 🚩
joanroucoux
Joan Roucoux

Posted on November 8, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related