Case Study
OpenAI leverages Ory to support over 400M weekly active users
Ory Homepage

Ory & Supabase url shortener part 2: Frontend

Step-by-step guide to create frontend for URL shortener with authentication using Ory

Picture of Andrew Minkin
Andrew Minkin

Ory Guest

Apr 22, 2022

The previous blog post covers the implementation of the backend for the showcase using Ory and Supabase. Here's an overview of the backend architecture we built in the previous post including Ory Kratos, Supabase, and Ory Oathkeeper:

Supabase Kratos Architecture

This article will show you an example of building a front-end for our URL shortener to complete the project. Here's the end result:

Frontend Screenshot

You can find the full backend and frontend source code for this project on GitHub.

Let's start hacking!

What we will use

  • Nuxt is an open source framework built on Vue.js. Vue.js is an open-source model–view–viewmodel front end JavaScript framework for building user interfaces and single-page applications.
  • TailwindCSS is basically a utility-first CSS framework for rapidly building custom user interfaces. It is a highly customizable, low-level CSS framework that gives you all of the building blocks you need to build bespoke designs without any annoying opinionated styles you have to fight to override.
  • Axios In a nutshell, Axios is a Javascript library used to make HTTP requests from node.js or XMLHttpRequests from the browser that also supports the ES6 Promise API.

I chose these products to build a frontend because of their simplicity. It's easier to build frontend apps today because React, Next, Vue and Nuxt frameworks exist with built-in es6 feature compatibility in the browser. I started building JS apps in 2013 and quit in 2015 because I had a bad experience with ES5 and backbone. I needed to write a lot of boilerplate code to add new CRUD views for any new datatype for my backbone application. Nowadays it became easier with project generators and useful frameworks. You can concentrate on writing code without useless actions like copying and pasting boilerplate code.

We will use the following parts of the NuxtJS framework:

Creating layout for our application

We will use the default layout created by create-nuxt-app. The code of layouts/default.vue is simple. I took the basic example of the URl shortener frontend from jonathanjameswilliams26 URL Shortener project.

mkdir client
cd client
npx create-nuxt-app

Use the following settings to create the starter application:

Frontend Screenshot

<!-- Copyright © 2023 Ory Corp -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

<template>
  <div>
    <nuxt />
  </div>
</template>

<script>
export default {}
</script>

We need to have a simple layout with an application header with Sign-in/Sign-up buttons for unauthenticated user. Let's create the AppHeader component and add it to our layout. Create components/AppHeader.vue file with the following content

<!-- Copyright © 2023 Ory Corp -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

<template>
  <div>
    <div class="bg-gray-100">
      <nav
        class="container mx-auto px-6 py-8 md:flex md:items-center md:justify-between"
      >
        <div class="flex items-center justify-between">
          <router-link
            to="/"
            class="text-xl font-bold text-gray-800 hover:text-blue-400 md:text-2xl"
            >Shorts
          </router-link>
          <!-- Mobile menu button -->
          <div @click="showMenu = !showMenu" class="flex md:hidden">
            <button
              type="button"
              class="text-gray-800 hover:text-gray-400 focus:text-gray-400 focus:outline-none"
            >
              <svg viewBox="0 0 24 24" class="h-6 w-6 fill-current">
                <path
                  fill-rule="evenodd"
                  d="M4 5h16a1 1 0 0 1 0 2H4a1 1 0 1 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2z"
                ></path>
              </svg>
            </button>
          </div>
        </div>

        <!-- Mobile Menu open: "block", Menu closed: "hidden" -->
        <ul
          :class="showMenu ? 'flex' : 'hidden'"
          class="mt-8 flex-col space-y-4 md:mt-0 md:flex md:flex-row md:items-center md:space-y-0 md:space-x-10"
        >
          <li class="text-sm font-bold text-gray-800 hover:text-blue-400">
            Home
          </li>
          <li
            v-if="!authenticated"
            class="text-sm font-bold text-gray-800 hover:text-blue-400"
          >
            <a :href="$config.kratosUI + '/login'"> Sign in </a>
          </li>
          <li
            v-if="!authenticated"
            class="text-sm font-bold text-gray-800 hover:text-blue-400"
          >
            <a :href="$config.kratosUI + '/registration'"> Sign Up </a>
          </li>
          <li
            v-if="authenticated"
            class="text-sm font-bold text-gray-800 hover:text-blue-400"
          >
            <a :href="logoutURL"> Logout </a>
          </li>
        </ul>
      </nav>
    </div>
  </div>
