Skip to content

Instantly share code, notes, and snippets.

@kinngh
Last active December 21, 2025 08:07
Show Gist options
  • Select an option

  • Save kinngh/24f06116c671fe1bed0280092550acde to your computer and use it in GitHub Desktop.

Select an option

Save kinngh/24f06116c671fe1bed0280092550acde to your computer and use it in GitHub Desktop.
File based routing with `preact` and `preact-iso`
//I prefer renaming it to src/entry-client.jsx
//src/Index.jsx
import { render } from "preact";
import { LocationProvider } from "preact-iso";
import RouterView from "./routing/RouterView";
export function App() {
return (
<LocationProvider>
<RouterView />
</LocationProvider>
);
}
const mount = document.getElementById("root") || document.getElementById("app");
if (!mount) {
throw new Error("Missing mount element: expected #root or #app");
}
render(<App />, mount);
{
"compilerOptions": {
"paths": {
"@router": ["./src/routing/router.jsx"],
"@router/*": ["./src/routing/*"]
}
},
}
//src/routing/router.jsx
import { useLocation, useRoute } from "preact-iso";
/**
* @typedef {Object} RouterLike
* @property {(url: string, replace?: boolean) => void} push
* @property {Record<string, string>} query
* @property {Record<string, string>} params
* @property {string} currentPath
* @property {string} url
*/
/**
* Router hook backed by preact-iso.
*
* @returns {RouterLike}
*/
export function useRouter() {
const location = useLocation();
const route = useRoute();
return {
push: (url, replace) => location.route(url, replace),
query: route.query,
params: route.params,
currentPath: route.path,
url: location.url,
};
}
export default useRouter;
//src/routing/RouterView.jsx
/// <reference types="vite/client" />
import { ErrorBoundary, Router, Route, lazy } from "preact-iso";
/**
* @typedef {import('preact').ComponentType<any>} AnyComponent
*/
/**
* A Vite dynamic import loader for a page module.
*
* @typedef {() => Promise<{ default: AnyComponent } & Record<string, any>>} Loader
*/
/** @type {Record<string, Loader>} */
const pageImports = import.meta.glob("../pages/**/!(_)*.{tsx,jsx}");
/**
* @param {string} key
* @returns {string}
*/
function pathToIsoPattern(key) {
let routePath = key.replace(/^\.\.\/pages/, "").replace(/\.(t|j)sx$/, "");
if (routePath.endsWith("/index")) {
routePath = routePath.replace(/\/index$/, "") || "/";
}
routePath = routePath.replace(/\[\.\.\.([^/\]]+)\]/g, ":$1*");
routePath = routePath.replace(/\[([^/\]]+)\]/g, ":$1");
return routePath;
}
/**
* @param {unknown} loader
* @returns {() => Promise<{ default: AnyComponent }>}
*/
function toLazyModule(loader) {
/** @type {Loader} */
const typed = loader;
return async () => {
const mod = await typed();
return { default: mod.default };
};
}
function RouterView() {
const entries = Object.entries(pageImports).filter(
([key]) => !key.includes("/pages/api/")
);
const notFoundEntry = entries.find(([key]) => /\/404\.(t|j)sx$/.test(key));
const routes = entries
.filter(([key]) => !/\/404\.(t|j)sx$/.test(key))
.map(([key, loader]) => {
const path = pathToIsoPattern(key);
const Component = lazy(toLazyModule(loader));
return <Route key={key} path={path} component={Component} />;
});
const NotFound = notFoundEntry
? lazy(toLazyModule(notFoundEntry[1]))
: function NotFoundFallback() {
return <p>no route found</p>;
};
return (
<ErrorBoundary>
<Router>
{routes}
<Route default component={NotFound} />
</Router>
</ErrorBoundary>
);
}
export default RouterView;
import { fileURLToPath, URL } from "url";
export default defineConfig({
resolve: {
alias: {
"@router": fileURLToPath(
new URL("./src/routing/router.jsx", import.meta.url)
),
"@router/": fileURLToPath(new URL("./src/routing/", import.meta.url)),
},
},
});
@kinngh
Copy link
Author

kinngh commented Dec 20, 2025

  • Supports file-based routing via pages/
  • Supports index routes (/index.jsx/)
  • Supports dynamic routes (/[param].jsx:param)
  • Supports catch-all routes (/[...param].jsx:param*)
  • Supports a global 404 route (/404.jsx)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment