Vadim Ferderer

Texivia

3 kB router. Zero dependencies. Any framework.
From Latin textor (weaver) + via (road) — "path weaver."
#router · #typescript · #framework-agnostic · #svelte · #vue
Bundle Size npm version TypeScript License
npm install texivia-router

Why Texivia?

React RouterVue RouterTexivia
Size (min+gzip)12.4 KB9.8 KB3 kB
Framework lock-inReactVueNone
Route matchingRuntimeRuntimeCompiled regex
Navigation APIProprietaryProprietaryDOM events

Texivia compiles all routes into a single regex at startup. Matching is a single exec() call — O(1) regardless of route count.

Quick Start

import { Router } from 'texivia-router';

const router = new Router([
  { path: '/', view: Home },
  { path: '/recipe/{id}', view: RecipeDetail },
  { path: '*', view: NotFound }
]);

router.start();

document.addEventListener('texivia', (e) => {
  const { view, params, search, hash } = e.detail;
  // render your app — view is whatever you put in the config
});

That's it. No providers, no wrappers, no context.

Svelte 5

Extract the router into its own module so any component or service can import it:

src/router.ts
import { Router } from 'texivia-router';
import type { Component } from 'svelte';
import Home from './pages/Home.svelte';
import RecipeDetail from './pages/RecipeDetail.svelte';
import NotFound from './pages/NotFound.svelte';

export const router = new Router<Component<any>>([
  { path: '/', view: Home },
  { path: '/recipe/{id}', view: RecipeDetail },
  { path: '*', view: NotFound },
]);
App.svelte
<script lang="ts">
  import { onMount } from 'svelte';
  import { router } from './router';

  let View = $state(null);
  let params = $state({});

  function onNavigate(event: CustomEvent) {
    View = event.detail.view;
    params = event.detail?.params || {};
  }

  onMount(() => {
    router.start();
    document.addEventListener('texivia', onNavigate as EventListener);
    return () => {
      router.stop();
      document.removeEventListener('texivia', onNavigate as EventListener);
    };
  });
</script>

{#if View}
  <View {...params} />
{/if}

The view property holds the Svelte component directly — no string-to-component lookup. Router<Component<any>> gives you full type safety across the config.

No <Link> components. Plain <a> tags just work — Texivia intercepts relative links automatically. For programmatic navigation, import the router:

// anywhere — a service, a handler, another component
import { router } from './router';
router.navigate('/dashboard');

Nested layouts

Texivia doesn't need a nested routing concept. Use your framework's composition instead:

pages/RecipeDetail.svelte
<script lang="ts">
  import Layout from '../components/layout/Layout.svelte';
  const { id } = $props();
</script>

<Layout>
  {#snippet body()}
    <h1>Recipe #{id}</h1>
    <!-- page content -->
  {/snippet}
</Layout>

Layouts are components, not router config. This keeps the router simple and your layouts flexible.

Vue 3

src/router.ts
import { Router } from 'texivia-router';
import type { Component } from 'vue';
import Home from './pages/Home.vue';
import RecipeDetail from './pages/RecipeDetail.vue';
import NotFound from './pages/NotFound.vue';

export const router = new Router<Component>([
  { path: '/', view: Home },
  { path: '/recipe/{id}', view: RecipeDetail },
  { path: '*', view: NotFound },
]);
App.vue
<script setup lang="ts">
import { shallowRef, ref, onMounted, onUnmounted } from 'vue';
import { router } from './router';
import Home from './pages/Home.vue';

const View = shallowRef(Home);
const params = ref<Record<string, string>>({});

function onNavigate(event: Event) {
  const detail = (event as CustomEvent).detail;
  View.value = detail.view;
  params.value = detail?.params || {};
}

onMounted(() => {
  router.start();
  document.addEventListener('texivia', onNavigate);
});

onUnmounted(() => {
  router.stop();
  document.removeEventListener('texivia', onNavigate);
});
</script>

<template>
  <component :is="View" v-bind="params" />
</template>

Same pattern — shared router.ts, import where you need router.navigate().

Features

Compiled regex matching — All routes become one regex. One exec() per navigation, regardless of route count.

Framework-agnostic — Works with Svelte, Vue, React, or vanilla JS. No adapters, no plugins. Standard DOM events in, DOM events out.

Type-safe — Generic Router<T> lets you type your view data. Route configs, matched routes, and handler signatures are fully typed.

Dynamic parameters with constraints{id} matches any segment. {id:\d+} matches only digits. {slug:[a-z-]+} matches only lowercase slugs. Full regex power per segment.

Async navigation handlers — Per-route handler functions run before navigation. Return true to proceed, false to cancel, or a string to redirect.

const router = new Router([
  {
    path: '/dashboard',
    view: Dashboard,
    handler: async (match) => {
      if (!await isAuthenticated()) return '/login';
      await preloadData(match.params);
      return true;
    }
  }
]);

Automatic link interception — Clicks on relative <a> tags are captured and routed. External links, target="_blank", download, and no-router attributes are ignored.

<a href="/recipes/42">Recipe</a>        <!-- intercepted -->
<a href="https://example.com">Ext</a>   <!-- ignored: external -->
<a href="/file.pdf" download>PDF</a>    <!-- ignored: download -->
<a href="/raw" no-router>Raw</a>        <!-- ignored: opt-out -->

Declarative redirects{ path: '/old', redirect: '/new' } in the config. No imperative redirect logic needed.

Catch-all 404{ path: '*' } matches anything not matched by other routes. Place it last in your config.

Event-driven — Every navigation dispatches a texivia CustomEvent on document with full route detail: view, params, search, hash.

Programmatic navigation — Call router.navigate() from anywhere:

router.navigate('/recipes/42');
router.navigate('/search?q=pasta#results');

History API — Uses pushState/popstate for clean URLs. No hash routing.

Nesting through composition — No nested route config. Use your framework's own layout/slot/snippet system. The router stays flat, your component tree stays flexible.

API

new Router<T>(config)

Creates a router instance. config is an array of route objects:

type ConfigRoute<T> = {
  path: string;
  view?: T;
  redirect?: string;
  handler?: (match: MatchedRoute<T>) => string | boolean | Promise<string | boolean>;
};
  • path — URL pattern. Literal segments, {param} or {param:regex} for dynamic segments, * for catch-all.
  • view — The view or component to render for this route.
  • redirect — Target path for redirects.
  • handler — Async or sync function called before navigation. Return true to proceed, false to cancel, or a string to redirect.

router.start(): Promise<void>

Starts the router. Attaches popstate, click, and texivia.goto listeners. Navigates to the current URL.

router.stop(): void

Removes all event listeners. Call on cleanup.

router.navigate(path): Promise<MatchedRoute<T> | null>

Navigates to the given path. Runs handlers, pushes history state, and dispatches the texivia event. Returns the matched route or null if no route matches.

await router.navigate('/recipe/42');
await router.navigate('/search?q=pasta#results');

Event: texivia

Dispatched on document after each successful navigation.

type MatchedRoute<T> = {
  path: string;
  view?: T;
  params: Record<string, string>;
  search: Record<string, string>;
  hash: string;
};

document.addEventListener('texivia', (e: CustomEvent<MatchedRoute<T>>) => {
  const { view, params, search, hash } = e.detail;
});

Testing

Tested with Vitest covering route matching, navigation, handlers, redirects, link interception, and edge cases.

npm test

Source

github.com/ferderer/texivia · Apache 2.0