Skip to main content

Build a URL Shortener with Nitro on Cloudflare Pages

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:

npx giget@latest nitro url-shortener

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

cd url-shortener
npm install

Start the development server to view the default Nitro page:

npm run dev

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

Constructing the URL shortener

Initially, install the necessary packages:

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.

server/routes/index.get.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.

server/resources/template.tsx
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.

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.

server/routes/create.post.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.

Profile picture of Estéban

Thanks for reading! My name is Estéban, and I love to write about web development and the human journey around it.

I've been coding for several years, and I'm still learning new things every day. I share what I learn because I would have appreciated clear and complete resources when I started programming.

If you have a question or want to chat, leave a comment below or reach out through my social profiles.

I hope you learned something useful. Share the article, leave a comment, or add a reaction if you did. Support my work on GitHub.

Follow me

Estéban Soubiran

Software engineer, conference speaker, and open source enthusiast.

What do you think?

Discussions

Loading discussions...

Add a comment

Checking your session...