Tutorial: Build a URL Shortener with Varel

Difficulty: Beginner Time: 45-60 minutes Prerequisites: V language basics, basic web development knowledge

📝 Tutorial Updated: November 2025 - Now teaches layout-based templates for cleaner, more maintainable code. All URL views use Varel's layout system instead of full HTML templates.


What You'll Learn

In this hands-on tutorial, you'll build a complete URL shortener application with Varel. Along the way, you'll learn:

  • How to create a new Varel application
  • Using the scaffold generator to create resources quickly
  • Working with PostgreSQL databases and migrations
  • Building interactive UIs with Alpine.js (no build step!)
  • Using Varel's Alpine.js components (@copyButton, @toastContainer)
  • Server-side rendering with VeeMarker templates
  • Form validation and error handling

What You'll Build:

A URL shortener that lets users:

  • Submit long URLs and get short codes (like "abc123")
  • Copy shortened URLs to clipboard with one click
  • See success/error messages with toast notifications
  • View all shortened URLs in a list
  • Track click counts for each URL

Prerequisites

Before starting, ensure you have:

  1. V compiler installed (v0.4.12+)

    v version
    
  2. PostgreSQL installed and running (12+)

    psql --version
    
  3. Varel CLI installed (see Getting Started)

    varel version
    

Step 1: Create the Project

Let's create a new Varel application for our URL shortener.

# Navigate to your apps directory (or wherever you want to store your projects)
mkdir ~/varel-apps && cd ~/varel-apps

# Create new Varel project
varel new urlshortener

# Navigate into the project
cd urlshortener

What just happened?

