examples / sites

Multi-page site

Several pages routed from one file, all sharing a single layout.

denohtmlrouting

A service can be more than one file. Here main.ts is a small router that maps each pathname to a page, and layout.ts holds the shared shell (header, navigation, footer) that wraps every page. Relative imports like ./layout.ts work exactly as you would expect.

The three pages are plain HTML defined in main.ts, and the stylesheet is served from public/ at /style.css. Add a page by adding one entry to the PAGES map. Unknown paths return a 404 that still uses the shared layout, so even the error page stays on brand.

code
main.ts
// A small multi-page website. main.ts is the router: it maps the request
// pathname to a page and wraps every page in one shared layout (layout.ts).
// The content for each page is defined once, here, as plain HTML. The
// stylesheet lives in public/ and is served for you at /style.css.

import { layout } from "./layout.ts";

const PAGES: Record<string, { title: string; body: string }> = {
  "/": {
    title: "Home",
    body: `      <h1>We build friendly robots</h1>
      <p>Acme Robotics designs machines that help people at home and at work.
      This entire site is one Frontback service: a few files, no build step.</p>`,
  },
  "/about": {
    title: "About",
    body: `      <h1>About us</h1>
      <p>Founded in a garage, we now ship robots to teams across Europe. Our
      goal is simple: automation you can actually trust.</p>`,
  },
  "/projects": {
    title: "Projects",
    body: `      <h1>Projects</h1>
      <ul>
        <li><strong>Sweeper</strong> - a tidy little floor robot.</li>
        <li><strong>Gardener</strong> - waters your plants on a schedule.</li>
        <li><strong>Courier</strong> - carries parcels down the hall.</li>
      </ul>`,
  },
};

export default async (req: Request): Promise<Response> => {
  const { pathname } = new URL(req.url);
  const page = PAGES[pathname];

  if (!page) {
    const body = `      <h1>Page not found</h1>
      <p><a href="/">Back to home</a></p>`;
    return htmlResponse(layout("Not found", body), 404);
  }

  return htmlResponse(layout(page.title, page.body));
};

function htmlResponse(html: string, status = 200): Response {
  return new Response(html, {
    status,
    headers: { "content-type": "text/html; charset=utf-8" },
  });
}
layout.ts
// The shared page shell. Every page passes its title and its inner HTML; the
// layout returns a full document with the same header, navigation and footer.
// Keeping it in one place means the nav and chrome stay consistent across
// pages. A service can be more than one file: main.ts imports this with a normal
// relative import.

const NAV = [
  { href: "/", label: "Home" },
  { href: "/about", label: "About" },
  { href: "/projects", label: "Projects" },
];

export function layout(title: string, body: string): string {
  const nav = NAV.map(
    (item) => `<a href="${item.href}">${item.label}</a>`,
  ).join("\n        ");

  return `<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>${title} - Acme Robotics</title>
    <link rel="stylesheet" href="/style.css" />
  </head>
  <body>
    <header>
      <a class="brand" href="/">Acme Robotics</a>
      <nav>
        ${nav}
      </nav>
    </header>
    <main>
${body}
    </main>
    <footer>A multi-page site running as one Frontback service.</footer>
  </body>
</html>
`;
}
public/style.css
:root {
  color-scheme: light dark;
  --fg: #16181d;
  --muted: #5c636e;
  --bg: #ffffff;
  --line: #e6e8ec;
  --accent: #2f6feb;
}

@media (prefers-color-scheme: dark) {
  :root {
    --fg: #e9ebf0;
    --muted: #9aa1ad;
    --bg: #101216;
    --line: #262a31;
    --accent: #6ea0ff;
  }
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
}

header {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  max-width: 46rem;
  margin: 0 auto;
  padding: 1.5rem;
  border-bottom: 1px solid var(--line);
}

.brand {
  font-weight: 700;
  font-size: 1.1rem;
  color: var(--fg);
  text-decoration: none;
}

header nav {
  display: flex;
  gap: 1.25rem;
}

header nav a {
  color: var(--muted);
  text-decoration: none;
}

header nav a:hover {
  color: var(--accent);
}

main {
  max-width: 46rem;
  margin: 0 auto;
  padding: 2rem 1.5rem 3rem;
}

h1 {
  letter-spacing: -0.02em;
}

ul {
  padding-left: 1.2rem;
}

li {
  margin: 0.4rem 0;
}

footer {
  max-width: 46rem;
  margin: 0 auto;
  padding: 1.5rem;
  border-top: 1px solid var(--line);
  color: var(--muted);
  font-size: 0.85rem;
}

Go beyond what seems possible.