One URL, Two Formats: A Business Card for Browsers and Terminals

René Kulik on 15.10.2026

Introduction

I wanted a digital business card: one short domain I can put on a slide, in an email signature or say out loud, that leads to everything else. I registered kulik.tech for it.

A plain HTML page would have done the job. But most people I hand this card to are developers, so I wanted it to work in the place they spend their day as well: the terminal.

curl -L kulik.tech

This post walks through how it works. The whole project is one edge function, one redirects file and two static files, but a few of the details are easy to get wrong.

The Idea

The same URL serves two formats:

  René Kulik
  Freelance Software Developer · Hamburg, Germany

  I build AI-powered applications, backend systems,
  user interfaces and agent workflows.

  website   https://kulik.tech/website
  blog      https://kulik.tech/blog
  email     rene@kulik.io
  github    https://kulik.tech/gh
  linkedin  https://kulik.tech/li
  ...

On top of that, short links like kulik.tech/gh forward to my profiles.

Project Setup

The site runs on Netlify and has no build step. The repository looks like this:

netlify/edge-functions/card.ts
public/_redirects
public/card.txt
public/index.html
netlify.toml

netlify.toml sets public as the publish directory. The only dependency is @netlify/edge-functions for its types. For local development, I run the Netlify CLI through pnpm dlx with a pinned version, so it never ends up in the lockfile:

{
  "scripts": {
    "dev": "pnpm --package=netlify-cli@27.12.0 dlx netlify dev --offline"
  }
}

The HTML page inlines its CSS and uses an inline SVG as its favicon, so a visit is a single request.

Detecting the Terminal at the Edge

The interesting part is deciding which format to serve. That happens in a Netlify Edge Function that runs on /:

import type { Config, Context } from "@netlify/edge-functions";

const CLI_USER_AGENT = /^(curl|wget|httpie|xh)\//i;

const isCliRequest = (request: Request): boolean => {
  const userAgent = request.headers.get("user-agent") ?? "";

  return CLI_USER_AGENT.test(userAgent);
};

export default async (request: Request, context: Context): Promise<Response | URL> => {
  if (isCliRequest(request)) {
    return new URL("/card.txt", request.url);
  }

  const response = await context.next();
  response.headers.append("Vary", "User-Agent");

  return response;
};

export const config: Config = {
  path: "/",
};

Command-line HTTP clients identify themselves with a user agent like curl/8.5.0 or Wget/1.21.4. The regex is anchored to the start of the string and expects a slash after the name, so a browser user agent never matches by accident. If someone fakes their user agent, they get the text card in their browser. That is fine.

Why User-Agent and Not Accept

The textbook way to pick a format is content negotiation through the Accept header: the client says what it wants, and the server responds accordingly.

In practice, that does not help here. curl sends Accept: */* by default, which means “anything is fine”. So does wget. The header carries no information about whether the client is a terminal, so the user agent is the more useful signal.

Rewrite, Not Redirect

When an edge function returns a URL instead of a Response, Netlify treats it as a rewrite. It serves the content of /card.txt, but the client never sees a different URL. There is no extra round trip and no Location header. The terminal gets the text card directly at /.

For every other request, context.next() passes the request on to the static index.html.

Telling Caches What Changed

This is the detail that is easiest to forget. / now returns different bodies depending on a request header. Any cache sitting between the client and the server, like a corporate proxy, keys its entries by URL. Without further information, it could store the text card and hand it to the next visitor’s browser, or the other way around.

The Vary header solves this. Vary: User-Agent tells caches that the user agent is part of the cache key. Netlify itself does not need it, because the edge function runs before its cache on every request. But a cache further down the line only gets the full picture if both responses carry the header.

The HTML branch appends it in the edge function. The text branch is trickier: it returns a URL, not a Response, so there is no response object to set a header on. Instead, the header goes into netlify.toml. Headers defined for /card.txt also apply when / is rewritten to it:

[[headers]]
  for = "/card.txt"
  [headers.values]
    Content-Type = "text/plain; charset=utf-8"
    Vary = "User-Agent"
    X-Robots-Tag = "noindex"

Requests to /card.txt itself carry the header too, which is harmless. A quick check confirms both branches:

curl -sI https://kulik.tech/ | grep -i vary
# vary: User-Agent

curl -sI -A "Mozilla/5.0" https://kulik.tech/ | grep -i vary
# vary: Accept-Encoding, User-Agent

Why the Command Needs -L

Without a scheme, curl requests http://kulik.tech. Netlify answers with a 301 to the HTTPS version, and curl does not follow redirects by default:

curl -sI kulik.tech
# HTTP/1.1 301 Moved Permanently
# Location: https://kulik.tech/

The -L flag tells curl to follow it. That is why the HTML page shows curl -L kulik.tech as its prompt line: it is the shortest command that actually works.

The short links live in public/_redirects, which Netlify reads at deploy time:

# 302 so targets can change later without browsers caching the old one forever
/website  https://www.kulik.io/                     302
/blog     https://www.kulik.io/blog/                302
/gh       https://github.com/rkulik                 302
/li       https://www.linkedin.com/in/rene-kulik/   302

Using 302 instead of 301 is deliberate. Browsers may cache a 301 indefinitely. If I ever change where /li points, anyone who visited it before would keep going to the old target, and there is nothing I can do about it from the server side. A 302 is checked again on every visit.

The text card uses these short links as well. That keeps its lines short enough for a narrow terminal, and the targets only live in one place.

Headers for the Text Card

Besides Vary, netlify.toml sets two more headers on the text card:

Trade-offs

The text card and the HTML page contain the same information, maintained in two files. I could generate both from one source, but that would mean adding a build step to a project that currently has none. With eight links that rarely change, keeping two files in sync is the simpler option.

User agent detection is also a heuristic. A client I did not list, or a curl with a custom user agent, gets the HTML page. For a business card, that is an acceptable failure mode: the HTML is still readable in a terminal, just not pretty.

Conclusion

A business card that works in the browser and the terminal takes very little code on Netlify: an edge function that checks the user agent and rewrites to a text file, a redirects file for the short links and a few headers. The part worth paying attention to is caching. Whenever one URL serves different content based on a request header, every variant needs a Vary header for that request header.

Try it yourself:

curl -L kulik.tech