Platform map
A Hype Stack app already runs auth, permissions, feature flags, realtime, background jobs, a task queue, file storage, and email. This page is the list of those pieces, what you import to use each one in your own feature, and where it gets registered. Each section links to the page with the full details.
Everything here works the same in plain app code and inside a pack. The only difference is who does the registering: you edit the registry file by hand, or a pack declares a manifest field and the CLI edits it for you.
At a glance
| Mechanism | Use it for | In your code | Pack manifest field |
|---|---|---|---|
| Request context | Who is calling, which org, what they may do | ctx.var.user, ctx.var.organizationId, ctx.var.ability | context |
| Routes | An authenticated API under a prefix | getAuthenticatedApp() in routes/<domain>/index.ts | routes |
| Permissions | Who may read, write, or manage a thing | hasAccess({ action, subject }), useUser().hasPermission | permissions |
| Feature flags | Turning a feature on per organization | withFeature(key), isFeatureEnabled, useFeatureFlag(key) | none, edit catalog |
| Realtime | Pushing an update to a user or a whole org | wsEmit.toUser, wsEmit.toOrganization | sockets |
| Scheduled jobs | Work on a clock: reminders, sweeps, digests | a JobDefinition in src/jobs/index.ts | jobs |
| Task queue | Slow or retryable work after a request returns | defineQueue, enqueue(queue, data, { trx }) | queues |
| File storage | Uploads: avatars, logos, attachments | uploadFile, getFileUrl, deleteFile | none |
| Transactional mail | resend.emails.send(...) | none | |
| Cache | Short-lived shared state, rate limits | valkey from @backend/context | none |
| App shell slots | Showing up in nav, menus, settings, admin | entries in the constants/* files | nav, *Actions |
| Shared enums | Types and constants both sides import | @<your-app>/enums | enums |
How one request uses them
A typical write in a feature
withAuthUser
Loads the session and sets user, organizationId, and ability on the context.
hasAccess
Stops the request with a 403 when the role cannot do this action on this subject.
Feature module
Runs the business logic and scopes every query by organizationId.
Side effects
Enqueues slow work in the same transaction, pushes a socket event, sends an email.
Request context
Every route built on getAuthenticatedApp() runs withAuthUser first. After it, ctx.var holds:
| Variable | What it is |
|---|---|
user | The signed-in user |
organizationId | The active organization. Treat it as the tenant id for every query |
ability | A CASL ability built from the member's role. See Permissions |
sessionId | The current session record |
Shared clients live in apps/backend/src/context.ts: postgres (Kysely on postgres.qb), valkey, and storage.
Import them directly. A starter or pack adds its own client the same way (auth, workos, stripe), which is what the
context manifest field does.
Never take an organization id from the request body or query string. Read it from ctx.var.organizationId, which the
middleware already checked against the user's membership.
Routes
A feature's routes are one Hono app per domain. Build it on getAuthenticatedApp() so the auth middleware runs, and
keep the logic in features/<domain>/modules/:
// apps/backend/src/routes/invoices/index.ts
import { listInvoices } from "@backend/features/invoices/modules/list-invoices";
import { hasAccess } from "@backend/middleware/permissions/has-access";
import { getAuthenticatedApp } from "@backend/utils/auth/auth-hono";
export const invoiceRoutes = getAuthenticatedApp().get(
"/",
hasAccess({ action: "read", subject: "invoice" }),
async (ctx) => {
const { organizationId } = ctx.var;
return ctx.json(await listInvoices({ organizationId }));
},
);Then chain .route("/invoices", invoiceRoutes) onto the app in registerRoutes (apps/backend/src/routes/index.ts)
and run pnpm generate. The frontend SDK picks the new endpoints up from the exported route types.
- Folder layout: Organize features
- Route conventions and validation: Routing
- Calling it from React: HTTP
Permissions
Roles map to permission strings like read:organization or manage:project. The backend checks them with hasAccess,
the frontend with useUser().hasPermission. A new feature adds its own subject and grants it to roles.
That is its own page: Access control.
Feature flags
SaaS starters ship per-organization flags. A flag is an entry in packages/enums/src/feature-flags/catalog.ts, and the
admin app gets a Flags tab to switch it per org.
// backend: gate a whole route
app.get("/reports", withFeature("beta-reports"), handler);
// backend: branch inside a module
const enabled = await isFeatureEnabled({ organizationId, key: "beta-reports" });// frontend, mobile, extension
const enabled = useFeatureFlag("beta-reports");
<FeatureGate flag="beta-reports">
<ReportsLink />
</FeatureGate>;A flag answers "does this org have the feature". A permission answers "may this person use it". Most gated features need both. Details: Organizations, feature flags.
Realtime
The backend pushes typed events over one WebSocket connection per client. Declare the event with .emitter("paid") in
src/sockets/invoices/index.ts, register that router in src/sockets/index.ts, and run pnpm generate. Then push from
features/invoices/websockets/emitters.ts, after the database write commits:
import { wsEmit } from "@backend/sockets/emit";
wsEmit.toUser({ userId, topic: "invoice/paid", data: invoice });
wsEmit.toOrganization({ organizationId, topic: "invoice/paid", data: invoice });toOrganization reaches every connected member of that org. There are no per-team or per-project rooms, so send a
teamId or projectId in the payload and let the client filter, or collect the member ids and use toUsers.
Full walkthrough: WebSockets and frontend WebSockets.
Scheduled jobs
Cron work runs in the backend process. Each fire runs once across all instances (a Postgres advisory lock guards it), so two replicas never send the same reminder twice.
// apps/backend/src/jobs/invoice-overdue.job.ts
import type { JobDefinition } from "@backend/libs/scheduler/scheduler";
export const invoiceOverdueJob: JobDefinition = {
name: "invoice-overdue",
cron: "0 * * * *",
run: async () => {
await markOverdueInvoices();
},
};Add it to the jobs array in apps/backend/src/jobs/index.ts. Write jobs as sweepers: find the rows that are due and
handle them. The schedule of a business event (when an invoice is due) belongs in your tables, not in a cron pattern.
catchUp: true runs a missed fire once on boot. Leave it off for sweepers and turn it on when the fire time itself is
the event, like a nightly digest.
Task queue
For work that is slow, calls a flaky API, or must retry, enqueue a task instead of doing it in the request. The queue runs on Postgres (pg-boss), so there is no extra service to deploy.
// apps/backend/src/queues/invoice-pdf.queue.ts
import { z } from "zod";
import { defineQueue } from "@backend/libs/queue/queue";
export const invoicePdfQueue = defineQueue({
name: "invoice-pdf",
schema: z.object({ invoiceId: z.string() }),
exclusive: true,
run: async ({ data, signal }) => {
await renderInvoicePdf(data.invoiceId, { signal });
},
});// inside the module that creates the invoice
await enqueue(invoicePdfQueue, { invoiceId }, { trx, key: invoiceId });Register the queue in apps/backend/src/queues/index.ts. Passing trx means the task only exists if the transaction
commits. exclusive plus key makes a double click a no-op. Handlers can run twice after a crash, so make them safe to
repeat. Defaults: 3 retries with backoff, 15 minute timeout. Needs template v1.7.0 or newer.
File storage
S3-compatible storage (RustFS locally) through three helpers in @backend/libs/storage/storage:
await uploadFile({ bucket: "invoice-attachments", key, body, contentType });
const url = await getFileUrl({ bucket: "invoice-attachments", key });
await deleteFile({ bucket: "invoice-attachments", key });Buckets are created on first upload. Put the organization id in the key (${organizationId}/${invoiceId}.pdf) so files
stay grouped by tenant. Upload middleware and env: Storage.
Resend plus React Email templates that live next to the feature that sends them. Call resend.emails.send(...) from the
module. Send after the database write commits, and from a queue task when the send is not the point of the request.
Details: Mailing.
Cache
Valkey (Redis compatible) is on valkey from @backend/context. Use it for short-lived shared state and counters.
Never store the only copy of anything there: when Valkey is down, reads should fall back to Postgres or skip, not fail
the request.
App shell slots
Your feature appears in the app by adding an entry to a constants file. The layout reads those files, so a feature never edits the layout itself.
| Where it shows | File in your app | Pack field |
|---|---|---|
| Sidebar or top nav | frontend/src/constants/nav.constants.ts | nav |
| Icon in the navbar | frontend/src/constants/navbar-actions.tsx | navbarActions |
| Avatar dropdown item | frontend/src/constants/navbar-actions.tsx | userMenuActions |
| Block above sidebar navigation | frontend/src/constants/navbar-actions.tsx | sidebarActions |
| Tab on the settings page | frontend/src/constants/settings-tabs.constants.tsx | settingsTabs |
| Step in organization onboarding | setup-organization/steps/index.ts | onboardingSteps |
| Provider mounted at the app root | components/providers/providers.tsx | providers |
| Mobile drawer row | mobile constants/navbar-actions.tsx | drawerActions |
| Admin dashboard row | admin dashboard.rows.tsx | dashboardRows |
| Admin organization tab, card, or row | admin organization-details.*.tsx | orgDetails* |
A nav entry can carry a permission: { action, subject }. The nav filters it out for anyone without that permission,
which is how Billing stays hidden from non-admins. Hiding a link is only cosmetic, so the route still needs hasAccess.
Shared enums
Anything both the backend and a client need (permission subjects, statuses, flag keys) goes in the shared enums package and gets imported from both sides. That keeps a renamed status from compiling on one side and breaking on the other. See End-to-end typesafety.
Building it as a pack
Everything above is plain app code. To ship the same feature as an installable pack, the registry edits become manifest fields and the CLI applies them on install. The field list and what each one patches: Manifest reference.