Skip to content

Routes and Handlers

Slingshot gives you three ways to add routes. They are not alternatives; they layer.

Use thisWhen
Generated routesAn entity has standard CRUD or operation routes
Package routesA feature has custom HTTP handlers that belong to a domain
routesDirTop-level routes that don’t fit a package, or quick experiments

This page covers the second and third. Generated entity routes are covered under Data and Entities.

Package routes live inside definePackage. They are typed, schema-validated, and show up in OpenAPI alongside generated routes.

src/blog/package.ts
import { z } from 'zod';
import { definePackage, domain, route } from '@lastshotlabs/slingshot';
export const blogPackage = definePackage({
name: 'blog',
domains: [
domain({
name: 'posts',
basePath: '/posts',
routes: [
route.get({
path: '/featured',
summary: 'List featured posts',
responses: {
200: {
description: 'List of featured posts',
schema: z.object({ items: z.array(z.object({ id: z.string(), title: z.string() })) }),
},
},
handler: ({ respond }) => respond.json({ items: [] }),
}),
route.post({
path: '/:id/like',
summary: 'Like a post',
auth: 'userAuth',
request: { params: z.object({ id: z.string() }) },
handler: ({ params, respond }) => respond.json({ liked: params.id }),
}),
],
}),
],
});

Wire the package into your config:

app.config.ts
import { defineApp } from '@lastshotlabs/slingshot';
import { blogPackage } from './src/blog/package';
export default defineApp({
port: 3000,
packages: [blogPackage],
});

Each route.* call returns a typed route definition. The handler signature is fully inferred from the schemas — params, body, and the response shape are all checked at compile time.

For small apps, prototypes, or top-level routes that don’t belong to any domain, set routesDir and drop files in:

app.config.ts
import { defineApp } from '@lastshotlabs/slingshot';
export default defineApp({
port: 3000,
routesDir: './src/routes',
});
src/routes/orders.ts
import { Hono } from 'hono';
const router = new Hono();
router.get('/orders', async c => {
return c.json({ orders: [] });
});
router.post('/orders', async c => {
const body = await c.req.json();
return c.json({ id: 'ord_123' }, 201);
});
export { router };

Slingshot discovers every file under routesDir that exports a named router. File names don’t drive routing — paths come from what you register on the Hono instance. Nest files freely.

Whether you write package routes or routesDir handlers, the request model is the same underneath. Hono is the foundation; Slingshot adds typed input parsing and the runtime context.

// @skip-typecheck
import { getActor, getContext, getRequestTenantId, route } from '@lastshotlabs/slingshot';
route.get({
path: '/me',
auth: 'userAuth',
handler: async ({ c, respond }) => {
const ctx = getContext(c); // SlingshotContext for this app
const actor = getActor(c); // resolved user / m2m / anon
const tenantId = getRequestTenantId(c); // multi-tenant scope
return respond.json({ actor, tenantId });
},
});

c is the raw Hono context — every Hono API works. respond.json(...), respond.text(...), and respond.error(...) are typed helpers that pair with the route’s response schema.

// @skip-typecheck
import { z } from 'zod';
import { route } from '@lastshotlabs/slingshot';
route.post({
path: '/items',
summary: 'Create an item',
description: 'Longer description for OpenAPI docs',
tags: ['items'],
auth: 'userAuth', // 'userAuth' | 'none' | array of guards
request: { body: z.object({ name: z.string() }) },
responses: {
201: { description: 'Created', schema: z.object({ id: z.string() }) },
409: { description: 'Conflict', schema: z.object({ error: z.string() }) },
},
middleware: ['rateLimit'], // names from package middleware map
handler: async ({ body, respond }) => {
return respond.json({ id: '...' }, 201);
},
});

The same surface works for route.get, route.put, route.patch, route.delete. See Domain and Route for the full reference.