Skip to main content

How to Create a URL Shortener with Bitly API 2026

·APIScout Team
Share:

How to Create a URL Shortener with the Bitly API

Bitly shortens URLs, tracks clicks, and generates QR codes. This guide covers the Bitly API v4 for programmatic link management — shortening, custom domains, analytics, and bulk operations.

What You'll Build

  • URL shortening with custom back-halves
  • Click analytics and tracking
  • QR code generation
  • Custom branded domains
  • Bulk URL shortening

Prerequisites: Bitly account (free: 10 links/month, Basic: $8/month for 250 links).

1. Setup

Get API Token

  1. Go to app.bitly.com
  2. Settings → Developer Settings → API
  3. Generate Access Token
BITLY_ACCESS_TOKEN=your_access_token
BITLY_GROUP_GUID=your_default_group_guid

API Helper

// lib/bitly.ts
const BITLY_TOKEN = process.env.BITLY_ACCESS_TOKEN!;
const BASE_URL = 'https://api-ssl.bitly.com/v4';

export async function bitlyApi(
  path: string,
  options?: RequestInit & { params?: Record<string, string> }
) {
  let url = `${BASE_URL}${path}`;

  if (options?.params) {
    const searchParams = new URLSearchParams(options.params);
    url += `?${searchParams}`;
  }

  const res = await fetch(url, {
    ...options,
    headers: {
      Authorization: `Bearer ${BITLY_TOKEN}`,
      'Content-Type': 'application/json',
      ...options?.headers,
    },
  });

  if (!res.ok) {
    const error = await res.json();
    throw new Error(`Bitly API error: ${error.message || res.statusText}`);
  }

  return res.json();
}

2. Shorten URLs

Basic Shortening

export async function shortenUrl(longUrl: string, options?: {
  title?: string;
  tags?: string[];
  customBackhalf?: string;
  domain?: string;
}): Promise<{
  shortUrl: string;
  id: string;
  longUrl: string;
}> {
  const data = await bitlyApi('/shorten', {
    method: 'POST',
    body: JSON.stringify({
      long_url: longUrl,
      domain: options?.domain || 'bit.ly',
      ...(options?.title && { title: options.title }),
      ...(options?.tags && { tags: options.tags }),
    }),
  });

  // Set custom back-half if provided
  if (options?.customBackhalf) {
    await bitlyApi(`/bitlinks/${data.id}`, {
      method: 'PATCH',
      body: JSON.stringify({
        custom_bitlinks: [`${options.domain || 'bit.ly'}/${options.customBackhalf}`],
      }),
    });
  }

  return {
    shortUrl: data.link,
    id: data.id,
    longUrl: data.long_url,
  };
}
export async function createBitlink(options: {
  longUrl: string;
  title?: string;
  tags?: string[];
  domain?: string;
  deeplinks?: {
    appUriPath: string;
    installUrl: string;
    installType: string;
  }[];
}) {
  return bitlyApi('/bitlinks', {
    method: 'POST',
    body: JSON.stringify({
      long_url: options.longUrl,
      domain: options.domain || 'bit.ly',
      title: options.title,
      tags: options.tags,
      deeplinks: options.deeplinks?.map(dl => ({
        app_uri_path: dl.appUriPath,
        install_url: dl.installUrl,
        install_type: dl.installType,
      })),
    }),
  });
}

API Route

// app/api/shorten/route.ts
import { NextResponse } from 'next/server';
import { shortenUrl } from '@/lib/bitly';

export async function POST(req: Request) {
  const { url, title, tags } = await req.json();

  if (!url) {
    return NextResponse.json({ error: 'URL required' }, { status: 400 });
  }

  try {
    const result = await shortenUrl(url, { title, tags });
    return NextResponse.json(result);
  } catch (error: any) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }
}

3. Click Analytics

Get Click Summary

export async function getClickSummary(bitlinkId: string, unit: string = 'day', units: number = 30) {
  return bitlyApi(`/bitlinks/${bitlinkId}/clicks/summary`, {
    params: { unit, units: String(units) },
  });
}

// Example response:
// { total_clicks: 1523, unit: 'day', units: 30 }

Get Clicks Over Time

export async function getClicksByTime(
  bitlinkId: string,
  options: {
    unit?: 'minute' | 'hour' | 'day' | 'week' | 'month';
    units?: number;
  } = {}
) {
  const { unit = 'day', units = 30 } = options;

  return bitlyApi(`/bitlinks/${bitlinkId}/clicks`, {
    params: { unit, units: String(units) },
  });
}

// Response:
// {
//   link_clicks: [
//     { clicks: 45, date: '2026-03-08T00:00:00+0000' },
//     { clicks: 32, date: '2026-03-07T00:00:00+0000' },
//     ...
//   ]
// }

Get Click Details (Country, Referrer, Device)

export async function getClickMetrics(bitlinkId: string) {
  const [countries, referrers, devices] = await Promise.all([
    bitlyApi(`/bitlinks/${bitlinkId}/countries`, {
      params: { unit: 'day', units: '30' },
    }),
    bitlyApi(`/bitlinks/${bitlinkId}/referrers`, {
      params: { unit: 'day', units: '30' },
    }),
    bitlyApi(`/bitlinks/${bitlinkId}/clicks`, {
      params: { unit: 'day', units: '30' },
    }),
  ]);

  return { countries, referrers, clicks: devices };
}

4. QR Codes

export async function getQRCode(bitlinkId: string, options?: {
  imageFormat?: 'png' | 'svg';
  color?: string;
  backgroundColor?: string;
  size?: number;
}): Promise<string> {
  // Bitly QR codes are available via the link
  const qrUrl = `https://api-ssl.bitly.com/v4/bitlinks/${bitlinkId}/qr`;

  const params: Record<string, string> = {};
  if (options?.imageFormat) params.image_format = options.imageFormat;
  if (options?.color) params.color = options.color;

  const res = await fetch(`${qrUrl}?${new URLSearchParams(params)}`, {
    headers: {
      Authorization: `Bearer ${process.env.BITLY_ACCESS_TOKEN}`,
    },
  });

  // For PNG, return as base64 data URL
  if (options?.imageFormat === 'png' || !options?.imageFormat) {
    const buffer = await res.arrayBuffer();
    return `data:image/png;base64,${Buffer.from(buffer).toString('base64')}`;
  }

  // For SVG, return SVG string
  return res.text();
}

5. Custom Branded Domains

// List available branded domains
export async function getBrandedDomains() {
  return bitlyApi('/bsds'); // Branded Short Domains
}

// Use custom domain when shortening
const result = await shortenUrl('https://yourapp.com/signup', {
  domain: 'yourbrand.link', // Your custom short domain
  customBackhalf: 'signup',
});
// Result: https://yourbrand.link/signup

6. Bulk URL Shortening

export async function bulkShorten(urls: {
  longUrl: string;
  title?: string;
  tags?: string[];
}[]): Promise<{
  shortUrl: string;
  longUrl: string;
  id: string;
}[]> {
  const results = [];

  // Bitly doesn't have a batch endpoint — process sequentially with rate limiting
  for (const url of urls) {
    try {
      const result = await shortenUrl(url.longUrl, {
        title: url.title,
        tags: url.tags,
      });
      results.push(result);
    } catch (error: any) {
      results.push({
        shortUrl: '',
        longUrl: url.longUrl,
        id: '',
        error: error.message,
      });
    }

    // Rate limit: 150 requests per minute on free plan
    await new Promise(resolve => setTimeout(resolve, 400));
  }

  return results;
}
// Update a bitlink
export async function updateBitlink(bitlinkId: string, updates: {
  title?: string;
  tags?: string[];
  archived?: boolean;
}) {
  return bitlyApi(`/bitlinks/${bitlinkId}`, {
    method: 'PATCH',
    body: JSON.stringify(updates),
  });
}

// Get link info
export async function getBitlink(bitlinkId: string) {
  return bitlyApi(`/bitlinks/${bitlinkId}`);
}

// List all bitlinks
export async function listBitlinks(groupGuid: string, options?: {
  size?: number;
  page?: number;
  tags?: string[];
}) {
  const params: Record<string, string> = {
    size: String(options?.size || 50),
    page: String(options?.page || 1),
  };

  if (options?.tags) {
    params.tags = options.tags.join(',');
  }

  return bitlyApi(`/groups/${groupGuid}/bitlinks`, { params });
}

8. URL Shortener Component

// components/UrlShortener.tsx
'use client';
import { useState } from 'react';

export function UrlShortener() {
  const [url, setUrl] = useState('');
  const [shortUrl, setShortUrl] = useState('');
  const [loading, setLoading] = useState(false);
  const [copied, setCopied] = useState(false);

  const handleShorten = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);

    const res = await fetch('/api/shorten', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url }),
    });

    const data = await res.json();
    setShortUrl(data.shortUrl);
    setLoading(false);
  };

  const handleCopy = () => {
    navigator.clipboard.writeText(shortUrl);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <div style={{ maxWidth: 500, margin: '0 auto' }}>
      <form onSubmit={handleShorten}>
        <input
          type="url"
          value={url}
          onChange={(e) => setUrl(e.target.value)}
          placeholder="Paste a long URL..."
          required
          style={{ width: '100%', padding: '12px', fontSize: '16px' }}
        />
        <button
          type="submit"
          disabled={loading}
          style={{ width: '100%', padding: '12px', marginTop: '8px' }}
        >
          {loading ? 'Shortening...' : 'Shorten URL'}
        </button>
      </form>

      {shortUrl && (
        <div style={{
          marginTop: '16px',
          padding: '12px',
          background: '#f0f0f0',
          borderRadius: '6px',
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
        }}>
          <a href={shortUrl} target="_blank" rel="noopener noreferrer">
            {shortUrl}
          </a>
          <button onClick={handleCopy}>
            {copied ? '✓ Copied' : 'Copy'}
          </button>
        </div>
      )}
    </div>
  );
}

