Pages and routing
Every file in app/pages is a page, and its path is its URL:
| File | URL |
|---|---|
app/pages/index.html | / |
app/pages/about.html | /about/ |
app/pages/blog/index.html | /blog/ |
app/pages/blog/first-post.html | /blog/first-post/ |
app/pages/blog/[slug].html | /blog/anything/ (one page per value) |
app/pages/docs/[...path].html | /docs/a/ and /docs/a/b/ (any depth) |
app/pages/docs/(guides)/intro.html | /docs/intro/ ((guides) organizes the files without adding a URL segment) |
A few rules cover the rest:
index.htmlis its folder's page.- URLs end with a slash. The development server,
preview, andapplication.fetchredirect/aboutto/about/. A static host decides for itself, so check its trailing-slash setting. - Files and folders starting with
_or.are not pages, so keep helpers there or inapp/components. - Pages and page folders are regular files and folders, as
public/files are: a symbolic link to one is an error that names it. To serve pages kept elsewhere, add their folder to thepagesoption. - A number and a dot at the start of a name, as in
01.guide/, orders navigation and never appears in the URL.
A page
A page is an HTML Next component. Its <title> and <meta> tags go in its <defs>, and HTML Next
adds them to the document head (The page head):
<!-- app/pages/about.html -->
<template component="page-about">
<defs>
<title>About us</title>
<meta name="description" content="Who we are.">
</defs>
<main>
<h1>About us</h1>
</main>
</template>Component names must be unique across the site. A page- prefix keeps them apart from your other
components.
If a file declares more than one component, say which one is the page:
<meta name="mk:page" content="page-about">
<template component="team-card">…</template>
<template component="page-about">…</template>Dynamic pages
A name in brackets matches any single URL segment. Its value reaches the page through a loader, which also lists the pages to build:
app/pages/blog/[slug].html /blog/first-post/, /blog/second-post/, …
app/pages/blog/[slug].server.ts loads each post and lists the slugs// app/pages/blog/[slug].server.ts
export const entries = () => [{ slug: 'first-post' }, { slug: 'second-post' }];
export function load({ params }) {
return { props: { title: `Post ${params.slug}` } };
}When a literal page and a dynamic one could both match, the literal page wins.
Catch-all pages
[...name] matches one or more segments, and [[...name]] matches any number, including none. The
parameter is the list of segments:
| File | Matches | params |
|---|---|---|
app/pages/docs/[...path].html | /docs/a/, /docs/a/b/ | { path: ['a'] }, { path: ['a', 'b'] } |
app/pages/files/[[...path]].html | /files/, /files/a/b/ | { path: [] }, { path: ['a', 'b'] } |
A catch-all is the last part of its route, so it is a file name or a folder holding only an
index.html. Its entries() list each page as a list of segments:
// app/pages/docs/[...path].server.ts
export const entries = () => [{ path: ['guide', 'intro'] }, { path: ['api'] }];
export function load({ params }) {
return { props: { title: params.path.join(' / ') } };
}When several pages could match a URL, MarkupKit compares their segments from the left. At the first
segment where they differ, a literal name wins over [name], which wins over [...name], which
wins over [[...name]]. So with docs/index.html, docs/[id].html, and docs/[...path].html,
/docs/ is the index, /docs/a/ is [id], and /docs/a/b/ is [...path]. A build reports an
entry whose URL a more specific page matches.
Route groups
Wrap a folder's name in parentheses, such as (marketing), to organize pages without including
that folder name in their URLs. MarkupKit calls this a route group:
| File | URL |
|---|---|
app/pages/marketing/about.html | /marketing/about/ |
app/pages/(marketing)/about.html | /about/ |
app/pages/docs/(guides)/intro.html | /docs/intro/ |
Use a route group when pages should share a layout or appear under one navigation heading
without adding the group's name to their addresses. In the last example, docs is still part of
the URL; only (guides) is left out.
The text inside the parentheses labels the group in navigation. You can add a number and a dot before the opening parenthesis to order the group among its neighbours. For example, these pages appear under “Getting started” and “Reference” headings:
app/pages/docs/
index.html /docs/
01.(Getting started)/
_layout.html wraps intro and setup
01.intro.html /docs/intro/
02.setup.html /docs/setup/
02.(Reference)/
api.html /docs/api/A group is a folder like any other: its _layout.html and _layout.server.ts wrap its pages, and
it can hold further folders and groups. Two pages in different groups that produce the same URL
are an error that names both files.
Layouts
A layout wraps pages. app/layouts/default.html wraps every page automatically; the page appears
where the layout puts <slot name="page">:
<!-- app/layouts/default.html -->
<template component="site-shell">
<defs><title>My site</title></defs>
<header>My site</header>
<main><slot name="page"></slot></main>
</template>To use another layout for one page, name it; content="none" uses no layout:
<template component="page-dashboard">
<defs><meta name="mk:layout" content="admin"></defs> <!-- app/layouts/admin.html -->
<h1>Dashboard</h1>
</template>A layout name is an entry of the site's layouts option, a file in app/layouts/, or a plugin's
layout, in that order (Configuration). A name that matches
none of them is an error that names the page and the file it expected.
To give a whole folder a layout, add a _layout.html to it (below), or see
layoutDefaults in Configuration.
Nested layouts
A _layout.html in a folder of app/pages wraps every page in that folder and in the folders below it.
Each folder's layout sits inside its parent folder's layout, and the site's layout is outermost:
app/layouts/default.html site-shell
app/pages/docs/_layout.html docs-shell, inside site-shell
app/pages/docs/guide/_layout.html guide-shell, inside docs-shell
app/pages/docs/guide/intro.html /docs/guide/intro/, inside guide-shellA folder layout is a layout like any other: one component with a <slot name="page">. The _ keeps
it from being a page, so /docs/_layout/ is not a URL. A _layout.server.ts beside it is its
loader. Loaders run from the outside in, so intro.html's loader
receives the data of default, docs, and guide as parent.
The layout a page selects only replaces the outermost one, and folder layouts always nest inside it:
| The page's layout | Layers for /docs/guide/intro/ |
|---|---|
None selected, with app/layouts/default.html | default → docs → guide → page |
None selected, without default.html | docs → guide → page |
mk:layout="admin", or admin from a pages entry's layout, layoutDefaults, or the layout option | admin → docs → guide → page |
none or false, from any of these | page alone |
The outermost layout is chosen in this order: mk:layout, then the pages entry's layout, then
layoutDefaults, then the layout option, then app/layouts/default.html. Pages added through the
routes option are outside the page tree, so they take only their own layouts.
During client navigation, the layouts two pages share stay in place, with their state and module instances,
and take the new page's loader props. Going from /docs/guide/intro/ to /docs/guide/setup/ keeps
all three layouts; going to /blog/ keeps only default.
The not-found page
app/pages/404.html is the page for any URL that matches no page. It renders through its layout like
any other page, with status 404, in development, preview, markupkit start, and application.fetch. npm run build
writes it to 404.html. It is not a route: /404/ is not one of the site's pages, and navigation
doesn't list it. Its loader receives the requested URL, except in the built 404.html, which a static
host serves for every missing path. Without the file, MarkupKit serves a plain "Page not found" page.
The same holds for a 404 page at the top of any page folder
served at /, which renders through that folder's layout. A folder served at another prefix, such
as /v/one/, serves its 404 page at no URL, and the site's not-found page answers its missing
paths.
<template component="page-not-found">
<defs><title>Page not found</title></defs>
<h1>This page doesn't exist</h1>
<p><a href="/">Go to the home page</a></p>
</template>The error page
app/error.html is the page for a request that fails: a loader, an endpoint, or
middleware throws, or the page doesn't
render. It renders through its layout with status 500, and receives the status and message props
when it declares them. message is the status's reason phrase, Internal Server Error. The error
itself goes to the server's console, so its message and stack stay out of the page.
<!-- app/error.html -->
<template component="page-error">
<defs>
<prop name="status" type="number" required>Status</prop>
<prop name="message" type="string" required>Reason</prop>
<title>Something went wrong</title>
</defs>
<main>
<h1>{$status}: {$message}</h1>
<p><a href="/">Go to the home page</a></p>
</main>
</template>| Where | When a page fails |
|---|---|
application.fetch | It answers 500 with app/error.html. The page's payload answers 500, so client navigation loads the error page's document. |
markupkit dev | It answers 500 with the error and its stack, to fix in development. |
markupkit build | The build fails with the error, and the previous dist/ stays. A static build has no error page, because each of its pages rendered. |
markupkit preview | It serves the built pages, which rendered during the build. |
markupkit start | It answers 500 with app/error.html, which the build includes in the server when pages render on request or there are endpoints or middleware. |
Like the not-found page, it is not a route: no URL serves it, and navigation doesn't list it. It
takes its layout as a page does, from mk:layout, the layout option, or default.html, and it
has no loader of its own. When the layout's loader is what fails, or when there is no
app/error.html, MarkupKit answers with a plain "Internal Server Error" page.
Redirects
The redirects option sends old URLs to their new place. Each key is an application path, and each
value is an application path or an http(s) URL. Application paths get the site's base path:
// markupkit.config.ts
export default defineConfig({
redirects: {
'/getting-started/': '/docs/intro/',
'/chat/': 'https://example.com/community/',
},
});The development server, markupkit start, and application.fetch answer /getting-started/ and /getting-started
with a 308 and a Location header. A static build writes a page at each old URL, for hosts such as
GitHub Pages, which serve files as they are. The page refreshes to the new URL at once, names it as
canonical, and links to it for readers who stay on the page:
<meta http-equiv="refresh" content="0; url=/docs/intro/">
<link rel="canonical" href="/docs/intro/">
…
<p>This page is now at <a href="/docs/intro/">/docs/intro/</a>.</p>A page can also declare the old URLs that lead to it, with mk:redirect-from in its <defs>, once
per URL:
<template component="install-page"><defs>
<title>Installation</title>
<meta name="mk:redirect-from" content="/getting-started/">
<meta name="mk:redirect-from" content="/setup/">
</defs>…</template>Each path is relative to the page's page folder, as an mk:alias path is: in a folder served at
/docs/, content="/setup/" redirects /docs/setup/ to the page. A page in an
alias page folder redirects under that folder's prefix too,
to its page there. These redirects answer and build exactly as the option's do, with a 308 and a
redirect page, and the development server reads them again after each edit. The page they lead to
has no parameters.
A redirect from a page's own URL is an error that names the page. So is an mk:redirect-from path
that is a page's URL or mk:alias, a key of the redirects option, or another page's
mk:redirect-from path. The build lists every redirect in
the manifest's
redirects. To redirect from a loader, for example after
checking data, use redirect().
The page head
A page or layout declares what it adds to the document head in its <defs>, as every HTML Next
component does with head declarations:
a <title>, <meta> entries, <link> resource hints and metadata links, JSON-LD blocks, and the
external scripts it needs. They read the component's props, state, and data:
<template component="page-post">
<defs>
<prop name="title" type="string" required>Post title</prop>
<prop name="summary" type="string" required>Post summary</prop>
<prop name="schema" type="object" required>Structured data</prop>
<title>{$title} · My blog</title>
<meta name="description" from:content="$summary">
<meta property="og:title" from:content="$title">
<script type="application/ld+json" from:value="$schema"></script>
</defs>
<article><h1>{$title}</h1></article>
</template>- The page wins. A
<title>, a canonical link, and each<meta>name or property come from the deepest component that declares them, so a page's replace its layout's. Any component on the page, such as a product card, may declare head content too. - Everything else is kept. Other links, such as a preconnect, and JSON-LD blocks stay while any component on the page declares them.
- Stylesheets are styles. A page's stylesheet is an
@importin its<style>, such as<style>@import "./page.css";</style>.<link rel="stylesheet">is not a head declaration. - Head content goes in
<defs>. A<title>,<meta>, or<link>placed directly in a<template component>is an error that names the component. - Packages don't set the page's identity. A component reached by a bare name, such as
@acme/ui/card.htmlor@/app/components/card.html, is an imported component. Its page-wide entries, which are the canonical,alternate,icon, andmanifestlinks, therobots,referrer,viewport,color-scheme, andtheme-colormetas, andog:url, are left out with anHL011warning. Pages, layouts, and components linked by a relative path set them. - Navigation changes the head once. When client navigation replaces a page, HTML Next updates the head in one step, so no layout title shows between two page titles.
Every document starts from <meta charset="utf-8"> and <meta name="viewport" content="width=device-width, initial-scale=1">, which a page's own viewport meta replaces. A
loader sets the document's language and optional native direction with head: { lang, dir }, where dir is "ltr", "rtl" or "auto". A page can override its layout's direction. When no loader sets a direction, the document uses the browser default, and navigation to it removes the preceding page's direction. A loader's other values reach the head
as props.
Declared scripts
A component that needs a third-party script, such as a payment form that needs Stripe.js, declares it:
<defs>
<link rel="preconnect" href="https://js.stripe.com">
<script name="stripe" src="https://js.stripe.com/v3/" loading="lazy"></script>
</defs>HTML Next inserts one <script> for each URL, once per document, when a component that declares
it connects (eager, the default), comes near the viewport (lazy), or calls load() from its
module (manual). A server-rendered or prerendered page carries a <link rel="preload"> for
each eager script, and client navigation never runs a script twice. A declared script's src is an
absolute URL, a root-relative path to a file in public/, or a bare name the application's import
map resolves. $stripe.pending, $stripe.ok, and $stripe.error read its status, and the
connection hook waits for host.scripts.stripe.ready
(declared scripts).
Settings that start with mk: configure MarkupKit. They are <meta> entries in the component's
<defs>, and MarkupKit leaves them out of the document head:
| Metadata | Does |
|---|---|
mk:page | Chooses the page component in a file with several components. It describes the file, so it goes outside the components. |
mk:layout | Chooses a layout, or none. |
mk:label | Sets the page's label in navigation. |
mk:order | Sets the page's place in navigation, in place of its file name's number. |
mk:navigation | content="hidden" keeps the page out of navigation. |
mk:alias | Adds another URL for the page, such as content="/start/". Its page has a canonical link to the page's own URL, unless the page sets one, and sitemap.xml lists only the page's own URL. |
mk:redirect-from | Redirects an old URL to the page, such as content="/getting-started/" (details). |
mk:render | content="server" renders the page on each request instead of at build time (details). |
A script before first paint
app/head.js runs in every page's head before anything is drawn, which is how a site applies a
saved dark theme without a flash. Keep it small, because it delays the first paint:
// app/head.js
document.documentElement.dataset.theme = localStorage.getItem('theme') ?? 'light';It cannot contain <!--, <script, or </script. If the site has a Content Security Policy,
allow it with a 'sha256-…' hash of its text.
Details
- Two files that produce the same URL are an error that names both files.
app/apiholds endpoints, at paths without a trailing slash, andapp/middleware.server.tsis middleware.- Literal URL segments take priority over parameters, and parameters over catch-alls.
- Pages, layouts, and components link other components with
<link rel="component" href="…">.@/is the project root, sohref="@/app/components/card.html"works from anywhere. - Outside MarkupKit, the regular HTML Next loader ignores page metadata, so the same component files still work there.