diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0c63f69cf1..a3aedfa9eb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -273,7 +273,6 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド
 	query?: Record<string, string>;
 	loginRequired?: boolean;
 	hash?: string;
-	globalCacheKey?: string;
 	children?: RouteDef[];
 }
 ```
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index 10bcddbde7..a520be9f8a 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -26,8 +26,6 @@ import { deckStore } from '@/ui/deck/deck-store.js';
 import { analytics, initAnalytics } from '@/analytics.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { fetchCustomEmojis } from '@/custom-emojis.js';
-import { setupRouter } from '@/router/main.js';
-import { createMainRouter } from '@/router/definition.js';
 import { prefer } from '@/preferences.js';
 import { $i } from '@/i.js';
 
@@ -267,8 +265,6 @@ export async function common(createVue: () => App<Element>) {
 
 	const app = createVue();
 
-	setupRouter(app, createMainRouter);
-
 	if (_DEV_) {
 		app.config.performance = true;
 	}
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index 46668cb934..62ee0c5d72 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -24,7 +24,7 @@ import { miLocalStorage } from '@/local-storage.js';
 import { claimAchievement, claimedAchievements } from '@/utility/achievements.js';
 import { initializeSw } from '@/utility/initialize-sw.js';
 import { emojiPicker } from '@/utility/emoji-picker.js';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 import { makeHotkey } from '@/utility/hotkey.js';
 import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue
index 6892435a65..d6f65eca5d 100644
--- a/packages/frontend/src/components/MkAbuseReport.vue
+++ b/packages/frontend/src/components/MkAbuseReport.vue
@@ -88,9 +88,9 @@ import { i18n } from '@/i18n.js';
 import { dateString } from '@/filters/date.js';
 import MkFolder from '@/components/MkFolder.vue';
 import RouterView from '@/components/global/RouterView.vue';
-import { useRouterFactory } from '@/router/supplier';
 import MkTextarea from '@/components/MkTextarea.vue';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import { createRouter } from '@/router.js';
 
 const props = defineProps<{
 	report: Misskey.entities.AdminAbuseUserReportsResponse[number];
@@ -100,10 +100,9 @@ const emit = defineEmits<{
 	(ev: 'resolved', reportId: string): void;
 }>();
 
-const routerFactory = useRouterFactory();
-const targetRouter = routerFactory(`/admin/user/${props.report.targetUserId}`);
+const targetRouter = createRouter(`/admin/user/${props.report.targetUserId}`);
 targetRouter.init();
-const reporterRouter = routerFactory(`/admin/user/${props.report.reporterId}`);
+const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`);
 reporterRouter.init();
 
 const moderationNote = ref(props.report.moderationNote ?? '');
diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue
index f02a767186..c54d9eb4d5 100644
--- a/packages/frontend/src/components/MkDrive.file.vue
+++ b/packages/frontend/src/components/MkDrive.file.vue
@@ -47,7 +47,7 @@ import { i18n } from '@/i18n.js';
 import { $i } from '@/i.js';
 import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
 import { deviceKind } from '@/utility/device-kind.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue
index a6049b4d91..fae4246335 100644
--- a/packages/frontend/src/components/MkPageWindow.vue
+++ b/packages/frontend/src/components/MkPageWindow.vue
@@ -41,8 +41,7 @@ import { i18n } from '@/i18n.js';
 import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js';
 import { openingWindowsCount } from '@/os.js';
 import { claimAchievement } from '@/utility/achievements.js';
-import { useRouterFactory } from '@/router/supplier.js';
-import { mainRouter } from '@/router/main.js';
+import { createRouter, mainRouter } from '@/router.js';
 import { analytics } from '@/analytics.js';
 import { DI } from '@/di.js';
 import { prefer } from '@/preferences.js';
@@ -55,8 +54,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const routerFactory = useRouterFactory();
-const windowRouter = routerFactory(props.initialPath);
+const windowRouter = createRouter(props.initialPath);
 
 const pageMetadata = ref<null | PageMetadata>(null);
 const windowEl = shallowRef<InstanceType<typeof MkWindow>>();
diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue
index fddf3934bb..a094718382 100644
--- a/packages/frontend/src/components/MkSuperMenu.vue
+++ b/packages/frontend/src/components/MkSuperMenu.vue
@@ -98,7 +98,7 @@ import type { SearchIndexItem } from '@/utility/autogen/settings-search-index.js
 import MkInput from '@/components/MkInput.vue';
 import { i18n } from '@/i18n.js';
 import { getScrollContainer } from '@@/js/scroll.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 import { initIntlString, compareStringIncludes } from '@/utility/intl-string.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue
index 173f6a849f..3403418991 100644
--- a/packages/frontend/src/components/global/MkA.vue
+++ b/packages/frontend/src/components/global/MkA.vue
@@ -19,7 +19,7 @@ import { url } from '@@/js/config.js';
 import * as os from '@/os.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { i18n } from '@/i18n.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const props = withDefaults(defineProps<{
 	to: string;
diff --git a/packages/frontend/src/lib/nirax.ts b/packages/frontend/src/lib/nirax.ts
new file mode 100644
index 0000000000..cc20d497e6
--- /dev/null
+++ b/packages/frontend/src/lib/nirax.ts
@@ -0,0 +1,340 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// NIRAX --- A lightweight router
+
+import { onMounted, shallowRef } from 'vue';
+import { EventEmitter } from 'eventemitter3';
+import type { Component, ShallowRef } from 'vue';
+
+function safeURIDecode(str: string): string {
+	try {
+		return decodeURIComponent(str);
+	} catch {
+		return str;
+	}
+}
+
+interface RouteDefBase {
+	path: string;
+	query?: Record<string, string>;
+	loginRequired?: boolean;
+	name?: string;
+	hash?: string;
+	children?: RouteDef[];
+}
+
+interface RouteDefWithComponent extends RouteDefBase {
+	component: Component,
+}
+
+interface RouteDefWithRedirect extends RouteDefBase {
+	redirect: string | ((props: Map<string, string | boolean>) => string);
+}
+
+export type RouteDef = RouteDefWithComponent | RouteDefWithRedirect;
+
+export type RouterFlag = 'forcePage';
+
+type ParsedPath = (string | {
+	name: string;
+	startsWith?: string;
+	wildcard?: boolean;
+	optional?: boolean;
+})[];
+
+export type RouterEvent = {
+	change: (ctx: {
+		beforePath: string;
+		path: string;
+		resolved: Resolved;
+	}) => void;
+	replace: (ctx: {
+		path: string;
+	}) => void;
+	push: (ctx: {
+		beforePath: string;
+		path: string;
+		route: RouteDef | null;
+		props: Map<string, string> | null;
+	}) => void;
+	same: () => void;
+};
+
+export type Resolved = {
+	route: RouteDef;
+	props: Map<string, string | boolean>;
+	child?: Resolved;
+	redirected?: boolean;
+
+	/** @internal */
+	_parsedRoute: {
+		fullPath: string;
+		queryString: string | null;
+		hash: string | null;
+	};
+};
+
+function parsePath(path: string): ParsedPath {
+	const res = [] as ParsedPath;
+
+	path = path.substring(1);
+
+	for (const part of path.split('/')) {
+		if (part.includes(':')) {
+			const prefix = part.substring(0, part.indexOf(':'));
+			const placeholder = part.substring(part.indexOf(':') + 1);
+			const wildcard = placeholder.includes('(*)');
+			const optional = placeholder.endsWith('?');
+			res.push({
+				name: placeholder.replace('(*)', '').replace('?', ''),
+				startsWith: prefix !== '' ? prefix : undefined,
+				wildcard,
+				optional,
+			});
+		} else if (part.length !== 0) {
+			res.push(part);
+		}
+	}
+
+	return res;
+}
+
+export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvent> {
+	private routes: DEF;
+	public current: Resolved;
+	public currentRef: ShallowRef<Resolved>;
+	public currentRoute: ShallowRef<RouteDef>;
+	private currentPath: string;
+	private isLoggedIn: boolean;
+	private notFoundPageComponent: Component;
+	private redirectCount = 0;
+
+	public navHook: ((path: string, flag?: RouterFlag) => boolean) | null = null;
+
+	constructor(routes: DEF, currentPath: Nirax<DEF>['currentPath'], isLoggedIn: boolean, notFoundPageComponent: Component) {
+		super();
+
+		this.routes = routes;
+		this.current = this.resolve(currentPath)!;
+		this.currentRef = shallowRef(this.current);
+		this.currentRoute = shallowRef(this.current.route);
+		this.currentPath = currentPath;
+		this.isLoggedIn = isLoggedIn;
+		this.notFoundPageComponent = notFoundPageComponent;
+	}
+
+	public init() {
+		const res = this.navigate(this.currentPath, false);
+		this.emit('replace', {
+			path: res._parsedRoute.fullPath,
+		});
+	}
+
+	public resolve(path: string): Resolved | null {
+		const fullPath = path;
+		let queryString: string | null = null;
+		let hash: string | null = null;
+		if (path[0] === '/') path = path.substring(1);
+		if (path.includes('#')) {
+			hash = path.substring(path.indexOf('#') + 1);
+			path = path.substring(0, path.indexOf('#'));
+		}
+		if (path.includes('?')) {
+			queryString = path.substring(path.indexOf('?') + 1);
+			path = path.substring(0, path.indexOf('?'));
+		}
+
+		const _parsedRoute = {
+			fullPath,
+			queryString,
+			hash,
+		};
+
+		function check(routes: RouteDef[], _parts: string[]): Resolved | null {
+			forEachRouteLoop:
+			for (const route of routes) {
+				let parts = [..._parts];
+				const props = new Map<string, string>();
+
+				pathMatchLoop:
+				for (const p of parsePath(route.path)) {
+					if (typeof p === 'string') {
+						if (p === parts[0]) {
+							parts.shift();
+						} else {
+							continue forEachRouteLoop;
+						}
+					} else {
+						if (parts[0] == null && !p.optional) {
+							continue forEachRouteLoop;
+						}
+						if (p.wildcard) {
+							if (parts.length !== 0) {
+								props.set(p.name, safeURIDecode(parts.join('/')));
+								parts = [];
+							}
+							break pathMatchLoop;
+						} else {
+							if (p.startsWith) {
+								if (parts[0] == null || !parts[0].startsWith(p.startsWith)) continue forEachRouteLoop;
+
+								props.set(p.name, safeURIDecode(parts[0].substring(p.startsWith.length)));
+								parts.shift();
+							} else {
+								if (parts[0]) {
+									props.set(p.name, safeURIDecode(parts[0]));
+								}
+								parts.shift();
+							}
+						}
+					}
+				}
+
+				if (parts.length === 0) {
+					if (route.children) {
+						const child = check(route.children, []);
+						if (child) {
+							return {
+								route,
+								props,
+								child,
+								_parsedRoute,
+							};
+						} else {
+							continue forEachRouteLoop;
+						}
+					}
+
+					if (route.hash != null && hash != null) {
+						props.set(route.hash, safeURIDecode(hash));
+					}
+
+					if (route.query != null && queryString != null) {
+						const queryObject = [...new URLSearchParams(queryString).entries()]
+							.reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {});
+
+						for (const q in route.query) {
+							const as = route.query[q];
+							if (queryObject[q]) {
+								props.set(as, safeURIDecode(queryObject[q]));
+							}
+						}
+					}
+
+					return {
+						route,
+						props,
+						_parsedRoute,
+					};
+				} else {
+					if (route.children) {
+						const child = check(route.children, parts);
+						if (child) {
+							return {
+								route,
+								props,
+								child,
+								_parsedRoute,
+							};
+						} else {
+							continue forEachRouteLoop;
+						}
+					} else {
+						continue forEachRouteLoop;
+					}
+				}
+			}
+
+			return null;
+		}
+
+		const _parts = path.split('/').filter(part => part.length !== 0);
+
+		return check(this.routes, _parts);
+	}
+
+	private navigate(path: string, emitChange = true, _redirected = false): Resolved {
+		const beforePath = this.currentPath;
+		this.currentPath = path;
+
+		const res = this.resolve(this.currentPath);
+
+		if (res == null) {
+			throw new Error('no route found for: ' + path);
+		}
+
+		if ('redirect' in res.route) {
+			let redirectPath: string;
+			if (typeof res.route.redirect === 'function') {
+				redirectPath = res.route.redirect(res.props);
+			} else {
+				redirectPath = res.route.redirect + (res._parsedRoute.queryString ? '?' + res._parsedRoute.queryString : '') + (res._parsedRoute.hash ? '#' + res._parsedRoute.hash : '');
+			}
+			if (_DEV_) console.log('Redirecting to: ', redirectPath);
+			if (_redirected && this.redirectCount++ > 10) {
+				throw new Error('redirect loop detected');
+			}
+			return this.navigate(redirectPath, emitChange, true);
+		}
+
+		if (res.route.loginRequired && !this.isLoggedIn) {
+			res.route.component = this.notFoundPageComponent;
+			res.props.set('showLoginPopup', true);
+		}
+
+		this.current = res;
+		this.currentRef.value = res;
+		this.currentRoute.value = res.route;
+
+		if (emitChange && res.route.path !== '/:(*)') {
+			this.emit('change', {
+				beforePath,
+				path,
+				resolved: res,
+			});
+		}
+
+		this.redirectCount = 0;
+		return {
+			...res,
+			redirected: _redirected,
+		};
+	}
+
+	public getCurrentPath() {
+		return this.currentPath;
+	}
+
+	public push(path: string, flag?: RouterFlag) {
+		const beforePath = this.currentPath;
+		if (path === beforePath) {
+			this.emit('same');
+			return;
+		}
+		if (this.navHook) {
+			const cancel = this.navHook(path, flag);
+			if (cancel) return;
+		}
+		const res = this.navigate(path);
+		if (res.route.path === '/:(*)') {
+			location.href = path;
+		} else {
+			this.emit('push', {
+				beforePath,
+				path: res._parsedRoute.fullPath,
+				route: res.route,
+				props: res.props,
+			});
+		}
+	}
+
+	public replace(path: string) {
+		const res = this.navigate(path);
+		this.emit('replace', {
+			path: res._parsedRoute.fullPath,
+		});
+	}
+}
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
index 7e7467859b..cdf3b4230c 100644
--- a/packages/frontend/src/pages/admin/index.vue
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -43,7 +43,7 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { lookupUser, lookupUserByEmail, lookupFile } from '@/utility/admin-lookup.js';
 import { definePage, provideMetadataReceiver, provideReactiveMetadata } from '@/page.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const isEmpty = (x: string | null) => x == null || x === '';
 
diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue
index 129fabf489..7741064685 100644
--- a/packages/frontend/src/pages/admin/roles.edit.vue
+++ b/packages/frontend/src/pages/admin/roles.edit.vue
@@ -33,7 +33,7 @@ import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import MkButton from '@/components/MkButton.vue';
 import { rolesCache } from '@/cache.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue
index e7ebd30a3b..631873a076 100644
--- a/packages/frontend/src/pages/admin/roles.role.vue
+++ b/packages/frontend/src/pages/admin/roles.role.vue
@@ -75,7 +75,7 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import { infoImageUrl } from '@/instance.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index e16fca8286..0428352350 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -295,7 +295,7 @@ import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { instance, fetchInstance } from '@/instance.js';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 const baseRoleQ = ref('');
diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue
index 542fa72126..f0587a5ca0 100644
--- a/packages/frontend/src/pages/antenna-timeline.vue
+++ b/packages/frontend/src/pages/antenna-timeline.vue
@@ -32,7 +32,7 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue
index 80cefe12c3..d0656a163c 100644
--- a/packages/frontend/src/pages/channel-editor.vue
+++ b/packages/frontend/src/pages/channel-editor.vue
@@ -82,7 +82,7 @@ import { i18n } from '@/i18n.js';
 import MkFolder from '@/components/MkFolder.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
 
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index 1245561169..1419e83df7 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -99,7 +99,7 @@ import { isSupportShare } from '@/utility/navigator.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { notesSearchAvailable } from '@/utility/check-permissions.js';
 import { miLocalStorage } from '@/local-storage.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue
index 071f5a048b..cf047fcd5d 100644
--- a/packages/frontend/src/pages/channels.vue
+++ b/packages/frontend/src/pages/channels.vue
@@ -71,7 +71,7 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue
index c2f57cb665..5390a48be5 100644
--- a/packages/frontend/src/pages/drive.file.info.vue
+++ b/packages/frontend/src/pages/drive.file.info.vue
@@ -86,7 +86,7 @@ import { infoImageUrl } from '@/instance.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue
index b04974b7dc..16eeba7eea 100644
--- a/packages/frontend/src/pages/flash/flash-edit.vue
+++ b/packages/frontend/src/pages/flash/flash-edit.vue
@@ -53,7 +53,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
 import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkSelect from '@/components/MkSelect.vue';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const PRESET_DEFAULT = `/// @ ${AISCRIPT_VERSION}
 
diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue
index 3cd7c46c1e..6aee91dfda 100644
--- a/packages/frontend/src/pages/flash/flash-index.vue
+++ b/packages/frontend/src/pages/flash/flash-index.vue
@@ -47,7 +47,7 @@ import MkButton from '@/components/MkButton.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue
index 9cd59d0aa5..c85823ba86 100644
--- a/packages/frontend/src/pages/gallery/edit.vue
+++ b/packages/frontend/src/pages/gallery/edit.vue
@@ -50,7 +50,7 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue
index 14b3f7bf3c..04445c913c 100644
--- a/packages/frontend/src/pages/gallery/index.vue
+++ b/packages/frontend/src/pages/gallery/index.vue
@@ -54,7 +54,7 @@ import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue
index 6a9737e30f..9d37daf1ed 100644
--- a/packages/frontend/src/pages/gallery/post.vue
+++ b/packages/frontend/src/pages/gallery/post.vue
@@ -80,7 +80,7 @@ import { prefer } from '@/preferences.js';
 import { $i } from '@/i.js';
 import { isSupportShare } from '@/utility/navigator.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/lookup.vue b/packages/frontend/src/pages/lookup.vue
index e92ee0a4cc..fafad8af4a 100644
--- a/packages/frontend/src/pages/lookup.vue
+++ b/packages/frontend/src/pages/lookup.vue
@@ -25,7 +25,7 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 import MkButton from '@/components/MkButton.vue';
 
 const state = ref<'fetching' | 'done'>('fetching');
diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue
index 42d8b7be4c..e7460f0f93 100644
--- a/packages/frontend/src/pages/my-antennas/create.vue
+++ b/packages/frontend/src/pages/my-antennas/create.vue
@@ -16,7 +16,7 @@ import { computed } from 'vue';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { antennasCache } from '@/cache.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 import MkAntennaEditor from '@/components/MkAntennaEditor.vue';
 
 const router = useRouter();
diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue
index acd368b5e2..83d1183ddd 100644
--- a/packages/frontend/src/pages/my-antennas/edit.vue
+++ b/packages/frontend/src/pages/my-antennas/edit.vue
@@ -19,7 +19,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { antennasCache } from '@/cache.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue
index 782c069839..08ac3b4625 100644
--- a/packages/frontend/src/pages/my-lists/list.vue
+++ b/packages/frontend/src/pages/my-lists/list.vue
@@ -68,7 +68,7 @@ import MkInput from '@/components/MkInput.vue';
 import { userListsCache } from '@/cache.js';
 import { ensureSignin } from '@/i.js';
 import MkPagination from '@/components/MkPagination.vue';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 import { prefer } from '@/preferences.js';
 
 const $i = ensureSignin();
diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue
index e2f6084252..95a2d6d616 100644
--- a/packages/frontend/src/pages/page-editor/page-editor.vue
+++ b/packages/frontend/src/pages/page-editor/page-editor.vue
@@ -76,7 +76,7 @@ import { selectFile } from '@/utility/select-file.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { $i } from '@/i.js';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 import { getPageBlockList } from '@/pages/page-editor/common.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index 523d443359..cad5f2e109 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -120,7 +120,7 @@ import { isSupportShare } from '@/utility/navigator.js';
 import { instance } from '@/instance.js';
 import { getStaticImageUrl } from '@/utility/media-proxy.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 import { prefer } from '@/preferences.js';
 import { getPluginHandlers } from '@/plugin.js';
 
diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue
index f9bb825bd0..e41a7da055 100644
--- a/packages/frontend/src/pages/pages.vue
+++ b/packages/frontend/src/pages/pages.vue
@@ -45,7 +45,7 @@ import MkButton from '@/components/MkButton.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue
index d84c8f33dd..fda365fe52 100644
--- a/packages/frontend/src/pages/reset-password.vue
+++ b/packages/frontend/src/pages/reset-password.vue
@@ -26,7 +26,7 @@ import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 
 const props = defineProps<{
 	token?: string;
diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue
index 75d89be6b0..d2720a79fc 100644
--- a/packages/frontend/src/pages/reversi/game.setting.vue
+++ b/packages/frontend/src/pages/reversi/game.setting.vue
@@ -122,7 +122,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import * as os from '@/os.js';
 import type { MenuItem } from '@/types/menu.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const $i = ensureSignin();
 
diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue
index fb2019ae3c..a447572cc0 100644
--- a/packages/frontend/src/pages/reversi/game.vue
+++ b/packages/frontend/src/pages/reversi/game.vue
@@ -18,7 +18,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import { definePage } from '@/page.js';
 import { useStream } from '@/stream.js';
 import { ensureSignin } from '@/i.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 import * as os from '@/os.js';
 import { url } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue
index d66ff8db05..e3f01d9938 100644
--- a/packages/frontend/src/pages/reversi/index.vue
+++ b/packages/frontend/src/pages/reversi/index.vue
@@ -115,7 +115,7 @@ import MkFolder from '@/components/MkFolder.vue';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/i.js';
 import MkPagination from '@/components/MkPagination.vue';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 import * as os from '@/os.js';
 import { useInterval } from '@@/js/use-interval.js';
 import { pleaseLogin } from '@/utility/please-login.js';
diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue
index 4801e9bc27..1dc55d002c 100644
--- a/packages/frontend/src/pages/search.note.vue
+++ b/packages/frontend/src/pages/search.note.vue
@@ -121,7 +121,7 @@ import { instance } from '@/instance.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { apLookup } from '@/utility/lookup.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 import MkButton from '@/components/MkButton.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkInput from '@/components/MkInput.vue';
diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue
index 40019cc870..101de6a64f 100644
--- a/packages/frontend/src/pages/search.user.vue
+++ b/packages/frontend/src/pages/search.user.vue
@@ -37,7 +37,7 @@ import { instance } from '@/instance.js';
 import * as os from '@/os.js';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const props = withDefaults(defineProps<{
   query?: string,
diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue
index f6feaee453..89dc9581c2 100644
--- a/packages/frontend/src/pages/settings/index.vue
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -42,7 +42,7 @@ import { clearCache } from '@/utility/clear-cache.js';
 import { instance } from '@/instance.js';
 import { definePage, provideMetadataReceiver, provideReactiveMetadata } from '@/page.js';
 import * as os from '@/os.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 import { searchIndexes } from '@/utility/autogen/settings-search-index.js';
 import { enableAutoBackup, getPreferencesProfileMenu } from '@/preferences/utility.js';
 import { store } from '@/store.js';
diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue
index e984ed7f8a..22b53b4b96 100644
--- a/packages/frontend/src/pages/settings/plugin.install.vue
+++ b/packages/frontend/src/pages/settings/plugin.install.vue
@@ -26,7 +26,7 @@ import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { installPlugin } from '@/plugin.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 const code = ref<string | null>(null);
diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue
index 68e4bef5c4..ac95279402 100644
--- a/packages/frontend/src/pages/settings/theme.install.vue
+++ b/packages/frontend/src/pages/settings/theme.install.vue
@@ -24,7 +24,7 @@ import { parseThemeCode, previewTheme, installTheme } from '@/theme.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 const installThemeCode = ref<string | null>(null);
diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue
index 2de948c69d..6a6cec70ba 100644
--- a/packages/frontend/src/pages/settings/webhook.edit.vue
+++ b/packages/frontend/src/pages/settings/webhook.edit.vue
@@ -79,7 +79,7 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue
index d4c7c9386d..e4857c7d30 100644
--- a/packages/frontend/src/pages/user-list-timeline.vue
+++ b/packages/frontend/src/pages/user-list-timeline.vue
@@ -31,7 +31,7 @@ import { scroll } from '@@/js/scroll.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 149481f99b..dfa43e1ef2 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -181,7 +181,7 @@ import { dateString } from '@/filters/date.js';
 import { confetti } from '@/utility/confetti.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 import { getStaticImageUrl } from '@/utility/media-proxy.js';
 import MkSparkle from '@/components/MkSparkle.vue';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router.definition.ts
similarity index 98%
rename from packages/frontend/src/router/definition.ts
rename to packages/frontend/src/router.definition.ts
index 923903ec3d..3b60ee68e3 100644
--- a/packages/frontend/src/router/definition.ts
+++ b/packages/frontend/src/router.definition.ts
@@ -5,8 +5,7 @@
 
 import { defineAsyncComponent } from 'vue';
 import type { AsyncComponentLoader } from 'vue';
-import type { RouteDef } from '@/router.js';
-import { Router } from '@/router.js';
+import type { RouteDef } from '@/lib/nirax.js';
 import { $i, iAmModerator } from '@/i.js';
 import MkLoading from '@/pages/_loading_.vue';
 import MkError from '@/pages/_error_.vue';
@@ -17,7 +16,7 @@ export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({
 	errorComponent: MkError,
 });
 
-const routes: RouteDef[] = [{
+export const ROUTE_DEF = [{
 	path: '/@:username/pages/:pageName(*)',
 	component: page(() => import('@/pages/page.vue')),
 }, {
@@ -567,7 +566,6 @@ const routes: RouteDef[] = [{
 	name: 'index',
 	path: '/',
 	component: $i ? page(() => import('@/pages/timeline.vue')) : page(() => import('@/pages/welcome.vue')),
-	globalCacheKey: 'index',
 }, {
 	// テスト用リダイレクト設定。ログイン中ユーザのプロフィールにリダイレクトする
 	path: '/redirect-test',
@@ -576,8 +574,4 @@ const routes: RouteDef[] = [{
 }, {
 	path: '/:(*)',
 	component: page(() => import('@/pages/not-found.vue')),
-}];
-
-export function createMainRouter(path: string): Router {
-	return new Router(routes, path, !!$i, page(() => import('@/pages/not-found.vue')));
-}
+}] satisfies RouteDef[];
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index c6f3937cde..b5f59b30c1 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -3,339 +3,44 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-// NIRAX --- A lightweight router
+import { inject } from 'vue';
+import { page } from '@/router.definition.js';
+import { $i } from '@/i.js';
+import { Nirax } from '@/lib/nirax.js';
+import { ROUTE_DEF } from '@/router.definition.js';
+import { analytics } from '@/analytics.js';
+import { DI } from '@/di.js';
 
-import { onMounted, shallowRef } from 'vue';
-import { EventEmitter } from 'eventemitter3';
-import type { Component, ShallowRef } from 'vue';
+export type Router = Nirax<typeof ROUTE_DEF>;
 
-function safeURIDecode(str: string): string {
-	try {
-		return decodeURIComponent(str);
-	} catch {
-		return str;
-	}
+export function createRouter(path: string): Router {
+	return new Nirax(ROUTE_DEF, path, !!$i, page(() => import('@/pages/not-found.vue')));
 }
 
-interface RouteDefBase {
-	path: string;
-	query?: Record<string, string>;
-	loginRequired?: boolean;
-	name?: string;
-	hash?: string;
-	globalCacheKey?: string;
-	children?: RouteDef[];
-}
-
-interface RouteDefWithComponent extends RouteDefBase {
-	component: Component,
-}
-
-interface RouteDefWithRedirect extends RouteDefBase {
-	redirect: string | ((props: Map<string, string | boolean>) => string);
-}
-
-export type RouteDef = RouteDefWithComponent | RouteDefWithRedirect;
-
-export type RouterFlag = 'forcePage';
-
-type ParsedPath = (string | {
-	name: string;
-	startsWith?: string;
-	wildcard?: boolean;
-	optional?: boolean;
-})[];
-
-export type RouterEvent = {
-	change: (ctx: {
-		beforePath: string;
-		path: string;
-		resolved: Resolved;
-	}) => void;
-	replace: (ctx: {
-		path: string;
-	}) => void;
-	push: (ctx: {
-		beforePath: string;
-		path: string;
-		route: RouteDef | null;
-		props: Map<string, string> | null;
-	}) => void;
-	same: () => void;
-};
-
-export type Resolved = {
-	route: RouteDef;
-	props: Map<string, string | boolean>;
-	child?: Resolved;
-	redirected?: boolean;
-
-	/** @internal */
-	_parsedRoute: {
-		fullPath: string;
-		queryString: string | null;
-		hash: string | null;
-	};
-};
-
-function parsePath(path: string): ParsedPath {
-	const res = [] as ParsedPath;
-
-	path = path.substring(1);
-
-	for (const part of path.split('/')) {
-		if (part.includes(':')) {
-			const prefix = part.substring(0, part.indexOf(':'));
-			const placeholder = part.substring(part.indexOf(':') + 1);
-			const wildcard = placeholder.includes('(*)');
-			const optional = placeholder.endsWith('?');
-			res.push({
-				name: placeholder.replace('(*)', '').replace('?', ''),
-				startsWith: prefix !== '' ? prefix : undefined,
-				wildcard,
-				optional,
-			});
-		} else if (part.length !== 0) {
-			res.push(part);
-		}
-	}
-
-	return res;
-}
-
-export class Router extends EventEmitter<RouterEvent> {
-	private routes: RouteDef[];
-	public current: Resolved;
-	public currentRef: ShallowRef<Resolved>;
-	public currentRoute: ShallowRef<RouteDef>;
-	private currentPath: string;
-	private isLoggedIn: boolean;
-	private notFoundPageComponent: Component;
-	private redirectCount = 0;
-
-	public navHook: ((path: string, flag?: RouterFlag) => boolean) | null = null;
-
-	constructor(routes: Router['routes'], currentPath: Router['currentPath'], isLoggedIn: boolean, notFoundPageComponent: Component) {
-		super();
-
-		this.routes = routes;
-		this.current = this.resolve(currentPath)!;
-		this.currentRef = shallowRef(this.current);
-		this.currentRoute = shallowRef(this.current.route);
-		this.currentPath = currentPath;
-		this.isLoggedIn = isLoggedIn;
-		this.notFoundPageComponent = notFoundPageComponent;
-	}
-
-	public init() {
-		const res = this.navigate(this.currentPath, false);
-		this.emit('replace', {
-			path: res._parsedRoute.fullPath,
-		});
-	}
-
-	public resolve(path: string): Resolved | null {
-		const fullPath = path;
-		let queryString: string | null = null;
-		let hash: string | null = null;
-		if (path[0] === '/') path = path.substring(1);
-		if (path.includes('#')) {
-			hash = path.substring(path.indexOf('#') + 1);
-			path = path.substring(0, path.indexOf('#'));
-		}
-		if (path.includes('?')) {
-			queryString = path.substring(path.indexOf('?') + 1);
-			path = path.substring(0, path.indexOf('?'));
-		}
-
-		const _parsedRoute = {
-			fullPath,
-			queryString,
-			hash,
-		};
-
-		function check(routes: RouteDef[], _parts: string[]): Resolved | null {
-			forEachRouteLoop:
-			for (const route of routes) {
-				let parts = [..._parts];
-				const props = new Map<string, string>();
-
-				pathMatchLoop:
-				for (const p of parsePath(route.path)) {
-					if (typeof p === 'string') {
-						if (p === parts[0]) {
-							parts.shift();
-						} else {
-							continue forEachRouteLoop;
-						}
-					} else {
-						if (parts[0] == null && !p.optional) {
-							continue forEachRouteLoop;
-						}
-						if (p.wildcard) {
-							if (parts.length !== 0) {
-								props.set(p.name, safeURIDecode(parts.join('/')));
-								parts = [];
-							}
-							break pathMatchLoop;
-						} else {
-							if (p.startsWith) {
-								if (parts[0] == null || !parts[0].startsWith(p.startsWith)) continue forEachRouteLoop;
-
-								props.set(p.name, safeURIDecode(parts[0].substring(p.startsWith.length)));
-								parts.shift();
-							} else {
-								if (parts[0]) {
-									props.set(p.name, safeURIDecode(parts[0]));
-								}
-								parts.shift();
-							}
-						}
-					}
-				}
-
-				if (parts.length === 0) {
-					if (route.children) {
-						const child = check(route.children, []);
-						if (child) {
-							return {
-								route,
-								props,
-								child,
-								_parsedRoute,
-							};
-						} else {
-							continue forEachRouteLoop;
-						}
-					}
-
-					if (route.hash != null && hash != null) {
-						props.set(route.hash, safeURIDecode(hash));
-					}
-
-					if (route.query != null && queryString != null) {
-						const queryObject = [...new URLSearchParams(queryString).entries()]
-							.reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {});
-
-						for (const q in route.query) {
-							const as = route.query[q];
-							if (queryObject[q]) {
-								props.set(as, safeURIDecode(queryObject[q]));
-							}
-						}
-					}
-
-					return {
-						route,
-						props,
-						_parsedRoute,
-					};
-				} else {
-					if (route.children) {
-						const child = check(route.children, parts);
-						if (child) {
-							return {
-								route,
-								props,
-								child,
-								_parsedRoute,
-							};
-						} else {
-							continue forEachRouteLoop;
-						}
-					} else {
-						continue forEachRouteLoop;
-					}
-				}
-			}
-
-			return null;
-		}
-
-		const _parts = path.split('/').filter(part => part.length !== 0);
-
-		return check(this.routes, _parts);
-	}
-
-	private navigate(path: string, emitChange = true, _redirected = false): Resolved {
-		const beforePath = this.currentPath;
-		this.currentPath = path;
-
-		const res = this.resolve(this.currentPath);
-
-		if (res == null) {
-			throw new Error('no route found for: ' + path);
-		}
-
-		if ('redirect' in res.route) {
-			let redirectPath: string;
-			if (typeof res.route.redirect === 'function') {
-				redirectPath = res.route.redirect(res.props);
-			} else {
-				redirectPath = res.route.redirect + (res._parsedRoute.queryString ? '?' + res._parsedRoute.queryString : '') + (res._parsedRoute.hash ? '#' + res._parsedRoute.hash : '');
-			}
-			if (_DEV_) console.log('Redirecting to: ', redirectPath);
-			if (_redirected && this.redirectCount++ > 10) {
-				throw new Error('redirect loop detected');
-			}
-			return this.navigate(redirectPath, emitChange, true);
-		}
-
-		if (res.route.loginRequired && !this.isLoggedIn) {
-			res.route.component = this.notFoundPageComponent;
-			res.props.set('showLoginPopup', true);
-		}
-
-		this.current = res;
-		this.currentRef.value = res;
-		this.currentRoute.value = res.route;
-
-		if (emitChange && res.route.path !== '/:(*)') {
-			this.emit('change', {
-				beforePath,
-				path,
-				resolved: res,
-			});
-		}
-
-		this.redirectCount = 0;
-		return {
-			...res,
-			redirected: _redirected,
-		};
-	}
-
-	public getCurrentPath() {
-		return this.currentPath;
-	}
-
-	public push(path: string, flag?: RouterFlag) {
-		const beforePath = this.currentPath;
-		if (path === beforePath) {
-			this.emit('same');
-			return;
-		}
-		if (this.navHook) {
-			const cancel = this.navHook(path, flag);
-			if (cancel) return;
-		}
-		const res = this.navigate(path);
-		if (res.route.path === '/:(*)') {
-			location.href = path;
-		} else {
-			this.emit('push', {
-				beforePath,
-				path: res._parsedRoute.fullPath,
-				route: res.route,
-				props: res.props,
-			});
-		}
-	}
-
-	public replace(path: string) {
-		const res = this.navigate(path);
-		this.emit('replace', {
-			path: res._parsedRoute.fullPath,
-		});
-	}
+export const mainRouter = createRouter(location.pathname + location.search + location.hash);
+
+window.addEventListener('popstate', (event) => {
+	mainRouter.replace(location.pathname + location.search + location.hash);
+});
+
+mainRouter.addListener('push', ctx => {
+	window.history.pushState({ }, '', ctx.path);
+});
+
+mainRouter.addListener('replace', ctx => {
+	window.history.replaceState({ }, '', ctx.path);
+});
+
+mainRouter.addListener('change', ctx => {
+	console.log('mainRouter: change', ctx.path);
+	analytics.page({
+		path: ctx.path,
+		title: ctx.path,
+	});
+});
+
+mainRouter.init();
+
+export function useRouter(): Router {
+	return inject(DI.router, null) ?? mainRouter;
 }
diff --git a/packages/frontend/src/router/main.ts b/packages/frontend/src/router/main.ts
deleted file mode 100644
index f294af059d..0000000000
--- a/packages/frontend/src/router/main.ts
+++ /dev/null
@@ -1,199 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { EventEmitter } from 'eventemitter3';
-import type { Router, Resolved, RouteDef, RouterEvent, RouterFlag } from '@/router.js';
-
-import type { App, ShallowRef } from 'vue';
-import { analytics } from '@/analytics.js';
-
-/**
- * {@link Router}による画面遷移を可能とするために{@link mainRouter}をセットアップする。
- * また、{@link Router}のインスタンスを作成するためのファクトリも{@link provide}経由で公開する(`routerFactory`というキーで取得可能)
- */
-export function setupRouter(app: App, routerFactory: ((path: string) => Router)): void {
-	app.provide('routerFactory', routerFactory);
-
-	const mainRouter = routerFactory(location.pathname + location.search + location.hash);
-
-	window.addEventListener('popstate', (event) => {
-		mainRouter.replace(location.pathname + location.search + location.hash);
-	});
-
-	mainRouter.addListener('push', ctx => {
-		window.history.pushState({ }, '', ctx.path);
-	});
-
-	mainRouter.addListener('replace', ctx => {
-		window.history.replaceState({ }, '', ctx.path);
-	});
-
-	mainRouter.addListener('change', ctx => {
-		console.log('mainRouter: change', ctx.path);
-		analytics.page({
-			path: ctx.path,
-			title: ctx.path,
-		});
-	});
-
-	mainRouter.init();
-
-	setMainRouter(mainRouter);
-}
-
-function getMainRouter(): Router {
-	const router = mainRouterHolder;
-	if (!router) {
-		throw new Error('mainRouter is not found.');
-	}
-
-	return router;
-}
-
-/**
- * メインルータを設定する。一度設定すると、それ以降は変更できない。
- * {@link setupRouter}から呼び出されることのみを想定している。
- */
-export function setMainRouter(router: Router) {
-	if (mainRouterHolder) {
-		throw new Error('mainRouter is already exists.');
-	}
-
-	mainRouterHolder = router;
-}
-
-/**
- * {@link mainRouter}用のプロキシ実装。
- * {@link mainRouter}は起動シーケンスの一部にて初期化されるため、僅かにundefinedになる期間がある。
- * その僅かな期間のためだけに型をundefined込みにしたくないのでこのクラスを緩衝材として使用する。
- */
-class MainRouterProxy implements Router {
-	private supplier: () => Router;
-
-	constructor(supplier: () => Router) {
-		this.supplier = supplier;
-	}
-
-	get current(): Resolved {
-		return this.supplier().current;
-	}
-
-	get currentRef(): ShallowRef<Resolved> {
-		return this.supplier().currentRef;
-	}
-
-	get currentRoute(): ShallowRef<RouteDef> {
-		return this.supplier().currentRoute;
-	}
-
-	get navHook(): ((path: string, flag?: RouterFlag) => boolean) | null {
-		return this.supplier().navHook;
-	}
-
-	set navHook(value) {
-		this.supplier().navHook = value;
-	}
-
-	getCurrentPath(): string {
-		return this.supplier().getCurrentPath();
-	}
-
-	push(path: string, flag?: RouterFlag): void {
-		this.supplier().push(path, flag);
-	}
-
-	replace(path: string, key?: string | null): void {
-		this.supplier().replace(path, key);
-	}
-
-	resolve(path: string): Resolved | null {
-		return this.supplier().resolve(path);
-	}
-
-	init(): void {
-		this.supplier().init();
-	}
-
-	eventNames(): Array<EventEmitter.EventNames<RouterEvent>> {
-		return this.supplier().eventNames();
-	}
-
-	listeners<T extends EventEmitter.EventNames<RouterEvent>>(
-		event: T,
-	): Array<EventEmitter.EventListener<RouterEvent, T>> {
-		return this.supplier().listeners(event);
-	}
-
-	listenerCount(
-		event: EventEmitter.EventNames<RouterEvent>,
-	): number {
-		return this.supplier().listenerCount(event);
-	}
-
-	emit<T extends EventEmitter.EventNames<RouterEvent>>(
-		event: T,
-		...args: EventEmitter.EventArgs<RouterEvent, T>
-	): boolean {
-		return this.supplier().emit(event, ...args);
-	}
-
-	on<T extends EventEmitter.EventNames<RouterEvent>>(
-		event: T,
-		fn: EventEmitter.EventListener<RouterEvent, T>,
-		context?: any,
-	): this {
-		this.supplier().on(event, fn, context);
-		return this;
-	}
-
-	addListener<T extends EventEmitter.EventNames<RouterEvent>>(
-		event: T,
-		fn: EventEmitter.EventListener<RouterEvent, T>,
-		context?: any,
-	): this {
-		this.supplier().addListener(event, fn, context);
-		return this;
-	}
-
-	once<T extends EventEmitter.EventNames<RouterEvent>>(
-		event: T,
-		fn: EventEmitter.EventListener<RouterEvent, T>,
-		context?: any,
-	): this {
-		this.supplier().once(event, fn, context);
-		return this;
-	}
-
-	removeListener<T extends EventEmitter.EventNames<RouterEvent>>(
-		event: T,
-		fn?: EventEmitter.EventListener<RouterEvent, T>,
-		context?: any,
-		once?: boolean,
-	): this {
-		this.supplier().removeListener(event, fn, context, once);
-		return this;
-	}
-
-	off<T extends EventEmitter.EventNames<RouterEvent>>(
-		event: T,
-		fn?: EventEmitter.EventListener<RouterEvent, T>,
-		context?: any,
-		once?: boolean,
-	): this {
-		this.supplier().off(event, fn, context, once);
-		return this;
-	}
-
-	removeAllListeners(
-		event?: EventEmitter.EventNames<RouterEvent>,
-	): this {
-		this.supplier().removeAllListeners(event);
-		return this;
-	}
-}
-
-let mainRouterHolder: Router | null = null;
-
-export const mainRouter: Router = new MainRouterProxy(getMainRouter);
diff --git a/packages/frontend/src/router/supplier.ts b/packages/frontend/src/router/supplier.ts
deleted file mode 100644
index 3c05b41c20..0000000000
--- a/packages/frontend/src/router/supplier.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { inject } from 'vue';
-import type { Router } from '@/router.js';
-import { mainRouter } from '@/router/main.js';
-import { DI } from '@/di.js';
-
-/**
- * メインの{@link Router}を取得する。
- * あらかじめ{@link setupRouter}を実行しておく必要がある({@link provide}により{@link Router}のインスタンスを注入可能であるならばこの限りではない)
- */
-export function useRouter(): Router {
-	return inject(DI.router, null) ?? mainRouter;
-}
-
-/**
- * 任意の{@link Router}を取得するためのファクトリを取得する。
- * あらかじめ{@link setupRouter}を実行しておく必要がある。
- */
-export function useRouterFactory(): (path: string) => Router {
-	const factory = inject<(path: string) => Router>('routerFactory');
-	if (!factory) {
-		console.error('routerFactory is not defined.');
-		throw new Error('routerFactory is not defined.');
-	}
-
-	return factory;
-}
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index 1810ec1743..754bf070fa 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -97,7 +97,7 @@ import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js';
-import { useRouter } from '@/router/supplier.js';
+import { useRouter } from '@/router.js';
 import { prefer } from '@/preferences.js';
 import { openAccountMenu as openAccountMenu_ } from '@/accounts.js';
 import { $i } from '@/i.js';
diff --git a/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts
index ae61e497b5..1459881ba1 100644
--- a/packages/frontend/src/ui/_common_/sw-inject.ts
+++ b/packages/frontend/src/ui/_common_/sw-inject.ts
@@ -8,7 +8,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import { $i } from '@/i.js';
 import { getAccountFromId } from '@/utility/get-account-from-id.js';
 import { deepClone } from '@/utility/clone.js';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 import { login } from '@/accounts.js';
 
 export function swInject() {
diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue
index 969e30b3a9..fa63586ef7 100644
--- a/packages/frontend/src/ui/classic.vue
+++ b/packages/frontend/src/ui/classic.vue
@@ -58,7 +58,7 @@ import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js';
 import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { miLocalStorage } from '@/local-storage.js';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 import { prefer } from '@/preferences.js';
 import { DI } from '@/di.js';
 
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index a5db4031e2..4e9ec7c586 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -114,7 +114,7 @@ import XWidgetsColumn from '@/ui/deck/widgets-column.vue';
 import XMentionsColumn from '@/ui/deck/mentions-column.vue';
 import XDirectColumn from '@/ui/deck/direct-column.vue';
 import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 import { columns, layout, columnTypes, switchProfileMenu, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js';
 const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
 const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));
diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue
index b4d494fb09..78454d2e49 100644
--- a/packages/frontend/src/ui/deck/main-column.vue
+++ b/packages/frontend/src/ui/deck/main-column.vue
@@ -28,7 +28,7 @@ import type { PageMetadata } from '@/page.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 import { prefer } from '@/preferences.js';
 import { DI } from '@/di.js';
 
diff --git a/packages/frontend/src/ui/minimum.vue b/packages/frontend/src/ui/minimum.vue
index 7b58829ff5..4bfd240805 100644
--- a/packages/frontend/src/ui/minimum.vue
+++ b/packages/frontend/src/ui/minimum.vue
@@ -19,7 +19,7 @@ import { instanceName } from '@@/js/config.js';
 import XCommon from './_common_/common.vue';
 import type { PageMetadata } from '@/page.js';
 import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 import { DI } from '@/di.js';
 
 const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index');
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index be933d5324..a6695de39d 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -110,7 +110,7 @@ import { $i } from '@/i.js';
 import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js';
 import { deviceKind } from '@/utility/device-kind.js';
 import { miLocalStorage } from '@/local-storage.js';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 import { prefer } from '@/preferences.js';
 import { shouldSuggestRestoreBackup } from '@/preferences/utility.js';
 import { DI } from '@/di.js';
diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue
index 976c3584a7..ddc3761b04 100644
--- a/packages/frontend/src/ui/visitor.vue
+++ b/packages/frontend/src/ui/visitor.vue
@@ -36,7 +36,7 @@ import { instance } from '@/instance.js';
 import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js';
 import { i18n } from '@/i18n.js';
 import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 import { DI } from '@/di.js';
 
 const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index');
diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue
index d60611a19c..8a09ad80d9 100644
--- a/packages/frontend/src/ui/zen.vue
+++ b/packages/frontend/src/ui/zen.vue
@@ -30,7 +30,7 @@ import XCommon from './_common_/common.vue';
 import type { PageMetadata } from '@/page.js';
 import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js';
 import { i18n } from '@/i18n.js';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 import { DI } from '@/di.js';
 
 const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index');
diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts
index c28b2db6d7..de20f2678e 100644
--- a/packages/frontend/src/utility/get-user-menu.ts
+++ b/packages/frontend/src/utility/get-user-menu.ts
@@ -16,7 +16,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import { $i, iAmModerator } from '@/i.js';
 import { notesSearchAvailable, canSearchNonLocalNotes } from '@/utility/check-permissions.js';
 import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 import { genEmbedCode } from '@/utility/get-embed-code.js';
 import { prefer } from '@/preferences.js';
 import { getPluginHandlers } from '@/plugin.js';
diff --git a/packages/frontend/src/utility/lookup.ts b/packages/frontend/src/utility/lookup.ts
index e0b945e49b..90611094fa 100644
--- a/packages/frontend/src/utility/lookup.ts
+++ b/packages/frontend/src/utility/lookup.ts
@@ -3,11 +3,11 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
+import type { Router } from '@/router.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { Router } from '@/router.js';
-import { mainRouter } from '@/router/main.js';
+import { mainRouter } from '@/router.js';
 
 export async function lookup(router?: Router) {
 	const _router = router ?? mainRouter;