Pricing

PlanPriceLinks/MonthFeatures
Free$010Basic shortening
Core$8/month250Custom back-halves
Growth$29/month1,500Custom domains, QR codes
Premium$199/month10,000Advanced analytics, API
EnterpriseCustomUnlimitedSSO, dedicated support

Bitly vs Alternatives

FeatureBitlyShort.ioRebrandlyTinyURL
Free tier10 links1,000 links25 linksUnlimited
Custom domains✅ (paid)✅ (free)
APILimited
QR codes
Click analytics
Self-hostable

Common Mistakes

MistakeImpactFix
Not handling duplicate URLsBitly returns existing bitlinkCheck response for existing link
Exceeding rate limits429 errorsImplement backoff, batch with delays
Shortening already-short URLsDouble redirect, slowerCheck if URL is already a bitlink
Not tracking campaign tagsCan't attribute clicks to campaignsUse UTM parameters + Bitly tags
Ignoring 403 on custom back-halvesBack-half already takenHandle error, suggest alternatives

Bitly vs. Building Your Own URL Shortener

Bitly is the fastest path to URL shortening with analytics, but not always the right one. Understanding when to use a third-party API versus building your own shortener is a meaningful architectural decision.

Use Bitly (or Short.io, Rebrandly) when: you need link analytics (click counts, geographic data, referrer data) without building the tracking infrastructure yourself; you want QR code generation; you need branded short domains without managing redirect infrastructure; your link volume is under a few thousand per month where pricing is manageable; or you want the trust signal of a recognized short domain (bit.ly links have higher click-through rates than unknown custom short domains for some audiences).

Build your own when: you have high link volume where API costs exceed infrastructure costs; you need custom business logic around redirects (A/B testing redirects, user-segment-based landing pages, time-based expiration with your own rules); you can't share user link data with a third party (privacy requirements); or you need the short domain to be your own brand from day one with no third-party dependency.

Self-building a URL shortener is architecturally simple. Store a mapping of short codes to long URLs in a database (Redis works well for O(1) lookup), generate a random short code (6-8 characters, alphanumeric), and configure a minimal server or serverless function to handle the redirect:

// Minimal Redis-based URL shortener (Next.js API route):
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });

// POST /api/shorten — create short link
export async function createShortLink(longUrl: string): Promise<string> {
  const code = Math.random().toString(36).substring(2, 8);  // e.g., "k3m9xp"
  await redis.set(`url:${code}`, longUrl, { EX: 365 * 24 * 60 * 60 });  // 1 year TTL
  return `https://your.domain/${code}`;
}

// GET /[code] — redirect
// In Next.js app router: app/[code]/route.ts
export async function GET(req: Request, { params }: { params: { code: string } }) {
  const longUrl = await redis.get(`url:${params.code}`);
  if (!longUrl) return new Response('Not found', { status: 404 });

  // Track click:
  await redis.incr(`clicks:${params.code}`);

  return Response.redirect(longUrl, 301);
}

This self-hosted version costs ~$0 in infrastructure (free Upstash Redis tier handles 10K operations/day), handles tens of thousands of redirects per day, and gives you complete data ownership. The trade-off is no analytics dashboard, no geographic data, and no QR code generation without additional implementation.


Bitly's analytics tell you how many clicks a link received, but not why — which campaign, which email, which ad drove the click. UTM parameters solve this by carrying context from the click source through to your analytics platform.

UTM parameters work at the destination URL level, not the short URL level. When you shorten https://yourapp.com/signup?utm_source=email&utm_medium=newsletter&utm_campaign=march-2026, Bitly records clicks on the short URL while your analytics platform (Google Analytics, Posthog, Mixpanel) records the UTM attribution when the user lands on the destination page.

Standard UTM parameters and their meaning:

utm_source    — Who sent the traffic: email, twitter, newsletter, google
utm_medium    — The marketing channel: cpc, social, email, organic
utm_campaign  — The specific campaign: spring-launch, welcome-series, q1-promotion
utm_content   — Differentiates creatives within a campaign: hero-cta, footer-link, blue-button
utm_term      — Paid search keyword: "project management software"