The varel new command created a complete project structure:

  • main.v - Application entry point
  • config/config.toml - Database and server configuration
  • controllers/ - Request handlers (we'll add our logic here)
  • views/ - VeeMarker templates (HTML with Alpine.js)
  • models/ - Database models
  • db/migrations/ - Database schema changes
  • static/ - CSS, JavaScript, images

Explore the structure:

ls -la

You should see all these directories. Take a moment to peek inside main.v to see how a Varel app starts.


Step 2: Configure the Database

Important: For best practices, you should create a database user with access only to this database. for example:

sudo su - postgres
createuser -dSRP varelapp

Open config/config.toml in your editor and update the database settings for your postgresql server:

[database]
host = "localhost"
port = 5432
database = "urlshortener_dev"
user = "varelapp"
password = ""  # Add your PostgreSQL password if needed
sslmode = "prefer"
connect_timeout = 30

Create the database:

varel db create

You should see:

✓ Database urlshortener_dev created successfully!

What's happening?

Varel connects to PostgreSQL using the config and creates a new database for your app. Each Varel app should gets its own database to keep data isolated and protected.


Step 3: Generate the URL Resource

Now for the magic! We'll use Varel's scaffold generator to create everything we need for managing URLs.

varel generate scaffold Url \
  original_url:text \
  short_code:string \
  clicks:int

Watch the output:

Generating scaffold for Url...

  Created controllers/url.v
  Created models/url.v
  Created db/migrations/20251104_create_url.up.sql
  Created db/migrations/20251104_create_url.down.sql
  Created views/url/index.vtpl
  Created views/url/show.vtpl
  Created views/url/new.vtpl
  Created views/url/edit.vtpl
  Created tests/controllers/url_test.v

✓ Scaffold generated successfully!

What did we just create?

  • Controller (controllers/url.v) - 7 RESTful actions (index, show, new, create, edit, update, destroy)
  • Model (models/url.v) - Database operations (create, find, update, delete)
  • Views (4 templates) - HTML pages for listing, viewing, creating, editing URLs
  • Migrations (2 SQL files) - Database schema (up = create table, down = drop table)
  • Tests - Controller tests for all 7 actions

Peek at the migration:

cat db/migrations/*create_url.up.sql

You'll see a PostgreSQL table definition with your fields plus automatic id, created_at, updated_at columns!


Step 4: Run the Migration

Apply the database schema we just generated:

varel db migrate

You should see something similar to the following output:

Running 1 migration(s)...
Migrating: 20251105171507_create_url
Migrated:  20251105171507_create_url
All migrations completed successfully

(Optional) Verify the table was created:

PGPASSWORD='' psql -U postgres -d urlshortener_dev -c '\d url'

(Replace '' with your password if needed)

You should see the table structure with columns: id, original_url, short_code, clicks, created_at, updated_at.

                                         Table "public.url"
    Column    |            Type             | Collation | Nullable |             Default
--------------+-----------------------------+-----------+----------+---------------------------------
 id           | integer                     |           | not null | nextval('url_id_seq'::regclass)
 original_url | text                        |           | not null |
 short_code   | character varying(255)      |           | not null |
 clicks       | integer                     |           | not null |
 created_at   | timestamp without time zone |           | not null | CURRENT_TIMESTAMP
 updated_at   | timestamp without time zone |           | not null | CURRENT_TIMESTAMP
Indexes:
    "url_pkey" PRIMARY KEY, btree (id)

Step 5: Register Routes

Open routes.v in your editor. This file centralizes all route definitions. The import controllers is already included at the top of the file from the template.

First, add route wrapper functions (add these after the existing route wrapper functions, around line 24):

// URL shortener route wrappers
fn route_url_index(mut ctx http.Context) http.Response {
    mut ctrl := controllers.UrlController{}
    return ctrl.index(mut ctx)
}

fn route_url_new(mut ctx http.Context) http.Response {
    mut ctrl := controllers.UrlController{}
    return ctrl.new(mut ctx)
}

fn route_url_create(mut ctx http.Context) http.Response {
    mut ctrl := controllers.UrlController{}
    return ctrl.create(mut ctx)
}

fn route_url_show(mut ctx http.Context) http.Response {
    mut ctrl := controllers.UrlController{}
    return ctrl.show(mut ctx)
}

fn route_url_edit(mut ctx http.Context) http.Response {
    mut ctrl := controllers.UrlController{}
    return ctrl.edit(mut ctx)
}

fn route_url_update(mut ctx http.Context) http.Response {
    mut ctrl := controllers.UrlController{}
    return ctrl.update(mut ctx)
}

fn route_url_destroy(mut ctx http.Context) http.Response {
    mut ctrl := controllers.UrlController{}
    return ctrl.destroy(mut ctx)
}

Then, inside the register_routes() function, add the URL routes in the "Add Your Routes Below" section (after the health check, before the commented examples around line 63):

    // ========================================
    // URL Shortener Routes
    // ========================================

    // IMPORTANT: More specific routes must come before parameterized routes
    // /url/new must be registered before /url/:id
    app.get('/url/new', route_url_new)!
    app.get('/url', route_url_index)!
    app.post('/url', route_url_create)!
    app.get('/url/:id', route_url_show)!
    app.get('/url/:id/edit', route_url_edit)!
    app.put('/url/:id', route_url_update)!
    app.delete('/url/:id', route_url_destroy)!

⚠️ Important: Route Order Matters!

Notice that /url/new is registered before /url/:id. This is critical because:

  • Routes are matched in the order they're registered
  • If /url/:id came first, the router would match /url/new thinking "new" is an ID
  • Always register static paths (like /new) before parameterized paths (like /:id)

Understanding the routes:

HTTP Method Path Purpose Controller Action
GET /url List all URLs index()
GET /url/new Show create form new()
POST /url Submit create form create()
GET /url/:id View single URL show()
GET /url/:id/edit Show edit form edit()
PUT /url/:id Submit edit form update()
DELETE /url/:id Delete URL destroy()

This is the standard RESTful CRUD pattern used by Rails, Laravel, and Django. Each route maps to a controller action that handles that specific HTTP request.


Step 5.5: Add URLs to Navigation

Let's add a navigation link to access our URL shortener from anywhere in the app.

Open views/shared/header.vtpl and find the <nav> section (around line 16). Add the URLs link after the Home link:

<nav>
    <a href="/" style="color: white; margin-left: 20px; padding: 8px 16px; background: rgba(77, 208, 225, 0.15); border: 1px solid rgba(77, 208, 225, 0.3); border-radius: 5px; transition: all 0.3s; text-decoration: none;">Home</a>
    <a href="/url" style="color: white; margin-left: 10px; padding: 8px 16px; background: rgba(77, 208, 225, 0.15); border: 1px solid rgba(77, 208, 225, 0.3); border-radius: 5px; transition: all 0.3s; text-decoration: none;">URLs</a>
    <a href="/hello/world" style="color: white; margin-left: 10px; padding: 8px 16px; background: rgba(77, 208, 225, 0.15); border: 1px solid rgba(77, 208, 225, 0.3); border-radius: 5px; transition: all 0.3s; text-decoration: none;">Hello</a>
    <a href="/api/v1/status" style="color: white; margin-left: 10px; padding: 8px 16px; background: rgba(77, 208, 225, 0.15); border: 1px solid rgba(77, 208, 225, 0.3); border-radius: 5px; transition: all 0.3s; text-decoration: none;">API</a>
</nav>

What did we do?

Added a "URLs" link to the header navigation. Because header.vtpl is included in the layout (layouts/base.vtpl), this navigation will appear on every page that uses the layout!

This is one of the key benefits of layouts - make one change, see it everywhere.


Step 6: Test the Basic App

Let's see what we've built so far!

varel serve

You should see output like:

Starting Varel development server...

🚀 Starting urlshortener...
   Environment: development
   Listen: :8080
   Visit: http://localhost:8080
   Health: http://localhost:8080/health

Open your browser: http://localhost:8080/url/new

You should see a form with three fields:

  • Original URL
  • Short Code
  • Clicks

Try creating a URL:

  1. Original URL: https://www.google.com
  2. Short Code: google
  3. Clicks: 0
  4. Click "Create Url"

You should be redirected to the detail page showing your URL!

Stop the server: Press Ctrl+C in your terminal.


Step 6.5: Understanding Layouts

Before we continue, let's understand how Varel's layout system works. This will help you write cleaner, more maintainable templates.

What are Layouts?

Layouts are template wrappers that provide consistent structure across all pages. Instead of repeating the <!DOCTYPE html>, <head>, and <body> tags in every view, you write them once in a layout file.

Your app already has a layout file: views/layouts/base.vtpl

This layout includes:

  • HTML document structure (DOCTYPE, head, body)
  • CSS and JavaScript includes
  • Shared header and footer
  • A ${content} placeholder where your view content goes

How ctx.render_data() Works

When you call ctx.render_data('url/show.vtpl', data) in your controller:

  1. Varel renders your view template (url/show.vtpl)
  2. It wraps that content in the layout (layouts/base.vtpl)
  3. The ${content} variable in the layout is replaced with your view's HTML

This means your view templates should contain ONLY the page-specific content - no DOCTYPE, no <head>, no <body> tags!

Example: Layout + View

Layout (views/layouts/base.vtpl):

<!DOCTYPE html>
<html>
<head>
    <title>${title}</title>
    <link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
    <#include "shared/header.vtpl">
    <main>
        ${content}  <!-- Your view goes here -->
    </main>
    <#include "shared/footer.vtpl">
</body>
</html>

View (views/url/show.vtpl):

<div class="container">
    <h1>URL Details</h1>
    <p>Short code: ${url.short_code}</p>
</div>

Result: A complete HTML page with header, footer, and your content!

Benefits

DRY (Don't Repeat Yourself) - Write DOCTYPE/head/body once ✅ Consistency - All pages have the same structure ✅ Easy Updates - Change header/footer in one place ✅ Clean Views - Focus on page content, not boilerplate


Step 7: Add Alpine.js Interactivity

Now let's make it more user-friendly with Alpine.js! We'll add:

  • A copy button for the shortened URL
  • Toast notifications for feedback
  • Auto-generated short codes

Open views/url/show.vtpl and replace the entire content with:

<#include "shared/alpine_components.vtpl">

<div class="container">
    <header>
        <h1>Shortened URL</h1>
        <div class="actions">
            <a href="/url" class="btn">Back to List</a>
            <a href="/url/${url.id}/edit" class="btn btn-primary">Edit</a>
        </div>
    </header>

    <main>
        <div class="card">
            <dl class="details">
                <dt>Short Code</dt>
                <dd><strong>${url.short_code}</strong></dd>

                <dt>Short URL</dt>
                <dd>
                    <#assign shortUrl = base_url + "/" + url.short_code>
                    <code>${shortUrl}</code>
                    <@copyButton text=shortUrl label="Copy URL" />
                </dd>

                <dt>Original URL</dt>
                <dd>
                    <a href="${url.original_url}" target="_blank">${url.original_url}</a>
                </dd>

                <dt>Clicks</dt>
                <dd>${url.clicks}</dd>

                <dt>Created</dt>
                <dd>${url.created_at}</dd>
            </dl>

            <div class="danger-zone">
                <form method="POST" action="/url/${url.id}">
                    <input type="hidden" name="_method" value="DELETE">
                    <button type="submit" class="btn btn-danger"
                            onclick="return confirm('Are you sure you want to delete this URL?')">
                        Delete URL
                    </button>
                </form>
            </div>
        </div>
    </main>
</div>

<@toastContainer />

What changed?

  1. No DOCTYPE/head/body tags - The layout handles these! (Remember Step 6.5?)
  2. Line 1: <#include "shared/alpine_components.vtpl"> - Loads Alpine.js component macros
  3. Line 20: <#assign shortUrl = ...> - Build the URL as a variable (VeeMarker doesn't interpolate ${} inside macro attribute strings)
  4. Line 22: <@copyButton ... /> - Adds a copy-to-clipboard button using the shortUrl variable
  5. Line 49: <@toastContainer /> - Adds toast notification area

VeeMarker Note: When passing dynamic values to macros, use <#assign> to build the value first, then pass the variable name without quotes: text=shortUrl (not text="${shortUrl}"). VeeMarker doesn't interpolate ${} expressions inside quoted macro attribute strings.

Test it:

varel serve

Visit http://localhost:8080/url (your previously created URL should be listed)

Click on it, then click the "Copy URL" button. You should see:

  • Button text changes to "Copied!" briefly
  • A toast notification appears at the top
  • The URL is in your clipboard (try pasting it)

This is Alpine.js magic! All of this interactivity works with zero JavaScript build step, no npm, no webpack. The alpine.min.js file is included in your project at public/js/alpine.min.js, and it's all server-side rendered and enhanced with Alpine.js directives.


Step 8: Improve the Create Form

Let's make the create form smarter by auto-generating short codes.

Open views/url/new.vtpl and replace with:

<#include "shared/alpine_components.vtpl">

<div class="container">
    <header>
        <h1>Shorten a URL</h1>
        <a href="/url" class="btn">Back to List</a>
    </header>

    <main>
        <div x-data="{
            originalUrl: '',
            shortCode: '',
            autoGenerate: true,
            generateCode() {
                if (!this.autoGenerate || !this.originalUrl) return;
                const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
                let code = '';
                for (let i = 0; i < 6; i++) {
                    code += chars[Math.floor(Math.random() * chars.length)];
                }
                this.shortCode = code;
            }
        }">
            <form method="POST" action="/url" class="form">
                <div class="form-group">
                    <label for="original_url">Long URL *</label>
                    <input type="text"
                           id="original_url"
                           name="original_url"
                           x-model="originalUrl"
                           @blur="generateCode()"
                           placeholder="https://example.com/very/long/url"
                           required>
                    <small class="form-text">The URL you want to shorten</small>
                </div>

                <div class="form-group">
                    <label>
                        <input type="checkbox" x-model="autoGenerate" checked>
                        Auto-generate short code
                    </label>
                </div>

                <div class="form-group" x-show="!autoGenerate">
                    <label for="short_code">Custom Short Code</label>
                    <input type="text"
                           id="short_code"
                           name="short_code"
                           x-model="shortCode"
                           placeholder="my-custom-code">
                    <small class="form-text">Letters, numbers, and hyphens only</small>
                </div>

                <input type="hidden" name="short_code" x-bind:value="shortCode">
                <input type="hidden" name="clicks" value="0">

                <div class="form-group" x-show="shortCode">
                    <label>Preview</label>
                    <div class="preview">
                        <code>${base_url}/<span x-text="shortCode"></span></code>
                    </div>
                </div>

                <div class="form-actions">
                    <button type="submit" class="btn btn-primary">Shorten URL</button>
                    <a href="/url" class="btn btn-secondary">Cancel</a>
                </div>
            </form>
        </div>
    </main>
</div>

<@toastContainer />

What's new?

  1. Line 18-30: Alpine.js component with reactive data

    • originalUrl - Bound to the input field
    • shortCode - Auto-generated or custom
    • autoGenerate - Toggle between auto/custom
    • generateCode() - Generates random 6-character code
  2. Line 38: x-model="originalUrl" - Two-way data binding

  3. Line 39: @blur="generateCode()" - Generate code when user leaves field

  4. Line 54: x-show="!autoGenerate" - Show/hide custom code field

  5. Line 71: Live preview of shortened URL

Test it:

varel serve

Visit http://localhost:8080/url/new

  1. Type a URL in the "Long URL" field
  2. Click outside the field (or press Tab)
  3. Watch the "Preview" appear with a random short code!
  4. Try unchecking "Auto-generate" to enter a custom code

Create a few URLs to test:

  • https://github.com/vlang/v → random code
  • https://docs.varel.dev → custom: docs
  • https://www.youtube.com/watch?v=dQw4w9WgXcQ → random code

Step 9: Improve the Index Page

Let's make the URL list more useful with click counts and copy buttons.

Open views/url/index.vtpl and replace with:

<#include "shared/alpine_components.vtpl">

<div class="container">
    <header>
        <h1>URL Shortener</h1>
        <a href="/url/new" class="btn btn-primary">Shorten New URL</a>
    </header>

    <main>
        <#if url??>
        <#if url?size > 0>
        <table class="table">
            <thead>
                <tr>
                    <th>Short Code</th>
                    <th>Short URL</th>
                    <th>Original URL</th>
                    <th>Clicks</th>
                    <th>Actions</th>
                </tr>
            </thead>
            <tbody>
                <#list url as item>
                <#assign itemShortUrl = base_url + "/" + item.short_code>
                <tr>
                    <td><code>${item.short_code}</code></td>
                    <td>
                        <code>${itemShortUrl}</code>
                        <@copyButton text=itemShortUrl />
                    </td>
                    <td>
                        <a href="${item.original_url}" target="_blank" class="truncate">
                            ${item.original_url}
                        </a>
                    </td>
                    <td>
                        <strong>${item.clicks}</strong>
                    </td>
                    <td class="actions">
                        <a href="/url/${item.id}" class="btn btn-sm">View</a>
                        <a href="/url/${item.id}/edit" class="btn btn-sm">Edit</a>
                        <form method="POST" action="/url/${item.id}" style="display:inline;">
                            <input type="hidden" name="_method" value="DELETE">
                            <button type="submit" class="btn btn-sm btn-danger"
                                    onclick="return confirm('Delete this URL?')">
                                Delete
                            </button>
                        </form>
                    </td>
                </tr>
                </#list>
            </tbody>
        </table>
        <#else>
        <div class="empty-state">
            <p>No URLs yet!</p>
            <a href="/url/new" class="btn btn-primary">Create Your First Short URL</a>
        </div>
        </#if>
        </#if>
    </main>
</div>

<@toastContainer />

What's improved?

  1. Copy buttons for each URL (click to copy!)
  2. Click count display
  3. Better table layout
  4. Empty state message if no URLs exist
  5. Toast notifications when copying

Test it:

varel serve

Visit http://localhost:8080/url

You should see all your URLs in a nice table. Try:

  • Clicking the copy buttons (you'll get a toast notification!)
  • Creating more URLs
  • Deleting URLs

Step 10: Add URL Redirection (Bonus!)

Let's make the short URLs actually work! When someone visits http://localhost:8080/abc123, they should be redirected to the original URL.

Open controllers/url.v and add this function to the end of the file:

// redirect handles short code lookups and redirects
// GET /:short_code
pub fn (c UrlController) redirect(mut ctx http.Context) http.Response {
    short_code := ctx.param('short_code')

    // Find URL by short code
    mut urls := models.all_url(mut ctx.db) or { return ctx.not_found('Database error') }

    mut found_url := ?models.Url(none)
    for url in urls {
        if url.short_code == short_code {
            found_url = url
            break
        }
    }

    url := found_url or { return ctx.not_found('Short URL not found') }

    // Increment click count
    mut updated_url := url
    updated_url.clicks += 1
    models.update_url(mut ctx.db, updated_url) or {
        eprintln('Failed to update click count: ${err}')
    }

    // Redirect to original URL
    return ctx.redirect_permanent(url.original_url)
}

Open routes.v and add this route wrapper function after the other URL route wrappers (around line 60):

fn route_url_redirect(mut ctx http.Context) http.Response {
    mut ctrl := controllers.UrlController{}
    return ctrl.redirect(mut ctx)
}

Then, in the register_routes() function, add this route at the END (after all the /url routes, before the closing brace):

    // Catch-all route for short code redirects (must be LAST!)
    app.get('/:short_code', route_url_redirect)!

Important: This route MUST be last because it catches all paths. Any routes below it won't work!

Test it:

varel serve
  1. Visit http://localhost:8080/url and note one of your short codes (e.g., google)
  2. Visit http://localhost:8080/google (or whatever your code was)
  3. You should be redirected to the original URL!
  4. Go back to http://localhost:8080/url
  5. Notice the click count increased by 1!

Congratulations! You now have a fully functional URL shortener with click tracking! 🎉


Step 11: Add Custom Styling (Optional)

Let's make it look nicer with some custom CSS.

Open public/css/main.css and add at the end:

/* URL Shortener specific styles */
.preview {
    padding: 12px;
    background: #f0f4f8;
    border-radius: 6px;
    margin-top: 8px;
}

.preview code {
    color: #2563eb;
    font-size: 16px;
}

.truncate {
    max-width: 300px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    display: inline-block;
}

.empty-state {
    text-align: center;
    padding: 60px 20px;
    color: #6b7280;
}

.empty-state p {
    font-size: 18px;
    margin-bottom: 20px;
}

table code {
    background: #f3f4f6;
    padding: 4px 8px;
    border-radius: 4px;
    font-size: 14px;
}

.details dt {
    font-weight: 600;
    color: #374151;
    margin-bottom: 8px;
}

.details dd {
    margin-bottom: 20px;
    color: #6b7280;
}

.details code {
    background: #f3f4f6;
    padding: 8px 12px;
    border-radius: 6px;
    font-size: 14px;
    margin-right: 12px;
}

Refresh your browser - everything should look more polished now!


What You Built

Let's recap what you accomplished:

Full CRUD Application

  • Create, Read, Update, Delete URLs
  • RESTful routing pattern
  • Database persistence with PostgreSQL

Interactive UI with Alpine.js

  • Copy-to-clipboard buttons (no build step!)
  • Toast notifications for feedback
  • Auto-generated short codes
  • Live preview of shortened URLs
  • Reactive forms with show/hide logic

Real Functionality

  • URL redirection with :short_code routing
  • Click tracking
  • Database migrations
  • Form validation

Best Practices

  • MVC architecture (Models, Views, Controllers)
  • Database migrations (up/down SQL)
  • RESTful routes
  • Server-side rendering + client-side enhancement

Key Takeaways

Varel's Strengths:

  1. Scaffold Generator - Created 9 files (controller, model, 4 views, 2 migrations, tests) with one command
  2. Alpine.js Integration - Interactive UI with zero build step, no npm packages
  3. VeeMarker Components - Reusable @copyButton and @toastContainer macros
  4. Database Helpers - Simple migration system, type-safe queries
  5. Convention over Configuration - Everything just works with sensible defaults

Alpine.js Components Used:

  • @copyButton - One-line copy-to-clipboard with visual feedback
  • @toastContainer - Global notification system
  • x-data - Component state management
  • x-model - Two-way data binding
  • x-show - Conditional rendering
  • @blur - Event handling

Next Steps

Want to expand your URL shortener? Try these challenges:

Easy

  • Add a "Visit" link in the table (opens original URL in new tab)
  • Sort URLs by click count (most popular first)
  • Add a "Created" date column
  • Change the short code generator to use uppercase letters too

Medium

  • Add URL validation (check if original_url is a valid URL)
  • Prevent duplicate short codes (check before creating)
  • Add search/filter to the URL list (Alpine.js x-show + includes())
  • Add QR code generation for each short URL

Hard

  • Add user authentication (only logged-in users can create URLs)
  • Add analytics dashboard (chart showing clicks over time)
  • Add custom domains (e.g., short.io/abc123 vs localhost:8080/abc123)
  • Add expiration dates for URLs (auto-delete after 30 days)

Troubleshooting

Problem: "Database connection failed"

  • Check PostgreSQL is running: systemctl status postgresql
  • Verify credentials in config/config.toml
  • Try: varel db create again

Problem: "Table 'url' does not exist"

  • Run migrations: varel db migrate
  • Check migration status: varel db status

Problem: "Alpine.js copy button doesn't work"

  • Make sure you included: <#include "shared/alpine_components.vtpl">
  • Check browser console for errors (F12)
  • Verify Alpine.js is loaded: static/js/alpine.min.js should exist

Problem: "Route not found" for short codes

  • Make sure the /:short_code route is LAST in main.v
  • Restart the server after adding routes

Problem: "Clicks aren't incrementing"

  • Check the redirect() method is correct
  • Look for errors in terminal output
  • Verify the URL exists before testing

Learn More


Congratulations on building your first Varel application! 🎉

You've learned the core concepts of Varel web development. The same patterns you used here (scaffold → migrate → customize) work for any CRUD application: blogs, todo lists, inventory systems, and more.

Questions or feedback? Open an issue at https://github.com/leafscale/varel/issues