Jen.js uses file-based routing — your file structure automatically becomes your routes. No configuration needed.
Basic Concept
Files in site/ are converted to routes:
| File | Route | Type |
|---|---|---|
(home).tsx |
/ |
Page |
(about).tsx |
/about |
Page |
contact/(form).tsx |
/contact/form |
Page |
api/(users).ts |
/api/users |
API |
Route File Naming
Route files follow the pattern: (name).tsx or (name).ts
Valid names:
- (home).tsx — alphanumeric, hyphens, underscores
- (user-profile).tsx — hyphens are allowed
- (api_users).ts — underscores are allowed
Invalid names:
- home.tsx — missing parentheses
- (home).test.tsx — test files ignored
- home.module.tsx — module files ignored
Static Routes
Create a file to create a static route:
site/(about).tsx
export default function About() {
return <h1>About Page</h1>;
}
Access at /about
Nested Routes
Nested directories create nested routes:
site/
├── (home).tsx → /
├── blog/
│ ├── (index).tsx → /blog
│ └── (post).tsx → /blog/post
└── docs/
├── (intro).tsx → /docs/intro
└── api/
└── (reference).tsx → /docs/api/reference
Dynamic Routes
Use $ prefix for dynamic segments:
site/posts/($id).tsx
import type { LoaderContext } from '@src/core/routes';
export async function loader(ctx: LoaderContext) {
const postId = ctx.params.id;
// Fetch post by ID
const post = await getPost(postId);
return post;
}
export default function Post({ data }: any) {
return (
<article>
<h1>{data.title}</h1>
<p>{data.content}</p>
</article>
);
}
Access with: /posts/1, /posts/hello, /posts/any-value
Multiple Dynamic Segments
site/users/($userId)/posts/($postId).tsx
export async function loader(ctx: LoaderContext) {
const userId = ctx.params.userId;
const postId = ctx.params.postId;
// ...
}
Access with: /users/john/posts/42
Catch-All Routes
Use (...name) for catch-all routes:
site/docs/(...rest).tsx
export async function loader(ctx: LoaderContext) {
const pathSegments = ctx.params.rest; // array of segments
// Handle any path under /docs/*
}
Access with:
- /docs/intro
- /docs/api/reference
- /docs/api/reference/functions
Route Exports
Default Export (Required)
The default export is your page component:
export default function MyPage() {
return <h1>Hello</h1>;
}
Loader Export (Optional)
Load data before rendering:
export async function loader(ctx: LoaderContext) {
return {
title: 'My Page',
items: await fetchItems()
};
}
Data passed to component:
export default function MyPage({ data }: { data: any }) {
return <h1>{data.title}</h1>;
}
Head Export (Optional)
Custom head elements:
export function Head({ data }: any) {
return (
<>
<title>{data.title}</title>
<meta name="description" content={data.description} />
<meta property="og:title" content={data.title} />
</>
);
}
API Routes
API routes handle HTTP requests without rendering HTML:
site/api/(users).ts
import type { IncomingMessage, ServerResponse } from 'node:http';
export async function handle(req: IncomingMessage, res: ServerResponse) {
if (req.method === 'GET') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ users: [] }));
} else if (req.method === 'POST') {
// Handle POST
} else {
res.writeHead(405);
res.end('Method Not Allowed');
}
}
Reading Request Body
export async function handle(req: IncomingMessage, res: ServerResponse) {
if (req.method === 'POST') {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
const data = JSON.parse(body);
// Process data
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ success: true }));
});
}
}
Query Parameters
export async function handle(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url!, `http://${req.headers.host}`);
const search = url.searchParams.get('search');
// Use search parameter
}
Route Context
The LoaderContext provides:
interface LoaderContext {
params: Record<string, string | string[]>; // Route parameters
request: IncomingMessage; // HTTP request
response: ServerResponse; // HTTP response
query: URLSearchParams; // Query parameters
}
Example
For route /users/john/posts/42?tab=comments:
export async function loader(ctx: LoaderContext) {
console.log(ctx.params); // { userId: 'john', postId: '42' }
console.log(ctx.query.get('tab')); // 'comments'
}
Route Ordering
Routes are matched in order of specificity:
- Static routes (e.g.,
/about) - Dynamic routes (e.g.,
/posts/:id) - Catch-all routes (e.g.,
/docs/*)
So /posts/new matches the static route before ($id) route.
Special Routes
Error Pages
Create site/(error).tsx for 404s:
export default function NotFound() {
return <h1>Page Not Found</h1>;
}
Redirects
Use middleware or loader to redirect:
export async function loader(ctx: LoaderContext) {
if (!userAuthenticated) {
ctx.response.writeHead(302, { location: '/login' });
ctx.response.end();
return {};
}
// ...
}
Static vs Dynamic Routes
Static Generation
For routes that don't change:
// site/(about).tsx
export default function About() {
return <h1>About</h1>;
}
Build with npm run build — creates static HTML.
Dynamic Routes
For routes that change per request:
// site/posts/($id).tsx
export async function loader(ctx: LoaderContext) {
// This runs on every request (SSR)
return { post: await getPost(ctx.params.id) };
}
Leave running or use SSR mode.
Best Practices
- Use meaningful names:
(user-profile)not(up) - Keep routes flat unless nesting is logical
- Use API routes for backend logic
- Export loaders for data dependencies
- Use Head export for SEO meta tags