Build a URL Shortener with Nitro on Cloudflare Pages

- Lire en français

In this article, we will develop a URL shortener utilizing Nitro and deploy it on Cloudflare Pages.

The source code is accessible on url-shortener.

Nitro represents a new generation of server toolkit. It empowers us to construct web servers with all necessary functionalities and deploy them at our convenience.

Cloudflare Pages is a platform designed to build and host websites on the edge. It supports services like KV to create comprehensive full-stack stateful applications.

Our project is a straightforward URL shortener that facilitates converting a long URL into a shortened version. We will leverage the Cloudflare KV to store the URLs and the Nitro server to manage requests.

We will utilize:

  • unstorage to streamline the development process by abstracting the KV layer, eliminating the need for the Cloudflare Wrangler CLI.
  • ohash to generate a hash from the URL to prevent collisions.
  • nanojsx to build the HTML pages using TSX.
  • pico.css for styling the application.

Project Initialization

First, create a new Nitro project:

bash
npx giget@latest nitro url-shortener

Subsequently, navigate to the project and install the required dependencies:

bash
cd url-shortener
npm install

Start the development server to view the default Nitro page:

bash
npm run dev

Open your browser and visit http://localhost:3000 to verify functionality.

Constructing the URL Shortener

Initially, install the necessary packages:

bash
npm install ohash nano-jsx

Generate a Short URL

Create a route named index.get.tsx within the server/routes directory. This will serve as the home page of our URL shortener where users can generate a shortened URL from a long one.

tsx
import { h, Helmet, renderSSR } from 'nano-jsx' // the `h` is critical here
import { withTemplate } from '../resources/template'

export default defineLazyEventHandler(() => {
  const App = () => {
    return (
      <div>
        <Helmet>
          <title>URL Shortener with Nitro</title>
        </Helmet>
        <h2>Shorten a URL</h2>
        <form action="/create" method="POST">
          <input type="url" name="url" placeholder="URL to shorten" autocomplete="off" />
          <button type="submit">Create</button>
        </form>
      </div>
    )
  }
  const app = renderSSR(<App />)
  const { body, head } = Helmet.SSR(app)

  const page = withTemplate({
    body,
    head,
  })

  return defineEventHandler(() => {
    return page
  })
})

This route will present a form enabling users to input a URL to shorten. Upon form submission, a POST request is sent to the /create route.

A lazy event handler is utilized to generate the view only once, when a request reaches the server. The response is then cached in-memory and reused for future requests, reducing the computational load.

The withTemplate function serves as a utility that we must construct.

ts
interface LayoutProps {
  body: string
  head: string[]
}

export function withTemplate(props: LayoutProps) {
  const { head, body } = props

  return /* html */`<html>
      <head>
       <link
          rel="stylesheet"
          href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
        />
        ${head.join('\n')}
      </head>
      <body>
        <header class="container">
          <h1>
            <a href="/">URL Shortener with Nitro</a>
          </h1>
        </header>
        <main class="container">
          ${body}
        </main>
      </body>
    </html>`
}

This is a simple string template where we incorporate the head and body content generated by nano-jsx.

URL Storage

Before proceeding, install zod to validate the request body.

bash
npm install zod

Create the /create route to manage the POST request and store the URL in the KV. This route is labeled create.post.tsx and resides in the server/routes directory.

tsx
import { h, Helmet, renderSSR } from 'nano-jsx'
import { hash } from 'ohash'
import { z } from 'zod' // the `h` is essential here
import { withTemplate } from '../resources/template'

export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, z.object({
    url: z.string().url(),
  }).parse)

  const requestURL = getRequestURL(event)
  const id = hash(body.url)
  const shortenURL = new URL(`/${id}`, requestURL).href

  await useStorage('data').setItem(id, body.url)

  const App = () => {
    return (
      <div>
        <Helmet>
          <title>Created</title>
        </Helmet>
        <h2>Created and Ready</h2>
        <input
          type="text"
          value={shortenURL}
          autofocus
        />
      </div>
    )
  }

  const app = renderSSR(<App />)
  const { body: nanoBody, head } = Helmet.SSR(app)

  return withTemplate({
    body: nanoBody,
    head,
  })
})

In this segment, we utilize the readValidatedBody function to validate the request body. This assures that the url field is a legitimate URL, throwing an error otherwise.

We retrieve the request URL using the getRequestURL function from h3.

The hash is created from the body URL using the hash function from ohash, ensuring consistent hash generation to avoid collisions.

The URL is stored in the KV using the useStorage function from unstorage, employing the data namespace to store the URLs, pre-configured for our use.

KV Storage

At the end of this event handler, a route is returned with the shortened URL that users can copy and utilize.

