# Ads and sponsors

> Static ad placements and the /advertise/ page that prices them — where each renders, how a sponsor is marked, and the email-to-config selling flow.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/grow/ads/

---

Ads are static markup: sold by email, written into `site.config.ts`, rendered at build time, with
no script and no ad network. Each placement is one boolean under `ads`, and `ads.page.enabled`
builds the `/advertise/` page that lists the rates.

> **Free edition**
>
> The placements and `/advertise/` ship in the free edition unchanged.

## Setup

1. Turn on the page and one placement.

   ```ts title="site.config.ts"
   ads: {
     contactEmail: 'ads@acme.com',
     page: { enabled: true, note: 'Placements run for a calendar month.' },
     topBanner: { enabled: true, text: 'Sponsor Acme — your product in front of every reader.', url: 'https://sponsor.example' },
     rates: [{ slot: 'topBanner', price: '$99 / month', blurb: 'One line under the header, on every page.' }],
   },
   ```

   `pnpm check:config` answers `site.config.ts OK — <name> (<url>)`. A `url` or `logo` must be
   an `https://` URL or a `/`-rooted path.

2. Build and deploy.

   ```sh title="Terminal"
   pnpm deploy
   ```

   The build's `zero-js:` line still ends `— ok`: nothing here adds a script.

## Verify

| What to check | Where | You should see |
| --- | --- | --- |
| The placement | `curl -s https://<your domain>/ \| grep -o 'data-ad="[a-z-]*"'` | `data-ad="top-banner"`; the others are `sponsors`, `inline-card`, `corner` |
| The rates | `https://<your domain>/advertise/` | the table with a **live** badge on each enabled placement, and an **Email** button |
| The footer | any page | an **Advertise** link |
| The rel | page source | every ad link carries `rel="sponsored noopener"` |

## How it works

| Placement | Where it renders | What marks it |
| --- | --- | --- |
| `topBanner` | one line between the header and the content, on every public page | the `label` pill (default `Ad`), the `text`, a `linkLabel` button |
| `sponsors` | a logo wall under the home-page hero, above the featured listings; renders nothing while `items` is empty | the `heading` (default "Supported by these partners"), each logo titled with its `tagline`, and an **Advertise with us** link when the page is on |
| `inlineCard` | a card shaped like a listing inside every listing grid, after every `every` listings, never as the last cell | "Sponsored" under the name, an `Ad` pill, a dashed border |
| `corner` | a small card fixed bottom-right on wide screens (the `md` breakpoint and up) | an `Ad` pill and a × that hides it for that page through a CSS checkbox, no script |

![The demo home page with the top banner under the header, the sponsor wall under the hero, and the corner card bottom right](../../../assets/screenshots/home.png)

Every ad anchor is `rel="sponsored noopener"`, so search engines read it as what it is. A logo
renders with an empty `alt` and lazy loading; the in-grid card without a `logo` shows the
name's first letter.

**`/advertise/`** is built only while `ads.page.enabled` is true — the route has an empty
`getStaticPaths()` otherwise, so there is no stub — and `advertise` is a reserved segment no
listing or category base can take. It renders `page.intro`, a table of `rates` (placement, where
it appears plus the rate's `blurb`, price) with a **live** badge on every placement currently
on, `page.note`, and a `mailto:` button to `ads.contactEmail`, falling back to
`site.contactEmail`, with the subject `Advertising on <site name>`. The footer links to it from
the same switch, and so does the sponsor wall.

![The demo's /advertise/ page: the placements table with prices and live badges, and the Email button](../../../assets/screenshots/advertise.png)

**Selling one.** A prospect writes to the contact address naming a placement and a duration.
You add them to `site.config.ts` — a sponsor is `name`, `url`, optional `logo` and `tagline`; the
other placements take `name`, `tagline`, `url`, `logo` — and deploy. There is no runtime state:
the placement runs until you edit the file again.

The demo turns every placement on with placeholder copy pointing at `/advertise/`, priced per
month, so a buyer sees each slot before selling it. Treat those values as an example, not a
price list.

## When it is off or degraded

| State | Visitor sees | `/admin/` reports | Fix |
| --- | --- | --- | --- |
| Everything off (the default) | no ads, no footer link, `/advertise/` is a 404 | nothing — ads have no runtime | — |
| A placement on, `page.enabled` off | the ad links to `/advertise/`, which does not exist, unless `url` is set | nothing | set `url`, or enable the page |
| `sponsors.enabled` with empty `items` | nothing renders; not marked **live** on the rates table | nothing | add an item |
| A grid no longer than `every` | no in-grid card | nothing | expected |
| A `url` without `https://` or a leading `/` | `pnpm check:config` fails naming the field | — | fix the value |

## Configuration

| Field | Default | What it changes |
| --- | --- | --- |
| `ads.contactEmail` | `site.contactEmail` | the `mailto:` on `/advertise/` |
| `ads.page.enabled` | `false` | whether `/advertise/` and its footer link exist |
| `ads.page.intro`, `ads.page.note` | a stock intro, no note | the copy above and below the rates |
| `ads.topBanner`, `ads.sponsors`, `ads.inlineCard`, `ads.corner` | `enabled: false` | one placement each; `inlineCard.every` is 2 to 50, default 6 |
| `ads.rates` | one `On request` row per placement | the pricing table: `slot`, `price`, `blurb` |

See [`ads`](https://zerodirs.com/docs/configure/reference/#ads).

## Files

- src/
  - components/ads/
    - AdBanner.astro `topBanner`
    - Sponsors.astro `sponsors`
    - AdCard.astro `inlineCard`
    - CornerAd.astro `corner`
  - lib/ads.ts `AD_SLOTS`, `activeSlots()`, `withInlineSlots()`
  - pages/\[advertise\].astro the rates page
  - layouts/Base.astro mounts the banner and the corner card
  - templates/HomePage.astro mounts the sponsor wall
  - components/listing/Grid.astro mounts the in-grid card
  - components/site/Footer.astro the Advertise link
  - config/defaults.ts `DEFAULT_ADS`
- site.config.ts the demo's `ads` block

## Related

- [Configuration reference](https://zerodirs.com/docs/configure/reference/#ads) — Every ads field, its type and its default.
- [Plans and payments](https://zerodirs.com/docs/submissions/payments/) — The other revenue line: paid and featured listings through Stripe.
- [Click tracking](https://zerodirs.com/docs/grow/clicks/) — Popularity figures you can show a prospective sponsor.
- [SEO](https://zerodirs.com/docs/configure/seo/) — What the build report checks on every page.
