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. Your routes — Fresh discovers them from routes/ automatically
app.fsRoutes("./routes");
// 5. 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);
// 6. Serve
Deno.serve({ port: 3000, handler: app.handler() });
mountDuneAdmin(app, ctx)
Registers on the Fresh app:
- Admin panel routes under
/admin(or your configuredadmin.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
Use getContent() from @dune/core/content to query the content index from any Fresh route handler.
import { getContent } from "@dune/core/content";
getContent() returns a ContentApi object initialized by bootstrap(). It is synchronous — no await needed.
pages(options?)
List pages with optional filtering and ordering:
// routes/blog/index.tsx
import type { FreshContext, PageProps } from "fresh";
import { getContent } from "@dune/core/content";
import type { PageIndex } from "@dune/core";
export function handler(_req: Request, ctx: FreshContext) {
const posts = getContent().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 { getContent, type ResolvedPage } from "@dune/core/content";
export async function handler(req: Request, ctx: FreshContext) {
const page = await getContent().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 = getContent().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 = getContent().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 getContent().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
getContent()is a singleton — it is initialized once bybootstrap(). In a single-process multi-site setup, only the lastbootstrap()call's content index is accessible viagetContent(). Use theengineobject fromBootstrapResultdirectly if you need per-site isolation.- 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. Addapp.use(staticFiles())and serve your ownstatic/directory.