For development, everything functions correctly, but we must update this configuration, nitro.config.ts, for the production environment.

ts
export default defineNitroConfig({
  srcDir: 'server',
  $production: {
    storage: { data: { driver: 'cloudflare-kv-binding', binding: 'url-shortener' } },
  },
})

In this configuration, we define the data namespace, as in development, to use the cloudflare-kv-binding driver and the url-shortener binding, specified under the key $production. This indicates that the configuration applies solely to the production environment for accessing the Cloudflare KV.

Environment-specific configuration

Redirecting to the Original URL

Finally, we need to establish a route to handle shortened URLs and redirect users to the primary URL. This route is named [id].get.ts and is situated in the server/routes directory.

ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'short')

  const value = await useStorage<string>('data').getItem(id)

  if (!value) {
    throw createError({
      statusCode: 404,
      statusMessage: 'Not Found',
    })
  }

  return sendRedirect(event, value)
})

Here, we simply obtain the id from the router parameters and use useStorage to fetch the URL from the KV. If the URL does not exist, a 404 error is thrown. Otherwise, the user is redirected to the original URL.

Deploying the URL Shortener

Congratulations! We have successfully created a basic URL shortener using Nitro. The next step is to deploy it on Cloudflare Pages. Rest assured, it's straightforward thanks to the zero-config deployment providers.

Zero Config Providers

Warning

A Cloudflare account and a GitHub repository are required to continue with the deployment. Initially, log in to the Cloudflare dashboard and select an account. Navigate to Account Home, choose Workers & Pages, and create a new application using the button at the top right. Select the Pages tab and follow the instructions to establish a GitHub connection.

Select the repository, add the build command npm run build, specify the output directory dist, include an environment variable NODE_ENV with the value production, and save and deploy.

Now, let's wait for the deployment to complete. Once done, you can access the URL shortener using the URL provided by Cloudflare Pages.

However, it may not function adequately initially, as a KV namespace has not been bound. This requires manual binding. Go to the project settings, select functions, and scroll until you find KV namespace bindings. Add a binding with the name url-shortener and choose the desired namespace.

Note

Creating a KV namespace is achievable in the Workers & Pages section. Re-deploy your application to account for the new KV namespace binding. Once completed, the URL shortener will be fully functional. ✨

Note

If you do not intend to maintain the project, remember to delete it from the Cloudflare Pages dashboard to avoid unnecessary expenses.

Enhancements and Future Considerations

To enhance security, consider adding simple CSRF protection using the handler object syntax. Modify the create.post.tsx file to include a pre-request handler for origin header verification.

tsx
import { h, Helmet, renderSSR } from 'nano-jsx'
import { hash } from 'ohash'
import { z } from 'zod'
import { withTemplate } from '../resources/template'

export default defineEventHandler({
  onBeforeResponse: async (event) => {
    const requestURL = getRequestURL(event).origin
    const origin = getRequestHeader(event, 'origin')

    if (!origin) {
      throw createError({
        statusCode: 400,
        statusMessage: 'Bad Request',
      })
    }

    if (origin !== requestURL) {
      throw createError({
        statusCode: 403,
        statusMessage: 'Forbidden',
      })
    }
  },
  handler: async (event) => {
    const body = await readValidatedBody(event, z.object({
      url: z.string().url(),
    }).parse)

    const requestURL = getRequestURL(event)
    const id = hash(body.url)
    const shortenURL = new URL(`/${id}`, requestURL).href

    await useStorage('data').setItem(id, body.url)

    const App = () => {
      return (
        <div>
          <Helmet>
            <title>Created</title>
          </Helmet>
          <h2>Created and Ready</h2>
          <input
            type="text"
            value={shortenURL}
            autofocus
          />
        </div>
      )
    }

    const app = renderSSR(<App />)
    const { body: nanoBody, head } = Helmet.SSR(app)

    return withTemplate({
      body: nanoBody,
      head,
    })
  },
})

We set up a pre-request handler to check the origin header. If there is a mismatch, an error is raised; otherwise, the request proceeds to the main handler.

Note

For comprehensive information on CSRF protection, refer to the Cross-Site Request Forgery Prevention Cheat Sheet by OWASP. Validating the origin header is a basic defense approach for CSRF. However, it is advisable to implement a more robust protection mechanism such as a token.

Conclusion

Developing with Nitro and Cloudflare Pages is straightforward and provides an exceptional developer experience due to the ability to utilize TSX and development storage, eliminating the need for the Cloudflare Wrangler CLI.

Enjoy your development journey with Nitro and Cloudflare Pages! 🚀

Note

The URL shortener is inspired by Yusuke Wada's URL Shortener.

Support my work
Follow me on