Project Conventions
The directory layout, naming rules, and default behaviors Telaio expects and how to override them.
Project Conventions
Telaio's CLI and builder have opinions about where things live. Sticking to the defaults means less configuration. Every default has an explicit override if you need to deviate.
Directory layout
project-root/
telaio.config.ts # CLI discovery -- must be at the root
src/
app.ts # Your AppBuilder wiring
config.ts # loadConfig() call and exported Config type
routes/ # Fastify route plugins (autoloaded)
schemas/ # TypeBox schema files (auto-registered)
db/
migrations/ # Timestamped Kysely migration files
queues/
registry/ # Queue job handler registryRoutes -- src/routes/
Telaio uses @fastify/autoload to load route plugins from src/routes/. The defaults are:
routeParams: true-- directory names prefixed with_are treated as route parameters.src/routes/users/_id/index.tsbecomesGET /users/:id.autoHooks: true-- files named_hooks.tsat any level are registered as Fastify plugins before sibling routes.cascadeHooks: true-- hooks registered in a parent directory apply to all routes in subdirectories.
Route file shape
// src/routes/users/_id/index.ts => GET /users/:id
import type { FastifyPluginAsync } from 'fastify';
const plugin: FastifyPluginAsync = async (fastify) => {
fastify.get('/', { schema }, async (req, reply) => {
const { id } = req.params as { id: string };
// ...
});
};
export default plugin;The path is derived from the directory structure. The filename index.ts contributes no path segment.
Auto-hooks
// src/routes/_hooks.ts -- applies to every route in the application
import type { FastifyPluginAsync } from 'fastify';
const hooks: FastifyPluginAsync = async (fastify) => {
fastify.addHook('onRequest', async (req, reply) => {
// runs before every request handler
});
};
export default hooks;Hooks in a subdirectory apply only to routes at the same level and below:
// src/routes/admin/_hooks.ts -- applies only to routes under /admin
import type { FastifyPluginAsync } from 'fastify';
const hooks: FastifyPluginAsync = async (fastify) => {
fastify.addHook('onRequest', requireAdminSession);
};
export default hooks;Schemas -- src/schemas/
TypeBox schema files placed in src/schemas/ are auto-registered with Fastify at startup. Registered schemas can be referenced by $id in route schemas anywhere in the application.
Naming rules:
- Files must export values whose names end in
Schemato be picked up (e.g.,UserSchema,CreateUserBodySchema). index.tsandutils.tsare excluded from auto-registration -- use these for shared helpers and barrel re-exports.
// src/schemas/user.ts
import { Type } from '@sinclair/typebox';
export const UserSchema = Type.Object(
{
id: Type.String({ format: 'uuid' }),
email: Type.String({ format: 'email' }),
createdAt: Type.String({ format: 'date-time' }),
},
{ $id: 'User' }
);Once registered, { $ref: 'User#' } is valid in any route schema.
Migrations -- src/db/migrations/
Kysely migration files live in src/db/migrations/. Use timestamped filenames so migrations sort chronologically:
src/db/migrations/
20240101000000_create_users.ts
20240115120000_add_user_roles.ts
20240201090000_create_sessions.tsEach file exports an object with up and down functions:
// src/db/migrations/20240101000000_create_users.ts
import type { Kysely } from 'kysely';
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable('users')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn('email', 'text', (col) => col.notNull().unique())
.execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('users').execute();
}Run migrations with telaio migrate (or telaio migrate --latest to apply all pending).
telaio.config.ts -- CLI discovery
The telaio CLI reads telaio.config.ts from the current working directory. It must be at the project root -- the CLI does not search parent directories.
This file controls:
- Which source files the CLI uses for
dev,build, andconsumercommands - The output path for
gen-client - The queue registry path for
consumer
See Config System for the full defineConfig API.
Overriding the defaults
Every convention is a default, not a requirement.
Custom routes directory
createApp({ config })
.withPlugins({ autoload: { dir: 'src/api' } })
// routes are now loaded from src/api/ instead of src/routes/
.build();Disable route autoload entirely
createApp({ config })
.withPlugins({ autoload: false })
// you are responsible for registering all route plugins manually
.build();Custom schemas directory
createApp({ config })
.withSchemas('src/my-schemas')
.build();Disable schema auto-registration
createApp({ config })
.withSchemas(false)
.build();When autoload is disabled, Telaio still sets up all other features (database, cache, auth, etc.) normally. You can register routes with app.fastify.register() after calling app.start(), or manage the full Fastify lifecycle yourself.