TSX Content Pages

TSX content pages are full JSX components that serve as both content AND template. They're ideal for landing pages, interactive demos, or any page where you need full programmatic control.

How it works

A TSX content page is a .tsx file in your content directory that exports two things:

  1. export const frontmatter — a static object with your page metadata (title, date, taxonomies, etc.)
  2. export default function — a JSX component that IS the page content

Minimal example

// content/04.landing/page.tsx

export const frontmatter = {
  title: "My Landing Page",
  date: "2025-06-15",
  published: true,
  taxonomy: {
    tag: ["showcase"],
  },
};

export default function LandingPage({ site, media }) {
  return (
    <article>
      <h1>{frontmatter.title}</h1>
      <p>Welcome to {site.title}!</p>
      <img src={media.url("hero.jpg")} alt="Hero" />
    </article>
  );
}

Frontmatter options

TSX pages support the same frontmatter fields as Markdown pages — title, date, taxonomies, collections, visibility, caching. The difference is how frontmatter is declared:

Inline frontmatter (in the .tsx file)

export const frontmatter = {
  title: "Page Title",
  date: "2025-06-15",
  taxonomy: { tag: ["demo"] },
};

Constraint: The frontmatter object must be a static, JSON-compatible literal. No function calls, no variable references, no imports. This is because Dune extracts frontmatter during indexing without executing your code.

Sidecar YAML (separate file)

For complex frontmatter or if you prefer YAML, create a sidecar file next to your TSX file:

# content/04.landing/page.frontmatter.yaml
title: "Page Title"
date: 2025-06-15
taxonomy:
  tag: [demo]
  category: [showcase]
collection:
  items:
    "@self.children": true
  order:
    by: date
    dir: desc

The sidecar file takes precedence. If both exist, the YAML sidecar is used and the inline export is ignored for indexing purposes.

Layout control

TSX pages render themselves — they don't need a theme template. A plain TSX page (one without a handler) is wrapped in your theme's standard layout, components/layout.tsx — the same site chrome (head, navigation, footer) that Markdown pages get — so the component only has to produce the page body. Use layout in the frontmatter to change that:

export const frontmatter = {
  title: "My Page",

  // Layout options:
  // (omitted)            → wrapped in the theme's standard layout
  // layout: "default"    → same as omitting it
  // layout: "landing"    → wrap in components/landing.tsx instead
  // layout: false        → no wrapper — you control everything including <html>
};

A page that exports a handler and calls ctx.render() is never wrapped: like a Fresh route, it owns the whole page, and its component receives the site navigation as props so it can render its own chrome (or import the theme layout itself).

Request handlers

TSX content pages can export a handler object alongside the default component — mirroring Fresh's export const handler: Handlers<Data> idiom. When a request hits the page, Dune dispatches it through the matching method before rendering.

import type { Handlers, PageProps } from "@dune/core";

interface Data {
  items: string[];
}

export const handler: Handlers<Data> = {
  async GET(req, ctx) {
    const items = await loadItems();   // any async work
    return ctx.render({ items });
  },

  async POST(req, ctx) {
    const form = await req.formData();
    await saveItem(form.get("name") as string);
    return Response.redirect(req.url, 303);
  },
};

export default function MyPage({ data }: PageProps<Data>) {
  return (
    <ul>
      {data.items.map((item) => <li>{item}</li>)}
    </ul>
  );
}

ctx.render(data) wraps the default component with the provided data object. The component receives it as props.data. The GET handler is optional — omitting it renders the component with no data.

For POST and other mutating methods to reach the handler, the content catch-all route accepts all HTTP methods. No extra configuration needed.

What TSX pages can do

  • Import and use Fresh islands (interactive client components)
  • Import theme components (layouts, partials, design tokens)
  • Import from npm and JSR packages
  • Access co-located media via the media prop
  • Query the content index via the content prop — .pages(), .page(), .search(), .taxonomy()
  • Use collections defined in their frontmatter
  • Handle form submissions and API requests via handler
  • Read the current public-auth user via the user prop
  • Full TypeScript type safety

Reading the current user

The default component receives a user prop — the resolved User for the current request, or null if nobody is logged in (or public auth isn't configured at all). This is the same information a Fresh route handler gets from ctx.state.siteUser or a generated CRUD route gets from requireAuth() — TSX content pages no longer need to read it themselves.

export default function AccountPage({ user }: ContentPageProps) {
  if (!user) {
    return <p>Please log in to view this page.</p>;
  }

  return <p>Welcome back, {user.name ?? user.email}!</p>;
}

When to use TSX vs Markdown

Choose Markdown when...Choose TSX when...
Content is mostly prosePage needs custom layout or design
Non-developers will edit itPage includes interactive elements
Standard blog/docs layout worksYou need programmatic control
Quick authoring matters mostType safety and component reuse matter