Tutorial

Building a URL Shortener with Nuxt and nuxt-safe-action

A hands-on tutorial building a full-stack URL shortener with Nuxt, showcasing type-safe server actions, Zod validation, field-level errors, and composable middleware with nuxt-safe-action.

Philip RutbergPhilip Rutberg··8 min read

In my previous post, I introduced nuxt-safe-action and the problems it solves. But reading about features and actually using them are two different things. In this post, we're going to build something real: a URL shortener. It's a small project, but it touches every major feature of the module: Zod validation with field-level errors, the useAction composable with reactive status tracking, middleware for auth, and multiple actions working together.

By the end, you'll have a working app where users can shorten URLs, see a list of their shortened links, and delete them. More importantly, you'll have a solid feel for how nuxt-safe-action fits into a real Nuxt project.

What We're Building

A simple URL shortener with three actions:

  1. Shorten a URL - takes a URL, validates it, generates a short code
  2. List all URLs - returns all shortened links
  3. Delete a URL - removes a link (requires authentication)

We'll use an in-memory store for simplicity. In a real app, you'd swap this for a database or something like Pinia for client-side state, but the nuxt-safe-action code stays exactly the same.

Project Setup

Start with a fresh Nuxt project and install the dependencies:

npx nuxi@latest init url-shortener
cd url-shortener
npx nuxi module add nuxt-safe-action
pnpm add zod nanoid

nanoid will generate our short codes. Make sure your nuxt.config.ts includes the module:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-safe-action'],
})

Step 1: Create the Action Client

First, we set up our base action client. This is the foundation that all our actions will build on.

server/utils/action-client.ts
import { createSafeActionClient } from '#safe-action'

export const actionClient = createSafeActionClient({
  handleServerError: (error) => {
    console.error('[action error]', error.message)
    return error.message
  },
})

The handleServerError callback runs whenever an action throws. It's a single place to log errors, sanitize messages, or send them to an error tracking service. The string you return is what the client receives in serverError, so you control exactly what leaks to the frontend.

For our delete action, we'll also need an authenticated client. Let's add that now:

server/utils/action-client.ts
export const authActionClient = actionClient
  .use(async ({ next, event }) => {
    // In a real app, you'd validate a session or JWT here.
    // For this tutorial, we'll check for a simple auth header.
    const token = getHeader(event, 'authorization')
    if (token !== 'Bearer demo-token') {
      throw new Error('Unauthorized')
    }
    return next({ ctx: { userId: 'demo-user' } })
  })

This middleware runs before any action that uses authActionClient. If the check fails, the action never executes. If it passes, ctx.userId is available in the handler, fully typed.

Step 2: Create an In-Memory Store

Before writing our actions, let's set up a simple store. In a real project this would be Prisma, Drizzle, or whatever you prefer.

server/utils/store.ts
export interface ShortenedUrl {
  id: string
  code: string
  originalUrl: string
  createdAt: Date
}

const urls = new Map<string, ShortenedUrl>()

export const urlStore = {
  getAll: () => Array.from(urls.values()),
  getByCode: (code: string) => urls.get(code),
  create: (entry: ShortenedUrl) => {
    urls.set(entry.code, entry)
    return entry
  },
  delete: (code: string) => urls.delete(code),
}

Nothing fancy here. A Map keyed by the short code with basic CRUD operations.

Step 3: The "Shorten URL" Action

This is where nuxt-safe-action starts to shine. We define our input schema with Zod, and the module handles validation, error formatting, and type inference automatically.

server/actions/shorten.ts
import { z } from 'zod'
import { nanoid } from 'nanoid'
import { actionClient } from '../utils/action-client'
import { urlStore } from '../utils/store'

export default actionClient
  .schema(
    z.object({
      url: z
        .string()
        .url('Please enter a valid URL')
        .refine(
          (val) => val.startsWith('http://') || val.startsWith('https://'),
          'URL must start with http:// or https://'
        ),
    })
  )
  .action(async ({ parsedInput }) => {
    const code = nanoid(8)

    const entry = urlStore.create({
      id: nanoid(),
      code,
      originalUrl: parsedInput.url,
      createdAt: new Date(),
    })

    return {
      code: entry.code,
      shortUrl: `http://localhost:3000/r/${entry.code}`,
      originalUrl: entry.originalUrl,
    }
  })

