Setu (Bengali সেতু, "bridge") is a tiny static URL shortener. You keep a links.json map of slugs to destinations; this package builds a folder of HTML redirect pages that you can host on GitHub Pages (or any static host).
There is no server, database, or serverless function. Each short link is a directory with an index.html that sends the browser onward with a meta refresh and a JavaScript fallback.
This repository is the npm package @to_milon/setu. Generated sites can be hosted on GitHub Pages or any static host.
GitHub Pages only serves static files. It cannot issue HTTP 301 / 302 redirects for arbitrary paths. Generating one HTML page per slug is a reliable workaround:
<meta http-equiv="refresh" content="0; url=…">window.location.replace(…)if the meta tag is ignored
That is good enough for sharing personal and project links. It is not a replacement for true HTTP redirects when SEO or crawler-critical canonical moves matter.
- Node.js 18.18 or later
- A GitHub repository if you want to deploy with the included Actions workflow
Use it in its own repo (recommended):
mkdir my-links && cd my-links
npm init -y
npm install --save-dev @to_milon/setu
npx setu initinit writes:
links.json— your slug → URL mapsetu.config.json— site title, base path, optional custom domain.github/workflows/deploy.yml— GitHub Pages deploy on push tomain.gitignore— ignoresdist/andnode_modules/build/devscripts onpackage.jsonwhen that file already exists
You can also run the CLI without a local install:
npx @to_milon/setu --helplinks.json:
{
"github": "https://github.com/your-username",
"site": "https://example.com"
}Build:
npx setuPreview locally (serves dist/ at http://localhost:3000):
npm run devOutput:
dist/
index.html # listing of every slug
404.html # unknown paths
.nojekyll # skip Jekyll on GitHub Pages
CNAME # only if you set a custom domain
links.json # copy of your map
github/index.html # redirect page
site/index.html
After deploy, /github opens the GitHub URL.
Values are merged in this order (later wins):
- Defaults
setu.config.json- A
"setu"object inpackage.json - CLI flags
Example setu.config.json:
{
"title": "go.example.com",
"description": "Short links for Example.",
"base": "/",
"cname": "go.example.com",
"favicon": "https://example.com/favicon.svg",
"footer": {
"text": "example.com",
"href": "https://example.com"
}
}| Field | Type | Default | Meaning |
|---|---|---|---|
links |
string | links.json |
Path to the slug map |
out |
string | dist |
Build directory |
base |
string | / |
URL prefix of the site. Use / on a custom domain or a username.github.io user site. Use /repo-name on a project site at https://username.github.io/repo-name/. |
cname |
string | (empty) | Custom domain. When set, a CNAME file is written into dist/. Leave empty for *.github.io URLs. |
title |
string | Short links |
Index and 404 heading |
description |
string | Static short links. |
Index subtitle |
favicon |
string | (empty) | Absolute or site-relative favicon URL |
footer |
object or string | (none) | { "text", "href" } or raw HTML string |
copyLinks |
boolean | true |
Copy links.json into dist/ |
setu [build] [options]
setu init [options]
--links <file>
--out <dir>
--base <path>
--cname <domain>
--title <text>
--description <text>
--favicon <url>
--config <file>
--no-copy-links
--force # init only: overwrite existing files
import { build } from '@to_milon/setu';
await build({
cwd: process.cwd(),
base: '/my-links',
title: 'My links',
});- Keys in
links.jsonbecome URL path segments. - Allowed: letters, numbers, hyphen, underscore; must start with a letter or number (
github,talk-2024,cv). - Destinations must be absolute URLs (
https://…orhttp://…). - Slugs are case-preserving on disk; keep them lowercase so links are easy to type.
Two different site types matter, because they change the public URL and whether you need a CNAME.
| Kind of repo | Public URL | base |
cname |
|---|---|---|---|
Project site (any repo name except username.github.io) |
https://username.github.io/repo-name/ |
/repo-name |
omit |
User or org site (repo named username.github.io or org.github.io) |
https://username.github.io/ |
/ |
omit |
| Either kind plus a custom domain | https://go.example.com/ |
/ |
go.example.com |
In all cases:
- Push the project to GitHub.
- Repo Settings → Pages → Build and deployment → Source = GitHub Actions.
- Keep the workflow that uploads the
dist/artifact (created byinit, or see below). - After the first green run, the Pages URL appears in the Actions log and under Settings → Pages.
The workflow publishes dist/ on every push to main. It also writes .nojekyll so GitHub does not run Jekyll (which would ignore folders that start with _).
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install
- run: npm run build
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4Use this when you are fine with https://username.github.io/… and do not own (or do not want to configure) a custom hostname.
This is the usual case: the repo is named something like links or go, not username.github.io.
Example
- GitHub user:
ada - Repository:
links - Short link you want:
https://ada.github.io/links/github
Config
{
"title": "ada’s links",
"description": "Short links.",
"base": "/links"
}Do not set cname. A CNAME file would tell GitHub Pages to serve a custom domain instead of ada.github.io.
Why base must match the repo name
On a project site, GitHub Pages mounts the site at /{repo}/, not at /. Index links, the 404 “view all links” button, and any root-absolute hrefs have to include that prefix. If you leave base as /, a listing link would point at https://ada.github.io/github (missing /links) and 404.
The repo name in the URL is case-sensitive. If the repo is My-Links, use "base": "/My-Links".
Checklist
- Create the GitHub repo
links(public, unless you have GitHub Pro/Team/Enterprise Pages for private repos). - Commit
links.json,setu.config.jsonwith"base": "/links",package.json, and the workflow. - Settings → Pages → Source = GitHub Actions.
- Push to
mainand wait for the Deploy workflow. - Open
https://ada.github.io/links/for the listing andhttps://ada.github.io/links/githubfor a slug. - If you see a 404 on
/links/but/links/index.htmlworks, wait a minute and hard-refresh; the first Pages deploy can lag. Confirm the workflow uploadeddist/, not the repo root.
Local preview note
npx serve dist always serves at /. For a faithful local check of a project site, either:
- build with
--base /while iterating locally, or - put
dist/behind a folder named like the repo (mkdir -p preview/links && cp -R dist/* preview/links/ && npx serve preview) so/links/githubresolves.
If the repository is named exactly username.github.io (for example ada.github.io), GitHub Pages serves it at the domain root.
Config
{
"title": "ada.github.io",
"base": "/"
}Still no cname. Short links look like https://ada.github.io/github.
This repo name is special: it can also be deployed from the main (or docs) branch without Actions, but using the Actions workflow here is simpler and matches the project-site setup.
Use this when you want https://go.example.com/github instead of a github.io URL. link.milon.im is an example of that setup.
Custom domains work with either a project repo or a username.github.io repo. Once the domain is attached, GitHub serves the site at the domain root, so base should be / even if the repo is named links.
Example
- Domain:
go.example.com - Desired link:
https://go.example.com/github
Config
{
"title": "go.example.com",
"description": "Short links.",
"base": "/",
"cname": "go.example.com"
}The build writes dist/CNAME containing go.example.com. GitHub Pages reads that file and associates the domain with the site.
Pick one of these. Do not mix conflicting records.
Subdomain (go.example.com, recommended)
At your DNS host, add a CNAME record:
| Type | Name / host | Value |
|---|---|---|
CNAME |
go |
username.github.io |
The value is always username.github.io (or org.github.io), not username.github.io/repo. GitHub maps the hostname to the right repository using the CNAME file and Pages settings.
Apex domain (example.com)
GitHub documents A (and optionally AAAA) records to their Pages IPs. Apex CNAMEs are often unsupported by DNS providers. If you use the apex, also add www as a CNAME to username.github.io and choose a primary domain in Pages settings.
TTL: start with a low TTL (300 seconds) until the domain works, then raise it.
- Deploy once so
CNAMEexists on the Pages site (or type the domain under Settings → Pages → Custom domain and save; GitHub will commit aCNAMEif you use branch deploys — with Actions, prefer generating it fromsetu.config.jsonso it does not get wiped on the next build). - Settings → Pages → Custom domain =
go.example.com. - Wait for DNS check to pass. Failures are usually a wrong
CNAMEtarget, a leftoverArecord, or DNS not propagated. - Enable Enforce HTTPS. Certificate provisioning can take several minutes after DNS is correct. Do not toggle the domain on and off while it is issuing.
- If the site was previously available at
https://username.github.io/repo/, GitHub will redirect that URL to the custom domain.
"base": "/"and"cname": "go.example.com"in config.- DNS
CNAMEgo→username.github.io. - Pages source = GitHub Actions; workflow green.
- Pages custom domain shows a checkmark; HTTPS is enforced.
https://go.example.com/lists slugs;https://go.example.com/githubredirects.
- Change
basefrom/repo-nameto/. - Set
cname. - Redeploy, then add DNS and the Pages custom domain.
- Old links under
https://username.github.io/repo-name/slugwill stop matching unless you keepbaseas/repo-name— which you should not do on a custom domain, becausehttps://go.example.com/repo-name/slugwould be the path. Plan a cutover; update any already-shared URLs.
Remove cname from config (so no CNAME file is emitted), delete the custom domain in Pages settings, and set base back to /repo-name for a project site. Clear leftover DNS records so they do not keep pointing at GitHub.
- Redirects are client-side, not HTTP
301/302. - Unknown slugs depend on GitHub Pages serving
404.htmlfor missing paths (this is the default). - There are no click analytics unless you add your own (for example a privacy-friendly script on the redirect template).
- Query strings and hash fragments on the short URL are not forwarded today; the destination is exactly the string in
links.json.