</template>
<script>
export default {
  name: "AppHeader",
  computed: {
    authenticated() {
      return this.$store.state.session.authenticated
    },
    session() {
      return this.$store.state.session.session
    },
    logoutURL() {
      return this.$store.state.session.logoutURL
    },
  },
  data() {
    return {
      showMenu: false,
    }
  },
}
</script>

Take a closer look at two important parts of our AppHeader:

Let's create a store for sessions for our application:

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0

export const state = () => ({
  session: {},
  authenticated: false,
  logoutUrl: "",
})

export const mutations = {
  setSession(state, session) {
    state.session = session
  },
  setAuthenticated(state, authenticated) {
    state.authenticated = authenticated
  },
  setLogoutURL(state, logoutURL) {
    state.logoutURL = logoutURL
  },
}

Also, Nuxt uses publicRuntimeConfig object for application configuration using environment variables, hence we need to add these lines to our nuxt.config.js

// ...
  publicRuntimeConfig: {
    kratosUI: process.env.KRATOS_URL || "http://127.0.0.1:4455",
    kratosAPIURL: process.env.KRATOS_API_URL || "http://127.0.0.1:4433",
    apiURL: process.env.API_URL || "http://127.0.0.1:8080",
  },

  // Build Configuration: https://go.nuxtjs.dev/config-build
// ...

Let's make an URLInput that only lets authenticated users create shortened URls. Create a components/URLInput.vue file with the following content:

<!-- Copyright © 2023 Ory Corp -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

<template>
  <div class="w-full">
    <div class="mt-6 flex w-full rounded-md shadow-sm" v-if="authenticated">
      <div class="relative flex-grow focus-within:z-10">
        <DownloadIcon />
        <input
          v-model="url"
          class="form-input block w-full rounded-none rounded-l-md py-3 pl-10 font-semibold text-gray-700 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
          placeholder="www.example.com"
        />
      </div>
      <button
        v-if="!loading"
        @click="shorten"
        class="focus:shadow-outline-blue group relative -ml-px inline-flex items-center rounded-r-md border border-indigo-300 bg-indigo-700 px-3 py-3 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:bg-white hover:text-indigo-700 focus:border-indigo-300 focus:outline-none active:bg-gray-100 active:text-indigo-700"
      >
        <svg
          class="h-5 w-5 text-white group-hover:text-indigo-700"
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke="currentColor"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="2"
            d="M15 13l-3 3m0 0l-3-3m3 3V8m0 13a9 9 0 110-18 9 9 0 010 18z"
          />
        </svg>
        <span class="ml-2 text-sm font-semibold">Shorten</span>
      </button>
      <button
        v-else
        class="focus:shadow-outline-blue group relative -ml-px inline-flex items-center rounded-r-md border border-indigo-300 bg-indigo-700 px-3 py-3 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:bg-white hover:text-indigo-700 focus:border-indigo-300 focus:outline-none active:bg-gray-100 active:text-indigo-700"
      >
        <svg
          class="h-5 w-5 animate-spin text-white group-hover:text-indigo-700"
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke="currentColor"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="2"
            d="M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5"
          />
        </svg>
        <span class="ml-2 text-sm font-semibold">Shortening</span>
      </button>
    </div>
    <p v-show="errorMessage" class="text-xs font-semibold italic text-red-600">
      {{ errorMessage }}
    </p>
  </div>
</template>
<script>
export default {
  name: "URLInput",
  data() {
    return {
      loading: false,
      url: "",
      errorMessage: "",
    }
  },
  computed: {
    authenticated() {
      return this.$store.state.session.authenticated
    },
  },
  methods: {
    async shorten(e) {
      e.preventDefault()
      this.loading = true
      this.errorMessage = this.validateURL(this.url)
      try {
        const result = await this.$axios.$post("/api/url", {
          url: this.url,
        })
        this.$store.commit("url/add", result.data)
      } catch (error) {
        if (error.response) {
          this.errorMessage = error.response.message
        } else {
          this.errorMessage = "Sorry, the API is offline. Try again later"
        }
      }
      this.loading = false
    },
    validateURL(str) {
      if (str === null || str.match(/^ *$/) !== null) {
        return "A URL is required"
      }
      const regex = new RegExp(
        "^(https?:\\/\\/)?" + // protocol
          "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name
          "((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address
          "(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path
          "(\\?[;&a-z\\d%_.~+=-]*)?" + // query string
          "(\\#[-a-z\\d_]*)?$",
        "i",
      )
      if (str.match(regex) === null) {
        return "URL is invalid"
      }
      return ""
    },
  },
}
</script>

