pathpida: typed internal links for Next.js generated from your own routes
TypeScript friendly internal link client for Next.js and Vinext.
At a glance
- What is it?
- pathpida walks your pages directory and generates a TypeScript file of typed path builders, so an internal link to a dynamic route is a compile-time checked call rather than a hand-written string that breaks when a route is renamed. The generated code has no runtime component and static assets are included in the same treatment.
- Who is it for?
- Adopt pathpida if you are working in TypeScript on a Next.js or Vinext app and want internal links to fail at compile time rather than at click time, especially if you use dynamic routes with typed queries. Do not adopt it for a JavaScript project, since the whole value is the type definitions, and note that the generator is a build step you have to wire into your scripts rather than something that runs on its own.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 20 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The problem: internal links are strings that nobody checks
pathpida is a TypeScript friendly pages and static path generator for Next.js and Vinext, and the feature list is four bullets that describe a fairly narrow, very practical idea.
Type safety means it automatically generates type definition files for manipulating internal links. Zero configuration means no configuration is required and it can be used immediately after installation. Zero runtime means it is lightweight because runtime code is not included in the bundle. And support for static files means static files in public/ are also supported, so static assets can be safely referenced.
The problem being solved is the one every Next.js project hits within a week. A link to an internal route is a string. If you rename a route, change a dynamic segment, or add a required query parameter, nothing tells you. The link still compiles, it still deploys, and it 404s in production. In a large application that is a real cost, because the paths are scattered across components and the compiler sees none of it.
The approach is to make the build step aware of the filesystem. pathpida walks your pages directory, works out the route shape for each file, and writes out a TypeScript module that exposes one builder per route. A link then becomes a typed call, and a call with the wrong argument or a missing query fails to typecheck.
The project is by aspida, with the author recorded as Solufa, the primary language is TypeScript, the licence is MIT, and the homepage is the GitHub repository. The recent releases are v0.28.0 on 2026-10-03, v0.27.0 on 2026-09-12 and v0.26.1 from 2026-03-25, so this is a young project already shipping regular minor bumps.
The zero runtime claim is the part that makes it viable. Because the generated file is types plus plain object literals, nothing has to be imported into the server bundle, so the cost of using it is the build time and nothing else.
Installing it, and the four command line options
Installation is a dev dependency, because the generator runs at build time and nothing from it ships:
$ npm install pathpida npm-run-all --save-devor with Yarn:
$ yarn add pathpida npm-run-all --devThe second package, npm-run-all, is there so the dev script can run the Next.js dev server and the pathpida watcher at the same time.
The command line interface has four options, and the table in the README is short enough to be worth taking in full.
The --enableStatic flag, with the short form -s, generates static files path in $path.ts. The --ignorePath flag, short form -p, takes a string and specifies the ignore pattern file path. The --output flag, short form -o, takes a string and specifies the output directory for $path.ts. And the --watch flag, short form -w, enables watch mode and regenerates $path.ts.
The naming is slightly confusing at first because two of them end in Path but mean different things. The --ignorePath option takes the path to a file containing ignore patterns, and the example usage passes .gitignore, which is the obvious choice since the generated output and your build artefacts should not be scanned. The --output option takes the directory the generated file goes into.
The file itself is always called $path.ts, with the dollar sign, and it lands either in lib/ or utils/, at the root or under src/ depending on where your project keeps its code. The dollar sign in the name is a convention borrowed from the SvelteKit and Remix ecosystems, where a generated file with a $ prefix signals to the reader that the file is not meant to be edited by hand.
That is a nice small touch, because the alternative is a file named paths.ts that somebody will eventually open and edit, and then the next generation will silently overwrite their changes.
Wiring it into your scripts, and the two layouts it understands
The Setup section shows the package.json scripts, and the shape of them is the whole integration.
{
"scripts": {
"dev": "run-p dev:*",
"dev:next": "next dev",
"dev:path": "pathpida --ignorePath .gitignore --watch",
"build": "pathpida --ignorePath .gitignore && next build"
}
}The dev script uses run-p with the dev:* wildcard, which is what npm-run-all gives you: it runs every script whose name starts with dev: in parallel. So the Next.js dev server and the pathpida watcher start together, and the watcher keeps $path.ts in step with the files you create as you work.
The build script runs pathpida once and then next build, in that order, because the types have to exist before the TypeScript compilation happens. This is the one ordering constraint and it is easy to get wrong in a large project where the build has many steps.
The Usage section shows the two directory layouts pathpida understands, and they are the standard Next.js ones. Either a pages/ directory at the root:
pages/index.tsx
pages/post/create.tsx
pages/post/[pid].tsx
pages/post/[...slug].tsx
lib/$path.ts or utils/$path.ts // Generated automatically by pathpidaOr a src/ directory:
src/pages/index.tsx
src/pages/post/create.tsx
src/pages/post/[pid].tsx
src/pages/post/[...slug].tsx
src/lib/$path.ts or src/utils/$path.ts // Generated automatically by pathpidaThe fact that both are supported is not a detail, because a large number of Next.js applications were migrated from the first layout to the second during the app router transition, and a tool that only handled one would have been abandoned by half its audience.
The route shapes in the example are also the interesting part, because they cover the three cases that matter. A static route, a dynamic segment with a single parameter, and a catch-all segment with a rest parameter. The generated builder for each looks different, and the type of the argument you pass mirrors the shape of the route.
Using the generated builders, and the query type checking
The generated module exports a pagesPath object, and the usage example shows what each kind of route produces.
import Link from 'next/link';
import { pagesPath } from '../lib/$path';
console.log(pagesPath.post.create.$url()); // { pathname: '/post/create' }
console.log(pagesPath.post._pid(1).$url()); // { pathname: '/post/[pid]', query: { pid: 1 }}
console.log(pagesPath.post._slug(['a', 'b', 'c']).$url()); // { pathname: '/post//[...slug]', query: { slug: ['a', 'b', 'c'] }}A static route is a plain call. A dynamic route takes the segment value, and the leading underscore marks the segment, so _pid takes a number and _slug takes an array for a catch-all. The $url method then produces the object that next/link and router.push both accept, with the pathname and the query object already filled in.
The query typing is the part that is more than a convenience, and it comes from a type you export alongside the page. A page with required query parameters exports a Query type:
export type Query = {
userId: number;
name?: string;
};A page with only optional ones exports OptionalQuery:
export type OptionalQuery = {
limit: number;
label?: string;
};And then the calls are checked against those types:
console.log(pagesPath.post.create.$url({ query: { userId: 1 } })); // { pathname: '/post/create', query: { userId: 1 }}
console.log(pagesPath.post.create.$url()); // type error
console.log(pagesPath.post._pid(1).$url()); // { pathname: '/post/[pid]', query: { pid: 1 }}
console.log(pagesPath.post._pid(1).$url({ query: { limit: 10 }, hash: 'sample' })); // { pathname: '/post/[pid]', query: { pid: 1, limit: 10 }, hash: 'sample' }That third line is the point of the whole tool. Calling $url on a route whose page declares a required userId with no query is a type error, so the compiler refuses to let you ship the link that would have 404ed. The optional fields are genuinely optional, and the hash parameter is available for the fragment.
The practical value compounds. A route that gains a required query parameter produces a compile error at every call site rather than a silent runtime break, and because the type lives in the page file, the person who owns the route owns the contract.
Static files, and the switch that turns them on
Static assets get the same treatment as routes, but through a different flag and a different export.
Turning on --enableStatic adds a staticPath export alongside pagesPath, and it appears in the generated file in the same lib/ or utils/ location. The scripts that use it differ only in that flag:
{
"scripts": {
"dev": "run-p dev:*",
"dev:next": "next dev",
"dev:path": "pathpida --enableStatic --watch",
"build": "pathpida --enableStatic && next build"
}
}The file layout then includes the public directory:
pages/index.tsx
pages/post/create.tsx
pages/post/[pid].tsx
pages/post/[...slug].tsx
public/aa.json
public/bb/cc.png
lib/$path.ts or utils/$path.ts // Generated automatically by pathpidaAnd the usage is a property access rather than a call, because a static file has no parameters:
import Link from 'next/link';
import { pagesPath, staticPath } from '../lib/$path';
console.log(staticPath.aa_json); // /aa.jsonThe naming convention in that line is worth noting. A file called aa.json becomes the property aa_json, and a nested file bb/cc.png becomes bb.cc_png. Dots and dashes are not valid in a JavaScript identifier, so the generator substitutes them with underscore characters, which means the property name is a mechanical transformation of the path rather than something you choose.
The consequence is that the property name and the file path can drift apart in a way you have to know about, because bb.cc_png is not obviously bb/cc.png at a glance. That is a mild readability cost, and the alternative, a nested object with the real path segments, would have made every access a bracket lookup. The README opts for the flat form, which is faster to read at the call site and less flexible to write.
The benefit is the same as for routes. A static asset reference is a string that is wrong the moment a file is renamed, and the generated path changes with it, so the compiler finds every reference that needs updating.
The project itself, and what it is built with
The repository is a young, well-organised TypeScript project and the tooling tells you how it is maintained.
The package.json lists the scripts, and they are modern. The build runs a rimraf step and then tsc with a dedicated tsconfig.build.json, so the published output is a compiled dist/ directory, and the files array is just dist. The dev script runs the build and then tsx projects/build.ts, which is how the project builds its own example projects. Linting is oxlint with oxfmt for formatting, in both a check and a fix variant, and the tests run on vitest. The typecheck script runs tsc with noEmit.
The two formatter and linter configs at the root, .oxfmtrc.json and .oxlintrc.json, match that pairing, and the presence of .vscode/ means the editor is configured for the same tools.
The runtime dependency list is one package: ignore at 7.0.9, which is the ignore-file parser that makes the --ignorePath option work. Everything else is a dev dependency, which is the zero runtime claim made concrete: the published package depends on nothing but a file-pattern matcher, and the generated output that ends up in your application is plain TypeScript with no imports.
The top level listing has a src/ directory for the generator itself, a bin/ directory with the command line entry point that the package.json bin field points at, a tests/ directory, a projects/ directory for the example projects the dev script builds, a docs/ directory, and a Makefile. There is a package-lock.json rather than a yarn lockfile, and a tsconfig.json alongside the build-specific one.
The version in the package.json is 0.0.2 with a placeholder description, which is a common pattern for a package whose release version is set by the release tooling rather than by hand, and the published versions on npm are the ones that matter.
Given the release cadence of a minor bump every few weeks and the fact that the feature set is already complete for the common case, pathpida looks like a tool that has found its shape: one job, done at build time, with types as the output.
Editorial conclusion
Adopt pathpida if you are working in TypeScript on a Next.js or Vinext app and want internal links to fail at compile time rather than at click time, especially if you use dynamic routes with typed queries. Do not adopt it for a JavaScript project, since the whole value is the type definitions, and note that the generator is a build step you have to wire into your scripts rather than something that runs on its own. Before you start, decide where the generated file goes, because the README expects it in lib/ or utils/ at the root or under src/, and put the watch mode in your dev script so the file stays current while you work.
Frequently asked questions
What does pathpida do?
It is a TypeScript friendly pages and static path generator for Next.js and Vinext that automatically generates type definition files for manipulating internal links, so route references are typed rather than hand-written strings. It requires no configuration, ships no runtime code, and also supports static files in public/.
How do I install pathpida?
As a dev dependency, with npm install pathpida npm-run-all --save-dev, or yarn add pathpida npm-run-all --dev. The npm-run-all package is what lets the dev script run the Next.js dev server and the pathpida watcher in parallel.
What command line options does pathpida have?
Four: --enableStatic or -s to generate static file paths, --ignorePath or -p to specify the ignore pattern file path, --output or -o to specify the output directory for $path.ts, and --watch or -w to enable watch mode and regenerate the file.
How does pathpida type check query parameters?
A page exports a Query type for required query parameters or an OptionalQuery type for optional ones, and the generated builder requires the matching query object. Calling $url on a route with a required parameter and no query is a type error.
Where does pathpida put the generated file and how do I wire it in?
The generated file is always called $path.ts and goes in lib/ or utils/, at the root or under src/. The dev script runs pathpida with --watch through npm-run-all alongside the Next.js dev server, and the build script runs pathpida once before next build so the types exist at compile time.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/aspida-pathpida)Was this useful?