A few things to notice:

  • parsedInput is fully typed. Your editor knows parsedInput.url is a string. No casting, no as assertions.
  • Validation happens before the handler runs. If someone submits an empty string or something that's not a URL, the handler never executes. The error goes back to the client as a field-level validation error.
  • The return type is automatically inferred. On the client, data will be typed as { code: string, shortUrl: string, originalUrl: string }.

The .refine() call adds a custom validation rule on top of Zod's built-in .url() check. This lets you enforce things like requiring HTTPS in production.

Step 4: The "List URLs" Action

For fetching data, we create a GET action by using the .get.ts file suffix:

server/actions/list-urls.get.ts
import { actionClient } from '../utils/action-client'
import { urlStore } from '../utils/store'

export default actionClient.action(async () => {
  return {
    urls: urlStore.getAll().map((entry) => ({
      code: entry.code,
      originalUrl: entry.originalUrl,
      shortUrl: `http://localhost:3000/r/${entry.code}`,
      createdAt: entry.createdAt.toISOString(),
    })),
  }
})

No .schema() call here since this action doesn't need any input. The .get.ts suffix tells nuxt-safe-action to register this as a GET route instead of POST. On the client side, it imports as listUrls (the suffix is stripped, kebab-case becomes camelCase).

Step 5: The "Delete URL" Action (With Auth)

This action uses our authActionClient, so the auth middleware runs automatically before the handler:

server/actions/delete-url.ts
import { z } from 'zod'
import { authActionClient } from '../utils/action-client'
import { urlStore } from '../utils/store'

export default authActionClient
  .schema(
    z.object({
      code: z.string().min(1, 'Code is required'),
    })
  )
  .action(async ({ parsedInput, ctx }) => {
    const existing = urlStore.getByCode(parsedInput.code)
    if (!existing) {
      throw new Error('URL not found')
    }

    urlStore.delete(parsedInput.code)

    return { deleted: true, code: parsedInput.code }
  })

Because we used authActionClient, the ctx parameter is typed with { userId: string }. If we tried to access ctx.userId on an action built with the base actionClient, TypeScript would flag it. The type safety flows through the entire middleware chain.

Step 6: Build the Frontend

Now let's wire everything up in a Vue component. This is where useAction brings it all together.

pages/index.vue
<script setup lang="ts">
import { shorten, listUrls, deleteUrl } from '#safe-action/actions'

const urlInput = ref('')

// Shorten action
const {
  execute: shortenUrl,
  data: shortenResult,
  isExecuting: isShortening,
  validationErrors,
  hasErrored: shortenErrored,
  serverError: shortenServerError,
  reset: resetShorten,
} = useAction(shorten, {
  onSuccess() {
    urlInput.value = ''
    refreshList()
  },
})

// List action
const {
  execute: refreshList,
  data: listResult,
  isExecuting: isLoadingList,
} = useAction(listUrls)

// Delete action
const {
  execute: removeUrl,
  isExecuting: isDeleting,
} = useAction(deleteUrl, {
  onSuccess() {
    refreshList()
  },
  onError({ error }) {
    alert(error.serverError ?? 'Failed to delete')
  },
})

// Load the list on mount
onMounted(() => refreshList())

function handleSubmit() {
  resetShorten()
  shortenUrl({ url: urlInput.value })
}
</script>

<template>
  <div class="max-w-2xl mx-auto p-8">
    <h1 class="text-3xl font-bold mb-8">URL Shortener</h1>

    <!-- Shorten form -->
    <form @submit.prevent="handleSubmit" class="flex flex-col gap-3 mb-8">
      <div>
        <input
          v-model="urlInput"
          placeholder="https://example.com/some/long/url"
          class="w-full border rounded px-3 py-2"
          :class="{ 'border-red-500': validationErrors?.url }"
        />
        <p v-if="validationErrors?.url" class="text-red-500 text-sm mt-1">
          {{ validationErrors.url.join(', ') }}
        </p>
      </div>

      <button
        type="submit"
        :disabled="isShortening"
        class="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700 disabled:opacity-50"
      >
        {{ isShortening ? 'Shortening...' : 'Shorten URL' }}
      </button>

      <p v-if="shortenErrored" class="text-red-500 text-sm">
        {{ shortenServerError }}
      </p>

      <div
        v-if="shortenResult"
        class="bg-green-50 border border-green-200 rounded p-3 text-sm"
      >
        Shortened:
        <a :href="shortenResult.shortUrl" class="text-teal-600 underline">
          {{ shortenResult.shortUrl }}
        </a>
      </div>
    </form>

    <!-- URL list -->
    <h2 class="text-xl font-semibold mb-4">Your Links</h2>

    <p v-if="isLoadingList" class="text-gray-500">Loading...</p>

    <div v-else-if="listResult?.urls.length" class="flex flex-col gap-3">
      <div
        v-for="url in listResult.urls"
        :key="url.code"
        class="flex items-center justify-between border rounded p-3"
      >
        <div class="min-w-0 flex-1">
          <p class="font-mono text-sm text-teal-600">
            localhost:3000/r/{{ url.code }}
          </p>
          <p class="text-sm text-gray-500 truncate">{{ url.originalUrl }}</p>
        </div>
        <button
          @click="removeUrl({ code: url.code })"
          :disabled="isDeleting"
          class="ml-4 text-red-500 hover:text-red-700 text-sm"
        >
          Delete
        </button>
      </div>
    </div>

    <p v-else class="text-gray-500">No links yet. Shorten one above!</p>
  </div>
</template>

Let's break down what's happening here:

Three actions, each with their own reactive state. Each useAction call returns independent data, isExecuting, validationErrors, and serverError refs. They don't interfere with each other.

Field-level validation errors. When Zod validation fails on the url field, validationErrors.url contains an array of error messages. We render them right below the input. No manual error parsing, no try/catch blocks.

Lifecycle callbacks. onSuccess on the shorten action clears the input and refreshes the list. onSuccess on the delete action refreshes the list too. onError on delete shows an alert. These callbacks keep your side effects organized next to the action they belong to.

Status tracking for free. isShortening, isLoadingList, and isDeleting are all reactive booleans. We use them to disable buttons and show loading states without managing any state ourselves.

Step 7: Add a Redirect Route

To actually make the short URLs work, add a simple server route that redirects:

server/routes/r/[code].get.ts
import { urlStore } from '../../utils/store'

export default defineEventHandler((event) => {
  const code = getRouterParam(event, 'code')
  if (!code) throw createError({ statusCode: 400 })

  const entry = urlStore.getByCode(code)
  if (!entry) throw createError({ statusCode: 404, message: 'Not found' })

  return sendRedirect(event, entry.originalUrl, 302)
})

This is a regular Nitro route, not a safe action. Redirect handlers don't need input validation or reactive composables, so there's no reason to overthink it. Use nuxt-safe-action for client-facing actions where type safety and validation matter; use regular routes for everything else.

What We Covered

Building this small project touched on all the core features:

FeatureWhere we used it
Zod schema validationshorten action with .url() and .refine()
Field-level validation errorsRendering validationErrors.url in the form
useAction composableAll three actions with reactive state
Lifecycle callbacksonSuccess to refresh the list, onError for delete errors
Composable middlewareauthActionClient for the delete action
File-based routing.get.ts suffix for the list action
Typed contextctx.userId available in the delete handler

The full pattern is always the same: define a schema, write a handler, call useAction in your component. Once you've done it once, every subsequent action feels effortless.

Where to Go From Here

If you want to extend this project, here are some ideas:

  • Add a real database with Prisma or Drizzle (only the store file changes, the actions stay identical), or use Pinia if you want reactive client-side state management
  • Add click tracking with a counter that increments on each redirect
  • Add output validation using .outputSchema() to guarantee your return types at runtime
  • Add rate limiting as a middleware that runs before the shorten action

The nuxt-safe-action documentation covers all of these patterns in detail. The source code is on GitHub, and if you have questions or feedback, issues and discussions are always open.

Happy coding, Philip!

Comments