Build a Custom URL Shortener with .NET 8 and ASP.NET Core

November 15, 2024C# • .NET • ASP.NET Core • Software Development

URL shortener code and network diagram

Loading...

URL shorteners are everywhere—from social media to email campaigns. But building your own gives you control over analytics, branding, and custom features. In this guide, we'll build a production-ready URL shortener using .NET 8 and ASP.NET Core with a hash-based approach that generates compact, collision-resistant short codes.

Why Build Your Own?

Prerequisites

Project Setup

Create a new ASP.NET Core Web API project:

dotnet new webapi -n UrlShortener
cd UrlShortener

Core Implementation: Hash-Based Short Code Generation

Instead of using Base64 encoding, we'll use a hash-based approach that combines URL content with a timestamp to create unique, short codes. This method ensures better distribution and avoids collisions.

Step 1: Create the ShortLink Service

Add a new file Services/ShortLinkService.cs:

using System.Security.Cryptography;
using System.Text;

namespace UrlShortener.Services;

public interface IShortLinkService
{
    string GenerateShortCode(string originalUrl);
    bool IsValidShortCode(string code);
}

public class ShortLinkService : IShortLinkService
{
    private const string Base62Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    private const int ShortCodeLength = 7;

    public string GenerateShortCode(string originalUrl)
    {
        // Combine URL with current timestamp for uniqueness
        var input = $"{originalUrl}{DateTime.UtcNow.Ticks}";
        
        // Compute SHA-256 hash
        using var sha256 = SHA256.Create();
        var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(input));
        
        // Convert first 4 bytes to a number
        var number = BitConverter.ToUInt32(hashBytes, 0);
        
        // Convert to Base62 string (URL-safe)
        return ConvertToBase62(number);
    }

    private string ConvertToBase62(ulong number)
    {
        var result = new StringBuilder(ShortCodeLength);
        
        for (int i = 0; i < ShortCodeLength; i++)
        {
            result.Append(Base62Chars[(int)(number % 62)]);
            number /= 62;
        }
        
        return result.ToString();
    }

    public bool IsValidShortCode(string code)
    {
        return !string.IsNullOrWhiteSpace(code) &&
               code.Length == ShortCodeLength &&
               code.All(c => Base62Chars.Contains(c));
    }
}

Step 2: Create the Data Model

Add Models/ShortUrl.cs:

namespace UrlShortener.Models;

public class ShortUrl
{
    public int Id { get; set; }
    public string OriginalUrl { get; set; } = string.Empty;
    public string ShortCode { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public int ClickCount { get; set; }
    public DateTime? ExpiresAt { get; set; }
}

Step 3: Database Context

If using Entity Framework Core, add Data/UrlShortenerContext.cs:

using Microsoft.EntityFrameworkCore;
using UrlShortener.Models;

namespace UrlShortener.Data;

public class UrlShortenerContext : DbContext
{
    public UrlShortenerContext(DbContextOptions<UrlShortenerContext> options)
        : base(options)
    {
    }

    public DbSet<ShortUrl> ShortUrls { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ShortUrl>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.HasIndex(e => e.ShortCode).IsUnique();
            entity.Property(e => e.OriginalUrl).IsRequired().HasMaxLength(2048);
            entity.Property(e => e.ShortCode).IsRequired().HasMaxLength(10);
        });
    }
}

Step 4: Controller Implementation

Create Controllers/UrlController.cs:

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using UrlShortener.Data;
using UrlShortener.Models;
using UrlShortener.Services;

namespace UrlShortener.Controllers;

[ApiController]
[Route("api/[controller]")]
public class UrlController : ControllerBase
{
    private readonly UrlShortenerContext _context;
    private readonly IShortLinkService _shortLinkService;

    public UrlController(UrlShortenerContext context, IShortLinkService shortLinkService)
    {
        _context = context;
        _shortLinkService = shortLinkService;
    }

