Ory & Supabase url shortener part 2: Frontend
Step-by-step guide to create frontend for URL shortener with authentication using Ory
Ory Guest
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:
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:
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:
<!-- 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:
- Usage of
computed
properties that uses thestore
feature of Nuxt. In this case, we use the Nuxt.js store features, and create them later. - Usage of the
$config
variable that is part of the runtime configuration
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:
- toSession to check if the user session is valid
- Create a Logout URL for Browsers
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 }} →
</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:
- Ory Network
- Supabase
- Vercel or Netlify for frontend
Further reading

Back to the future: How today's user behavior around crowd-sourced software is reversing 20 years of security progress

Users are once again blindly running unvetted code. This post explores why security norms are failing in grassroots tech and what must change.

Secure authentication in Next.js: Best practices & implementation
Learn how to add secure auth to Next.js app in minutes, using our step-by-step guide with code examples for server & client components.