TypeScript
WebJs is built for TypeScript from the ground up, but never forces a build step you run. It runs on Node 24+ or Bun; on Node the type-stripping is the built-in (process.features.typescript === 'strip'), on Bun it is amaro (byte-identical). Server-side .ts imports work without any loader registration; browser-bound .ts requests go through the runtime stripper on the dev server. Both paths perform whitespace replacement: every (line, column) in the source maps to the same position in the stripped output, so no sourcemap is shipped and stack traces are byte-exact.
No-Build TypeScript
The runtime's type stripping does the heavy lifting: Node 24+'s built-in module.stripTypeScriptTypes, or amaro on Bun (the same engine Node wraps internally, so the output is byte-identical). The dev server reads each .ts request from disk, strips it, and serves the result. Transform time is around 1ms per file; the result is cached by mtime, so subsequent loads are instant. SSR and hydration produce identical JS because both halves use the same stripper.
On the server side, your pages, layouts, server actions, and middleware run as-is. On the client side, browsers fetch .ts URLs and receive whitespace-stripped JS with no sourcemap appended. The URL keeps its .ts extension; only the response body changes. In production, webjs start uses the same code path, with the same mtime cache.
The "no build" promise is literal: every position in source maps to itself in runtime. DevTools shows accurate stack traces without consulting a sourcemap, and wire bytes drop by roughly 70% vs a bundler-with-sourcemap pipeline.
Use .ts or .js: both are first-class
WebJs treats .ts, .mts, .js, and .mjs identically for routing and module resolution. The router recognises page.ts and page.js the same way. The action scanner recognises create-post.server.ts and create-post.server.js. Pick your preference and be consistent, or mix them freely across your project.
tsconfig.json Setup
TypeScript type-checking is entirely optional, but recommended. Here is the recommended tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noEmit": true,
"checkJs": true,
"allowJs": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": false,
"erasableSyntaxOnly": true
},
"include": [
"app/**/*",
"components/**/*",
"modules/**/*",
"lib/**/*",
"middleware.ts"
],
"exclude": ["node_modules", ".webjs", "db/migrations"]
}
Key settings explained:
- erasableSyntaxOnly: true: rejects non-erasable TypeScript syntax (
enum,namespacewith values, constructor parameter properties, legacy decorators,import = require) at compile time. Required because Node's built-in stripper only supports erasable TypeScript. Violations surface as red squiggles in the editor. See TypeScript Feature Support below for the erasable equivalents. - noEmit: true: WebJs never compiles TypeScript to JavaScript on disk. The TypeScript compiler is used only for type-checking (
tsc --noEmit). Node runs your.tsfiles directly via its built-in stripper. - allowImportingTsExtensions: true: lets you write
import { foo } from './bar.ts'with the explicit.tsextension. This is the WebJs convention (see below). - checkJs: true: type-check your
.jsfiles too, using JSDoc annotations. Enables a mixed codebase where both.tsand.jsfiles participate in the same type graph. - allowJs: true: include
.jsfiles in the project. Required alongsidecheckJs. - module / moduleResolution: NodeNext: matches how Node resolves ESM imports, including
.tsextensions. - isolatedModules: true: ensures every file can be transpiled independently, matching the per-file transform model of Node's stripper.
Import Convention: the # root alias and explicit .ts extensions
WebJs apps address top-level folders through the # root alias (#db/..., #lib/..., #modules/..., #components/...) rather than deep ../../ relative paths. It is Node's native package.json "imports" map (the scaffold ships a single "#*": "./*" entry, so every top-level folder is aliased), resolved with no build step and no tsconfig paths on Node 24+ and Bun. The sigil is # with no slash after it (#lib/..., never #/lib/...). A same-directory import stays relative (./sibling.ts).
Always use the real file extension in your imports:
// Good: explicit .ts extension
import { db } from '#db/connection.server.ts';
import { createPost } from '#modules/posts/actions/create-post.server.ts';
import type { PostFormatted } from '#modules/posts/types.ts';
// Also fine: .js files
import { slugify } from '#lib/utils/slugify.js';
// Avoid: extensionless imports don't work with Node's ESM or in browsers
import { db } from '#db/connection.server'; // ERROR
This convention works because:
- The runtime strips types from
.tsimports server-side natively (Node 24+, or Bun). No loader hook required. - The dev server reads
.tsfiles from disk, strips them (Node'smodule.stripTypeScriptTypesoramaroon Bun), and serves the result with position-preserving whitespace replacement. - When the browser requests a
.jsfile that doesn't exist but a sibling.tsdoes, WebJs falls back to the.tsversion automatically. This means libraries that import without extensions can still work.
Full-Stack Type Safety
Server actions in WebJs provide end-to-end type safety without code generation. When a client component imports from a .server.ts file, TypeScript sees the real function signature:
// modules/posts/actions/create-post.server.ts
'use server';
export type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string; status: number };
export async function createPost(
input: unknown
): Promise<ActionResult<PostFormatted>> {
// server-only code: database queries, auth checks, etc.
const me = await currentUser();
if (!me) return { success: false, error: 'Not signed in', status: 401 };
// ...
}
// components/new-post-form.ts: client component
import { createPost } from '#modules/posts/actions/create-post.server.ts';
// TypeScript knows createPost accepts (input: unknown)
// and returns Promise<ActionResult<PostFormatted>>
const result = await createPost({ title, body });
if (result.success) {
// result.data is typed as PostFormatted
console.log(result.data.slug);
}
At runtime, the browser never receives the server code. WebJs replaces the import with a thin RPC stub that calls POST /__webjs/action/:hash/createPost. But TypeScript's type checker sees through the .server.ts boundary and validates argument/return types at compile time.
Typed metadata and page props
The same isomorphic @webjsdev/core surface a page already imports html from also exports the types for its routing files, so metadata, page / layout / route-handler props, and client-router hrefs all type-check. Every one is a pure type (zero runtime, erased at strip time, no build cost).
Page metadata: Metadata and generateMetadata
A page or layout exports metadata (static) or generateMetadata(ctx) (request-scoped). Annotate the return with the exported Metadata type so a misspelled field (titel, descripton) or a wrong-typed value (themeColor: 123) is a compile-time error, the same ergonomics as Next.js's import type { Metadata } from 'next' (#257). MetadataContext types the generateMetadata argument.
import type { Metadata, MetadataContext } from '@webjsdev/core';
export const metadata: Metadata = {
title: 'Blog',
description: 'Latest posts',
openGraph: { type: 'website', image: '/og.png' },
twitter: { card: 'summary_large_image' },
};
export async function generateMetadata(ctx: MetadataContext): Promise<Metadata> {
return { title: `Post: ${ctx.params.slug}`, metadataBase: new URL(ctx.url).origin };
}
Metadata covers every field the SSR pipeline reads. Each field is optional, and string-or-object fields (title, viewport, robots, appleWebApp, icons) are unions, so both forms type-check. MetadataContext is { params, searchParams, url, actionData } (where actionData is set only on a failed-page-action re-render). See Metadata Routes for the full field reference.
Page / layout / route-handler props (PageProps, LayoutProps, RouteHandlerContext)
A page default export receives { params, searchParams, url, actionData }; a layout receives the same plus children; a route.ts handler receives (request, { params }). Type each with the exported helpers so a typo in a param name or a wrong-typed field is a compile-time error (#258).
import type { PageProps, LayoutProps, RouteHandlerContext } from '@webjsdev/core';
// A static route: params is Record<string, string>.
export default function About({ searchParams }: PageProps) { /* ... */ }
// A dynamic route: pass the route literal to narrow params.
export default function Post({ params }: PageProps<'/blog/[slug]'>) {
const slug = params.slug; // typed string
/* ... */
}
// A layout adds children: TemplateResult.
export default function RootLayout({ children }: LayoutProps) { /* ... */ }
// A route handler's 2nd arg.
export async function GET(req: Request, ctx: RouteHandlerContext) {
return Response.json({ id: ctx.params.id });
}
PageProps<R> / LayoutProps<R> / RouteHandlerContext<R> take an optional route literal R. With no R (or in an app that has not generated route types), params is Record<string, string>, the runtime default. With R set to a generated dynamic route, params narrows to its exact shape ({ slug: string }, { rest: string[] }, { slug?: string[] }). The shapes mirror what the server actually passes, NOT Next.js's superset.
The generated route union (webjs types)
Run webjs types to generate .webjs/routes.d.ts, an opt-in overlay that augments @webjsdev/core with one key per route in app/. It narrows two things at tsserver time.
- The
Routehref type thatnavigate()and a typed<a href>accept:navigate('/blog/anything')is accepted,navigate('/nonexistent')is a type error. Until you generate the types,Routeisstring, sonavigate()is unconstrained, non-breaking for JSDoc apps and un-generated apps alike. - Per-route
params:PageProps<'/blog/[slug]'>['params']becomes{ slug: string }, derived from the generatedRouteParamMap.
webjs types # writes .webjs/routes.d.ts (count of routes printed)
webjs dev also emits it automatically at startup and re-emits after each route rebuild, so an editor always has fresh route types. The file is gitignored (regenerated per machine, like Next's .next/types); the scaffold tsconfig.json lists .webjs/routes.d.ts in include so tsserver picks it up. To opt in for an existing app, run webjs types once and ensure your tsconfig.json include lists .webjs/routes.d.ts. This is webjs's no-build equivalent of Next 15's typedRoutes, achieved via interface declaration-merging rather than a bundler. Output is deterministic (sorted keys), so re-running yields a byte-identical file.
Rich Types Across the Wire
Standard JSON cannot represent Date, Map, Set, BigInt, undefined, NaN, Infinity, TypedArray, Blob, File, or FormData. WebJs ships its own pure-ESM serializer (in @webjsdev/core) used for all server action RPC calls and for the json() / richFetch() helpers, so rich types survive the network round-trip, including binary content (file uploads through actions just work).
// Server action
export async function getEvents(): Promise<Event[]> {
return db.query.events.findMany(); // createdAt is a Date
}
// Client: createdAt arrives as a real Date, not a string
const events = await getEvents();
events[0].createdAt instanceof Date; // true
events[0].createdAt.toLocaleDateString(); // works
For API routes, the same content negotiation applies. Use json() from @webjsdev/server on the server side and richFetch() from webjs on the client side to get rich-type encoding. External consumers (curl, other services) get plain JSON automatically.
Typing the auth() Session User
auth() resolves { user }, and by default user is Record<string, unknown>, so reading a custom field your session/jwt callbacks set (such as session.user.id) needs a cast and a typo is not caught. Two opt-in, types-only ways make user typed.
Augment the AuthUser interface (NextAuth/Auth.js style) to type every auth() call across the app:
declare module '@webjsdev/server' {
interface AuthUser {
id: string;
role: 'admin' | 'member';
}
}
Or parameterise the factory to type one instance without a global augmentation: createAuth<AppUser>(...) returns an auth() whose user is AppUser:
const { auth } = createAuth<AppUser>({ secret, providers });
const session = await auth();
session?.user.role; // typed, no cast
Un-augmented and un-parameterised, AuthUser is empty and resolves back to Record<string, unknown>, so pre-existing untyped code keeps compiling. The declared fields should mirror what the callbacks write onto session.user. See Auth.
JSDoc Alternative
If you prefer .js files, you can achieve the same type safety using JSDoc annotations with checkJs: true in your tsconfig:
// db/connection.server.js
import * as schema from './schema.server.js';
const { DatabaseSync } = await import('node:sqlite');
const { drizzle } = await import('drizzle-orm/node-sqlite');
export const db = drizzle({ client: new DatabaseSync('db/dev.db'), relations: schema.relations });
/**
* @param {{ title: string, body: string }} input
* @returns {Promise<{ success: boolean, data?: Post, error?: string }>}
*/
export async function createPost(input) {
// TypeScript checks types via JSDoc, same strictness
}
You can also define complex types with @typedef:
/**
* @typedef {{
* id: number,
* title: string,
* slug: string,
* body: string,
* createdAt: Date,
* author: { name: string, email: string }
* }} PostFormatted
*/
/** @param {PostFormatted} post */
export function formatDate(post) {
return post.createdAt.toLocaleDateString();
}
JSDoc-typed .js files and .ts files can import each other freely. The type checker treats them as part of the same project.
TypeScript Feature Support: Erasable Only
WebJs uses Node 24+'s built-in module.stripTypeScriptTypes as its primary stripper. That stripper only supports erasable TypeScript: type annotations, interface, type, declare, generics, import type, as casts, satisfies. Non-erasable syntax is rejected at compile time (via erasableSyntaxOnly: true) and at runtime.
What's not allowed, and what to write instead:
- Enums are not allowed. Use a
constobject plus a derived union type. - Namespaces with values are not allowed. Use a plain object or an ES module.
- Constructor parameter properties are not allowed. Declare the field explicitly and assign in the constructor body.
- Legacy decorators with
emitDecoratorMetadataare not allowed. Stage-3 standard decorators work fine. import = require()is not allowed. Use standard ES import.- Generics, type aliases, interfaces, type assertions, satisfies, const assertions: all supported (these are erasable).
// Not allowed (red squiggle with erasableSyntaxOnly):
enum Direction { Up, Down, Left, Right }
class User { constructor(public name: string) {} }
namespace Util { export const VERSION = '1.0'; }
// Erasable equivalents:
const Direction = { Up: 'up', Down: 'down', Left: 'left', Right: 'right' } as const;
type Direction = (typeof Direction)[keyof typeof Direction];
class User {
name: string;
constructor(name: string) {
this.name = name;
}
}
const Util = { VERSION: '1.0' };
This constraint exists because Node's stripper performs whitespace replacement, not AST regeneration. enum requires emitting a real runtime object, which would change line numbers and require shipping a sourcemap. Banning it preserves the byte-exact position property.
WebJs is buildless end-to-end: there is no bundler fallback for non-erasable syntax. If a .ts file uses enum, namespace with values, parameter properties, legacy decorators with emitDecoratorMetadata, or import = require, Node's stripper throws and the dev server returns a 500 naming the file and pointing at the no-non-erasable-typescript lint rule. The companion erasable-typescript-only rule additionally warns when erasableSyntaxOnly is missing or off in tsconfig.json, so the compiler catches the same constructs at edit time. The realistic edge case (a third-party package shipping raw non-erasable .ts) is rare in practice: published packages compile to .js with sidecar .d.ts files, which the runtime serves as plain JavaScript with no transform.
Mixed Codebases
.js and .ts files can coexist in the same WebJs project and import each other without restriction:
my-app/
app/
layout.ts # TypeScript
page.js # JavaScript
blog/
page.ts # TypeScript
[slug]/
page.ts
components/
counter.ts # TypeScript component
footer.js # JavaScript component
db/
connection.server.ts
lib/
utils.js # JSDoc-typed JavaScript
middleware.ts # TypeScript
tsconfig.json
// app/page.js can import from .ts files
import '#components/counter.ts';
// lib/utils.js can import from .ts files
import { db } from '#db/connection.server.ts';
// app/blog/page.ts can import from .js files
import '#components/footer.js';
The router, action scanner, dev server, and production bundler all accept .ts, .mts, .js, and .mjs interchangeably. Type-check the whole project with a single tsc --noEmit.
Running Type Checks
WebJs does not type-check at runtime or during dev serving. Add a type-check command to your workflow:
{
"scripts": {
"dev": "webjs dev",
"start": "webjs start",
"typecheck": "tsc --noEmit",
"typecheck:watch": "tsc --noEmit --watch"
}
}
Run npm run typecheck in CI or as a pre-commit hook. The dev server stays fast because it only strips types. Full type analysis is a separate, parallelizable step.