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 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
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. Addapp.use(staticFiles())and serve your ownstatic/directory.