GitHub

Routing

Every route in Pulse is explicitly declared in the spec. There is no file-based routing, no magic directory conventions, and no implicit mapping. Every page's URL is visible in its spec file and nowhere else.

The route field

Every spec has a route field. This is the URL pattern the spec handles:

export default {
  route: '/about',
  view: () => `<h1>About</h1>`,
}

Pulse matches the exact path. By default, trailing slashes are removed — /about/ redirects to /about with a 301. This is controlled by the trailingSlash option in createServer:

ValueBehaviour
"remove" (default)Redirects /about//about (301)
"add"Redirects /about/about/ (301)
"allow"Serves both — no redirect
createServer(specs, {
  trailingSlash: 'add', // enforce trailing slashes
})

Dynamic segments

Use a colon prefix for dynamic path segments. Named segments are captured and available in ctx.params in server data:

export default {
  route: '/products/:id',
  state: { quantity: 1 },
  server: {
    data: async (ctx) => {
      // ctx.params.id is the captured segment
      const product = await db.products.find(ctx.params.id)
      return { product }
    },
  },
  view: (state, server) => `<h1>${server.product.name}</h1>`,
}

Multiple dynamic segments

Any number of dynamic segments can appear in a route:

route: '/blog/:year/:month/:slug'
// Matches: /blog/2025/03/my-first-post
// ctx.params = { year: '2025', month: '03', slug: 'my-first-post' }

Registering routes

Specs are registered explicitly by passing them to createServer as an array. Routes are matched in order — more specific routes must come before more general ones:

import { createServer } from '@invisibleloop/pulse'
import home     from './src/pages/home.js'
import products from './src/pages/products.js'
import product  from './src/pages/product.js'   // more specific — comes first
import blog     from './src/pages/blog.js'

createServer([home, product, products, blog], { port: 3000 })

Query strings

Query string parameters are not part of the route pattern but are accessible via ctx.query in server data:

// URL: /products?category=shoes&sort=price
server: {
  data: async (ctx) => {
    const { category, sort } = ctx.query
    return { products: await db.products.list({ category, sort }) }
  },
}

Redirects

Migrating an existing site? Preserve the old URLs with the redirects map — legacy links and search rankings survive the move:

await createServer(pages, {
  redirects: {
    '/old-blog/:slug': '/blog/:slug',                 // 301 — :params carry over
    '/pricing-2024':   '/pricing',                    // 301 (default)
    '/promo':          { to: '/sale', status: 302 },  // temporary redirect
    '/moved/:slug':    'https://other.com/:slug',     // absolute targets for domain moves
  },
})

Redirects respond 301 by default (permanent — search engines transfer ranking to the target). Use { to, status } for 302, 307, or 308. The query string is preserved, redirects apply to GET/HEAD only, and the map is validated at startup — a bad entry fails the boot rather than silently misrouting traffic.

Redirects are checked before route matching, so a redirect source that equals a registered route shadows the page — the server logs a startup warning when that happens. Occasionally intentional (retiring a page), usually a mistake.

404 handling

If no spec matches the incoming request path, Pulse returns a 404. To customise it, create a spec with route: '*' — it renders through the normal pipeline (layout, styles, components) with status 404:

src/pages/not-found.js
export default {
  route: '*',
  meta:  { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
  view:  () => `<main id="main-content"><h1>Page not found</h1><p><a href="/">Back home</a></p></main>`,
}

See Error Pages for the full behaviour, including 500 handling.

File naming conventions

While Pulse does not auto-discover files, the recommended convention maps file names to routes:

FileRoute
src/pages/home.js/
src/pages/about.js/about
src/pages/products.js/products
src/pages/product.js/products/:id
src/pages/blog-post.js/blog/:slug
The filename does not need to match the route exactly — it is just a helpful convention. A file named product.js can handle /products/:id without any issue.