    [HttpPost("shorten")]
    public async Task<IActionResult> ShortenUrl([FromBody] ShortenRequest request)
    {
        if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri))
        {
            return BadRequest("Invalid URL format");
        }

        var shortCode = _shortLinkService.GenerateShortCode(request.Url);
        
        // Ensure uniqueness (retry if collision)
        while (await _context.ShortUrls.AnyAsync(s => s.ShortCode == shortCode))
        {
            shortCode = _shortLinkService.GenerateShortCode($"{request.Url}{Guid.NewGuid()}");
        }

        var shortUrl = new ShortUrl
        {
            OriginalUrl = request.Url,
            ShortCode = shortCode,
            CreatedAt = DateTime.UtcNow,
            ExpiresAt = request.ExpiresInDays.HasValue 
                ? DateTime.UtcNow.AddDays(request.ExpiresInDays.Value) 
                : null
        };

        _context.ShortUrls.Add(shortUrl);
        await _context.SaveChangesAsync();

        var baseUrl = $"{Request.Scheme}://{Request.Host}";
        return Ok(new { shortUrl = $"{baseUrl}/{shortCode}" });
    }

    [HttpGet("{code}")]
    public async Task<IActionResult> Redirect(string code)
    {
        if (!_shortLinkService.IsValidShortCode(code))
        {
            return NotFound();
        }

        var shortUrl = await _context.ShortUrls
            .FirstOrDefaultAsync(s => s.ShortCode == code);

        if (shortUrl == null || (shortUrl.ExpiresAt.HasValue && shortUrl.ExpiresAt.Value < DateTime.UtcNow))
        {
            return NotFound();
        }

        shortUrl.ClickCount++;
        await _context.SaveChangesAsync();

        return Redirect(shortUrl.OriginalUrl);
    }
}

public record ShortenRequest(string Url, int? ExpiresInDays = null);

Step 5: Register Services

Update Program.cs:

using Microsoft.EntityFrameworkCore;
using UrlShortener.Data;
using UrlShortener.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Register our services
builder.Services.AddScoped<IShortLinkService, ShortLinkService>();

// Database configuration (example for SQLite)
builder.Services.AddDbContext<UrlShortenerContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Advanced Features

1. Rate Limiting

Prevent abuse by adding rate limiting middleware:

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("shorten", opt =>
    {
        opt.Window = TimeSpan.FromMinutes(1);
        opt.PermitLimit = 10;
    });
});

2. Caching

Cache frequently accessed URLs for better performance:

builder.Services.AddMemoryCache();

// In controller, inject IMemoryCache and check cache before DB lookup

3. Analytics Endpoint

Add an endpoint to track click statistics:

[HttpGet("analytics/{code}")]
public async Task<IActionResult> GetAnalytics(string code)
{
    var shortUrl = await _context.ShortUrls
        .FirstOrDefaultAsync(s => s.ShortCode == code);
    
    if (shortUrl == null) return NotFound();
    
    return Ok(new
    {
        shortCode = shortUrl.ShortCode,
        originalUrl = shortUrl.OriginalUrl,
        clickCount = shortUrl.ClickCount,
        createdAt = shortUrl.CreatedAt
    });
}

Testing the Service

Use curl or Postman to test:

# Shorten a URL
POST https://localhost:5001/api/url/shorten
Content-Type: application/json

{
  "url": "https://example.com/very/long/url/here",
  "expiresInDays": 30
}

# Response
{
  "shortUrl": "https://localhost:5001/aB3dF9k"
}

# Redirect (use in browser)
GET https://localhost:5001/aB3dF9k

Performance Considerations

Production Deployment Tips

Conclusion

You now have a production-ready URL shortener that you can customize for your specific needs. The hash-based approach ensures uniqueness while keeping codes short and memorable. Extend it with analytics, custom domains, or QR code generation as needed.

For more development workflows, check out Vibe Coding and MCP Server Use Cases.

Loading...