Skip to content

Routing & Controllers

Routes in Hypersonic are standard Express handlers registered on app.express.

Registering routes

ts
// server.ts
const app = await createApp({ config, env, prisma })

app.express.get('/', (_req, res) => {
  res.inertia!('Welcome', { message: 'Hello!' })
})

All routes must be registered after createApp and before app.start().

Rendering Inertia pages

res.inertia!(component, props) renders a React page component and passes props to it. The component name maps to a file under resources/js/Pages/:

CallFile resolved
res.inertia!('Welcome', {})resources/js/Pages/Welcome.tsx
res.inertia!('Auth/Login', {})resources/js/Pages/Auth/Login.tsx
res.inertia!('Posts/Show', { post })resources/js/Pages/Posts/Show.tsx

Props are serialised to JSON, so they must be plain objects. Type them with an interface on the React side — see Frontend.

Protecting routes

createAuthGuard returns a middleware that checks for a valid Better Auth session. Unauthenticated requests are redirected to /login.

ts
// src/middleware.ts (generated by hypersonic new)
import { createAuthGuard } from './src/middleware.ts'

const requireAuth = createAuthGuard(app.auth)

app.express.get('/dashboard', requireAuth, (req, res) => {
  res.inertia!('Dashboard', { user: req.sessionUser })
})

On a successful session check, the middleware attaches the session user to req.sessionUser.

req.sessionUser

req.sessionUser is typed as SessionUser and is only present on routes protected by createAuthGuard:

ts
app.express.get('/profile', requireAuth, (req, res) => {
  const { id, name, email } = req.sessionUser!
  res.inertia!('Profile', { id, name, email })
})

Error classes

Import these from @hypersonic-js/complete and throw them from any route handler. The Inertia error handler will redirect back with an error state; non-Inertia requests receive a JSON response.

ClassStatus
NotFoundError404
UnauthorizedError401
ForbiddenError403
ValidationError422
HttpError(status, message)custom
ts
import { NotFoundError, ForbiddenError } from '@hypersonic-js/complete'

app.express.get('/posts/:id', requireAuth, async (req, res, next) => {
  try {
    const post = await prisma.post.findUnique({ where: { id: Number(req.params.id) } })
    if (!post) return next(new NotFoundError('Post not found'))
    if (post.userId !== req.sessionUser!.id) return next(new ForbiddenError())
    res.inertia!('Posts/Show', { post })
  } catch (err) {
    next(err)
  }
})

Always wrap async handlers in try/catch and pass errors to next.

Error handler

Mount createInertiaErrorHandler() after all routes. It intercepts HttpError instances and, for Inertia requests, redirects back instead of breaking the page.

ts
import { createInertiaErrorHandler } from '@hypersonic-js/complete'

// ... all routes above ...

app.express.use(createInertiaErrorHandler())

await app.start()

Organising routes

For anything beyond a handful of routes, extract them into src/routes.ts:

ts
// src/routes.ts
import type { Application } from 'express'
import { NotFoundError } from '@hypersonic-js/complete'
import { createAuthGuard } from './middleware.ts'
import type { AuthLike } from './types.ts'

export function registerRoutes(app: Application, prisma: PrismaClient, auth: AuthLike): void {
  const requireAuth = createAuthGuard(auth)

  app.get('/', (_req, res) => {
    res.inertia!('Welcome', {})
  })

  app.get('/dashboard', requireAuth, (req, res) => {
    res.inertia!('Dashboard', { user: req.sessionUser })
  })
}
ts
// server.ts
import { registerRoutes } from './src/routes.ts'

const app = await createApp({ config, env, prisma })
registerRoutes(app.express, prisma, app.auth)
await app.start()

Released under the MIT License.