Building everything together

We do not call Kratos APIs yet, and we need to make two API calls:

Let's create a pages/index.vue file with the following content

<!-- Copyright © 2023 Ory Corp -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

<template>
  <!-- Container -->
  <div class="min-h-screen w-full bg-gray-100">
    <AppHeader />
    <div
      class="mx-auto flex max-w-2xl flex-col items-center justify-center px-4"
    >
      <!-- Logo Image -->
      <ShortsLogo />
      <!-- Header -->
      <h1 class="text-center text-5xl font-black uppercase text-gray-900">
        Shorts
      </h1>
      <h2 class="text-sm font-semibold italic text-indigo-700">
        Make your URLs shorter!
      </h2>

      <URLInput />
      <URLView />
    </div>
  </div>
</template>

<script lang="ts">
import Vue from "vue"
import { Configuration, V0alpha2Api } from "@ory/kratos-client"
import type { SelfServiceLogoutUrl } from "@ory/kratos-client"
import type { AxiosResponse } from "axios"
import AppHeader from "../components/AppHeader"

const getLogoutURL = async ({ app }) => {
  const ory = new V0alpha2Api(
    new Configuration({
      basePath: app.$config.kratosAPIURL,
      baseOptions: {
        withCredentials: true,
      },
    }),
  )
  try {
    const data = await ory.createSelfServiceLogoutFlowUrlForBrowsers()
    return data
  } catch {
    return {
      data: {
        logout_url: "",
      },
    }
  }
}

const getAuthState = async ({ app }) => {
  const ory = new V0alpha2Api(
    new Configuration({
      basePath: app.$config.kratosAPIURL,
      baseOptions: {
        withCredentials: true,
      },
    }),
  )

  try {
    const session = await ory.toSession()
    return {
      authenticated: true,
      session: session,
    }
  } catch {
    return {
      authenticated: false,
      session: {},
    }
  }
}

export default Vue.extend({
  name: "IndexPage",
  async asyncData(context) {
    const authState = await getAuthState(context)
    const logoutData = await getLogoutURL(context)
    context.store.commit("session/setSession", authState.session.data)
    context.store.commit("session/setAuthenticated", authState.authenticated)
    context.store.commit("session/setLogoutURL", logoutData.data.logout_url)
  },
})
</script>

asyncData is a hook for universal data fetching in Nuxt.js. Unlike fetch, which requires you to set properties on the component instance (or dispatch Vuex actions) to save your async state. Nuxt waits for the asyncData hook to be finished before navigating to the next page or display the error page.

We commit the fetched data to our store and make it available for all our components once we get the result of async API calls.

Displaying shortened URLs component

Let's create a components/URLView.vue with the following content:

<!-- Copyright © 2023 Ory Corp -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

<template>
  <div class="w-full py-6">
    <div
      class="my-3 rounded-lg bg-white shadow"
      v-for="(shortenedUrl, index) in shortenedUrls"
      :key="index"
    >
      <div class="px-4 py-5 sm:p-6">
        <div class="flex items-center">
          <svg
            class="h-5 w-5 text-green-600"
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
          >
            <path
              stroke-linecap="round"
              stroke-linejoin="round"
              stroke-width="2"
              d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
            />
          </svg>
          <h3 class="px-2 text-lg font-semibold leading-6 text-gray-900">
            Your Shortened Url
          </h3>
        </div>
        <div
          class="mt-2 max-w-xl overflow-hidden text-xs font-medium leading-5 text-gray-500"
        >
          <p>
            {{ shortenedUrl.url }}
          </p>
        </div>

        <div class="mt-3 text-sm leading-5">
          <a
            v-bind:href="shortenedUrl.shortened"
            target="_blank"
            class="font-medium text-indigo-600 transition duration-150 ease-in-out hover:text-indigo-500"
          >
            {{ shortenedUrl.hash }} &rarr;
          </a>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  name: "URLView",
  async fetch() {
    try {
      this.shortenedUrls = await this.$axios.$get("/api/url")
    } catch {
      console.log("API is offline")
    }
  },
  data() {
    return {
      shortenedUrls: [],
    }
  },
}
</script>

Run npm run dev to run the example locally.

You can find the full backend and frontend source code for this project on the Ory example repository.

Next steps

  • Move Kratos API calls to middleware/plugins directory. That would be the best place for this code.
  • Add more pages with analytical information (for example Views,Top Referrals, or Highcharts.js graphs).
  • Go to production with free tiers of the following services: