Headless Mode

In the default Dune setup, the framework owns the entire request pipeline: it resolves URLs to content files, picks a template, and renders the page. Themes control the look.

Headless mode flips this. You write the Fresh routes; Dune manages content, search, the admin panel, and the content API. Your site is a standard Fresh app that happens to use Dune as its data layer.

Use headless mode when:

  • You need a design system or component library that doesn't fit the theme model
  • You're building a hybrid app (CMS-driven pages alongside custom interactive routes)
  • You want full control over how content is fetched, transformed, and rendered

Quick start

dune new my-site --headless
cd my-site
deno task dev

--headless scaffolds a Fresh project with Dune wired as a content provider. No theme is created.


Project layout

my-site/
├── config/
│   ├── site.yaml       ← site metadata, plugins, taxonomies
│   └── system.yaml     ← content dir, cache, debug
├── content/            ← your Markdown / MDX / TSX pages
├── routes/             ← Fresh routes — you own these
│   ├── _layout.tsx
│   ├── index.tsx
│   └── blog/
│       ├── index.tsx
│       └── [slug].tsx
├── islands/            ← your Fresh islands
├── static/             ← static assets
├── main.ts             ← entry point
└── deno.json

Dune manages content/ and data/. Everything else is yours.


Entry point (main.ts)

import { App, staticFiles } from "fresh";
import { Builder } from "jsr:@fresh/core@^2/dev";
import { bootstrap } from "@dune/core";
import { mountDuneAdmin, getDuneAdminIslands } from "@dune/core/admin";

// 1. Bootstrap Dune — content index, search, admin, plugins
const ctx = await bootstrap("./");
const app = new App();

// 2. Static file serving
app.use(staticFiles());

// 3. Admin panel + public API (contact forms, webhooks)
await mountDuneAdmin(app, ctx);

// 4. Make the content API available to your own routes via ctx.state —
// no global state, works the same in tests and multi-site setups.
app.use((freshCtx) => {
  freshCtx.state.contentApi = ctx.contentApi;
  return freshCtx.next();
});

// 5. Your routes — Fresh discovers them from routes/ automatically
app.fsRoutes("./routes");

// 6. Bundle islands — yours + admin islands
const builder = new Builder({
  root: "./",
  islandDir: "./islands",
  islandSpecifiers: getDuneAdminIslands(),
});
const applySnapshot = await builder.build({ mode: "production", snapshot: "memory" });
applySnapshot(app);

// 7. Serve
Deno.serve({ port: 3000, handler: app.handler() });

mountDuneAdmin(app, ctx)

Registers on the Fresh app:

  • Admin panel routes under /admin (or your configured admin.path)
  • Per-site admin context middleware
  • Plugin public routes
  • Public API endpoints (/api/contact, /api/forms/:name, /api/webhook/incoming)

getDuneAdminIslands()

Returns absolute paths to all island .tsx files bundled with Dune's admin panel. Pass these to Builder({ islandSpecifiers }) so admin islands are included in the production JS bundle alongside your own islands.


Reading content in routes

ctx.contentApi (a ContentApi) is threaded into every request's ctx.state.contentApi by the middleware registered in main.ts above — read it from there in any Fresh route handler. There's no global/singleton to import.

pages(options?)

List pages with optional filtering and ordering:

// routes/blog/index.tsx
import type { FreshContext, PageProps } from "fresh";
import type { ContentApi } from "@dune/core/content";
import type { PageIndex } from "@dune/core";

export function handler(_req: Request, ctx: FreshContext) {
  const contentApi = ctx.state.contentApi as ContentApi;
  const posts = contentApi.pages({
    orderBy: "date",
    orderDir: "desc",
    limit: 20,
  });
  return ctx.render(posts);
}

export default function BlogIndex({ data }: PageProps<PageIndex[]>) {
  return (
    <ul>
      {data.map((p) => (
        <li key={p.route}>
          <a href={p.route}>{p.title}</a>
          {p.date && <time> — {p.date}</time>}
        </li>
      ))}
    </ul>
  );
}

pages() options:

Option Type Description
orderBy "date" | "title" | "route" Sort field
orderDir "asc" | "desc" Sort direction
limit number Maximum results
offset number Pagination offset

page(route)

Resolve a single page by route path. Returns a ResolvedPage with the full HTML, frontmatter, and summary, or null if not found.

// routes/blog/[slug].tsx
import type { FreshContext, PageProps } from "fresh";
import type { ContentApi, ResolvedPage } from "@dune/core/content";

export async function handler(req: Request, ctx: FreshContext) {
  const contentApi = ctx.state.contentApi as ContentApi;
  const page = await contentApi.page(`/blog/${ctx.params.slug}`);
  if (!page) return ctx.next();
  return ctx.render(page);
}

export default function Post({ data }: PageProps<ResolvedPage>) {
  return (
    <article>
      <h1>{data.title}</h1>
      {data.date && <time>{data.date}</time>}
      <div dangerouslySetInnerHTML={{ __html: data.html }} />
    </article>
  );
}

ResolvedPage shape:

Field Type Description
route string URL path (e.g. /blog/hello-world)
title string Page title
date string | null Publication date
html string Rendered HTML body
summary string First paragraph or description frontmatter
frontmatter FM Full typed frontmatter (generic — pass your type as page<MyFM>())

search(query, limit?)

Full-text search across all indexed pages. Returns synchronously.

const results = contentApi.search("deno deploy", 10);
// results: Array<{ route, title, score, excerpt }>

taxonomy(name)

Get all values for a taxonomy (e.g. tags, categories) with their page counts.

const tags = contentApi.taxonomy("tag");
// tags: Array<{ name, slug, count }>

Typed frontmatter

Pass a frontmatter type to page<FM>() and pages<FM>() for end-to-end type safety:

interface PostFM {
  title: string;
  date: string;
  tags?: string[];
  hero?: string;
}

const post = await contentApi.page<PostFM>(`/blog/${slug}`);
// post.frontmatter.tags is string[] | undefined

Dev mode

The scaffold's deno task dev uses --watch on main.ts. Fresh rebuilds island bundles on change; Dune rebuilds the content index when Markdown files change.

For a more responsive dev loop, bootstrap() accepts a dev: true option that disables the page cache and enables live-reload SSE — but in headless mode you manage the server yourself, so you can wire this up as needed.


Admin panel

The admin panel is available at /admin (or your configured admin.path) automatically via mountDuneAdmin(). On first run, a default admin account is created and the password written to .dune/admin/admin-password.txt. Read the file, then delete it and change the password in the admin UI.

The admin panel is fully functional in headless mode: create and edit pages, manage users, view metrics, configure plugins.


Limitations

  • No /* catch-all — unlike full Dune mode, there is no automatic content routing. Every public URL must be handled by one of your Fresh routes or it will 404.
  • Static file serving — theme static files and /static/* are not automatically mounted. Add app.use(staticFiles()) and serve your own static/ directory.