A practical naming convention matters at scale. Establish a consistent format before you start creating links — once you have thousands of UTM-tagged links in analytics, inconsistent naming makes segmentation impossible. A common convention: utm_source=email, utm_medium=newsletter, utm_campaign=YYYY-MM-campaign-name. Use lowercase only (UTM values are case-sensitive — "Email" and "email" are different sources in most analytics tools).

Combining Bitly tags with UTM parameters gives you two layers of attribution: Bitly's tags let you filter your link library by campaign, audience, or channel inside Bitly's dashboard; UTM parameters carry attribution to your analytics platform. Create a link creation convention where every campaign link gets both Bitly tags (for Bitly organization) and UTM parameters (for analytics attribution):

const campaignLink = await createBitlink({
  longUrl: 'https://yourapp.com/signup?utm_source=email&utm_medium=newsletter&utm_campaign=march-2026',
  title: 'March Newsletter CTA',
  tags: ['email', 'newsletter', 'march-2026'],
  domain: 'go.yourapp.com',
});

Bitly's paid plans offer features beyond basic shortening that are valuable for specific use cases.

Link expiration on Bitly Growth and above lets you set a date after which a short link redirects to a custom URL (usually an "offer expired" landing page) instead of the original destination. This is useful for time-limited offers, event registration links (redirect to "registration closed" after the event), and seasonal campaigns. Configure expiration when creating links via the API:

const timedLink = await bitlyApi('/bitlinks', {
  method: 'POST',
  body: JSON.stringify({
    long_url: 'https://yourapp.com/black-friday-deal',
    domain: 'bit.ly',
    title: 'Black Friday Deal',
    archived: false,
  }),
});

// Update with expiration (via Bitly dashboard or PUT endpoint):
// Links expire and redirect to a fallback URL you configure

Password-protected links aren't natively available in Bitly's API — this is a common feature gap. If you need password protection (for shared documents, private content, or gated access), you'd need to build it yourself: redirect the short link to your own middleware that checks a session cookie or token before forwarding to the final destination.

A/B redirect testing is similarly not available in Bitly's API. For split-testing landing pages using short links, use your own redirect layer that randomly splits traffic between two destination URLs and tracks conversion per variant. The Bitly API short URL can point to your redirect handler, which then splits traffic.

For teams needing expiration, A/B testing, or access control in their short links, Short.io is worth evaluating — it natively supports link expiration, password protection, and redirect rules at a price point competitive with Bitly ($19-$49/month for equivalent features).


Methodology

Link previews and social sharing metadata: when a short link is shared on social platforms (Twitter/X, LinkedIn, Slack, iMessage), the platform fetches the Open Graph metadata from the destination URL to generate the preview card. Short links work transparently here — the platform follows the redirect and reads the OG tags from the final destination. However, if your destination page doesn't have OG tags, previews will be blank or minimal across all platforms. Bitly itself doesn't add OG metadata; that's the responsibility of your destination page. Ensure every shareable destination URL has og:title, og:description, og:image, and og:url tags populated for good social preview rendering.

Handling Bitly's 422 error on duplicate custom back-halves: when you attempt to create a custom back-half that already exists, the API returns a 422 Unprocessable Entity with a ALREADY_A_BITLY_LINK error. Implement retry logic with a suffix: try signup, then signup-2, then signup-3. Alternatively, maintain a registry of claimed custom back-halves in your database before calling the API, though this can drift from Bitly's actual state if links are created outside your application.

Bitly link security considerations: short links obscure the destination URL, which is both a feature and a risk. Users can't see where they're going before clicking. For internal applications (employee links, admin dashboards), this is rarely a problem. For consumer-facing links, consider adding a ?preview=1 parameter that shows the destination before redirecting, or use a "preview" page approach where the short link first shows a preview of the destination with your branding before the actual redirect. This also protects your domain reputation — a short link that redirects to a spam or phishing site damages trust in your domain even if the destination isn't your content.

Bitly API v4 documentation sourced as of March 2026. Rate limits (150 requests/minute on free plan, higher on paid plans) verified from Bitly API documentation. Short.io, Rebrandly, and TinyURL pricing from each provider's published pricing page as of March 2026. Custom short domain setup (CNAME configuration) for Bitly Growth plan described per Bitly's branded domain documentation. Redis-based self-hosted URL shortener example uses Upstash Redis with @upstash/redis or the standard redis npm package; redirect handling shown in Next.js App Router format. UTM parameter naming conventions follow Google Analytics 4 parameter documentation. QR code generation format options (PNG, SVG) verified from Bitly API QR code endpoint documentation as of March 2026.


Choosing a URL shortener API? Compare Bitly vs Short.io vs Rebrandly on APIScout — pricing, API features, and custom domain support.

Related: Building an AI Agent in 2026, Building an AI-Powered App: Choosing Your API Stack, Building an API Marketplace

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.