↖ Writing
Note in Obidian with a unique identifier in the permalink field

Stable URLs on Obsidian Publish

Stable URLs on Obsidian Publish

It is considered best practice on the web that “Cool URIs don’t change”: The link you copy today should still work tomorrow.

That is why I always felt uneasy about hosting my notes on Obsidian Publish. A major appeal of Evergreen notetaking is thought evolution: Over time, notes are reworded, improved and moved. But since Obsidian Publish builds the URL based on the note path, that also means a lot of changed URLs, which results in a lot of 404s.

I accepted this tradeoff and made the decision to prioritise notetaking ergonomics over stable links. If I thought twice about changing any note, my notetaking practice would stall, ultimately defeating the purpose of this Digital Garden.

But a few months ago Obsidian released permalinks in Obsidian Publish. The permalink field allows you to specify an URL that is used instead of the auto-generated URL from the title. This finally enables “cool”, stable, URLs in Obsidian.

I decided to use a unique ID as the permalink for each note. This way, the URL remains the same even if I move or rename the file.

Luckily, I don’t have to go through 500+ published notes by hand and come up with a unique name for each (😅) but code can accomplish that for me:

  1. Find all notes where the frontmatter includes publish: true and the permalink field holds no value (to not overwrite existing permalinks).
  2. For each note, write a universally unique identifier (UUID) in the permalink field.

JavaScript can produce UUIDs with the crypto.randomUUID() method (more on MDN). This produces a link like this: notes.joschua.io/14a3b208-c496-4888-93b9-e3bfa160e5d8. It’s unique, but it’s also kind of ugly 🤷‍♂️.

Instead, an API call to uuid.rocks can generate a short UUID through the aptly named package short-uuid. A link might look like this: notes.joschua.io/x2HYMKwttrCCoaznbBbSqb.

Surprisingly, this shorter UUID has the same risk of collisions (two IDs being the same) as the longer UUID mentioned above1. That likelihood, by the way, is incredibly low: To have a 50% probability of at least one collision you need to generate 1 billion UUIDs per second for about 86 years (according to Wikipedia). If my notetaking becomes that prolific, I can bear some duplicates 😅.

Even shorter UUIDs are not pretty. But they’re good enough for me2, especially because social previews and browsers tabs still use the title of the note.

Link preview image that shows Welcome as the title of the noteThe URL for this note is notes.joschua.io/x2HYMKwttrCCoaznbBbSqb but the social preview uses the readable title.

Updating all published notes

I ran this code in a Templater Plugin template file. It also requires the Dataview Plugin.

Note: This template modifies your notes without warning. I recommend backing up your notes before running it.

// Write Permalinks.md

// Reference the Dataview API concisely
const dv = app.plugins.plugins["dataview"].api;
// Get all notes through dataview
const notes = dv.pages();

// Filter to get only pages that have `publish: true` in the frontmatter and no `permalink` frontmatter field
const publishedNotesWithoutPermalink = notes.values.filter(
  (el) => el.file.frontmatter?.publish && !el.file.frontmatter?.permalink

// If there are no notes with that condition, exit early
if (!publishedNotesWithoutPermalink?.length) {
  new Notice("No notes without permalink found");

// Create an array of paths
const paths = publishedNotesWithoutPermalink.map((note) => note.file.path);

// Get the tFile object for each note
const tFiles = paths.map((el) => app.vault.getAbstractFileByPath(el));

// Get a short UUID for each note through API call
const response = await (
  await fetch(`https://uuid.rocks/json/bulk/?short&count=${tFiles.length}`)
const uuids = response.uuids;

// Loop through each note
await tFiles.forEach(async (tFile, index) => {
  const find = "publish: true";

  // instead of the API call above, you could also use the built-in browser method:
  // const uuid = crypto.randomUUID()
  const uuid = uuids[index]; // Get the UUID from the uuids array

  // Insert the permalink field into the frontmatter
  const insert = `\npermalink: ${uuid}`;
  const content = await app.vault.read(tFile);
  const contentInserted = content.replace(find, `${find}${insert}`);
  await app.vault.modify(tFile, contentInserted);
  console.log(`🔗 Added permalink ${uuid} in "${tFile.basename}"`);

// Show a notification with changed files
new Notice(
  `🔗 Added ${tFiles.length} permalink${tFiles.length > 1 ? "s" : ""}`

The code updated all files, and I uploaded the changes to Publish with trepidation. It took a bit for social previews to update, but nothing broke. Now I can link to notes with the certainty of stable URLs.

You can poke around this new structure on notes.joschua.io.

Handling new files

Just like that, all published notes have a unique permalink. But what about new files?

I already have a “pre-publish” template that I call to publish new files. By adding the code above to it, each time I publish new notes, it makes sure each note has a unique permalink.

The full template is available in a Github Gist.


  1. I think that is because it uses uppercase as well as lowercase characters, while crypto.randomUUID() only uses lowercase characters.

  2. Obsidian Publish has the option to Redirect old notes through the aliases key in the frontmatter. But keeping track of every rename or move of a file would introduce a lot of maintenance into notetaking.