---
title: "Headless sites and content types"
description: "Render your site with plain fetch calls: entries, expand and filters, TypeScript from your own schema, schema files, and exporting a whole Site."
canonical: "https://writavo.com/docs/headless"
---

# Headless sites and content types

Writavo has no SDK to install, on purpose: the API is plain HTTP and JSON, a publishable key is safe in a browser, and your content types generate their own TypeScript. This page shows the whole loop, from reading entries to moving a Site.

## Read published content

Use a publishable key (it starts with wv_pub_) in a browser or at build time. It only ever sees published content. A secret key (wv_sk_) sees drafts too and belongs on a server.

Lists are cursor paged: pass next_cursor back as cursor until it is null. See Pagination, fields and concurrency for sparse fields, sync by updated_since, and safe retries.

## Content types and entries

A content type is a model you define (a Product, a Testimonial, a Homepage) and an entry is one piece of its content. Read them the same way. expand turns reference and media ids into the objects they point at, one level deep, and filter matches a field exactly.

entries:

```
// Every published product, newest first, with related products and the photo expanded
const products = await writavo("/entries/product?order=-published_at&status=published&expand=related,photo");

const one = await writavo("/entries/product/blue-widget");
const pro = await writavo("/entries/product?filter[tier]=pro");

const home = (await writavo("/entries/homepage")).items[0];
```

- An entry is { id, type, slug, external_id, title, status, data, published_at, ... }; data holds the values by field API id.
- A publishable key never sees a draft or a scheduled entry, and an expanded reference to one reads as null.
- Subscribe to the entry.published, entry.updated, entry.unpublished and entry.deleted webhooks to rebuild when content changes.

## TypeScript from your own schema

GET /content-types/typescript returns one file describing every content type: an interface per type and component, ContentTypes (API id to interface) and a generic Entry. Save the typescript value to a file in your front end and regenerate when schema_version changes.

typed reads:

```
import type { Entry, ContentTypes } from "./writavo-types";

async function entries<T extends keyof ContentTypes>(type: T): Promise<Entry<T>[]> {
  return (await writavo<{ items: Entry<T>[] }>(`/entries/${type}?status=published`)).items;
}

const products = await entries("product"); // products[0].data.name is a string
```

> Want a client for the whole API instead? The OpenAPI description at /openapi.json works with any generator, for example openapi-typescript.

## Keep your schema in a file

Content types can be built in the dashboard or kept in your repository and pushed. POST /content-types/apply takes the whole list in the shape GET /content-types returns, works out what to create and change, and never touches the Site's other types unless you ask it to (delete_missing). Always dry run first: you get the plan and every problem, and nothing is written.

shell:

```
# schema.json: { "types": [ { "api_id": "product", "name": "Product", "fields": [ ... ] } ], "dry_run": true }
writavo apply-content-schema --body-file schema.json

# happy with the plan? set "dry_run": false and run it again
```

- Schema changes never rewrite your entries. To rename a field and keep its values, send the new field with renamed_from set to the old API id.
- A removed field's values stay stored but are no longer returned; a type that still has entries cannot be deleted.
- required is enforced when an entry is published, so drafts can be saved half-finished.

## Export a whole Site

GET /export returns the Site as Writavo Import Format: articles and pages, authors, categories, tags, content types, entries, custom fields, redirects and engagement history, with media as links. It comes in volumes of about 8 MB: when there is more, the Writavo-Export-Next header (and source.export.next in the file) is the cursor for the next one. Import the files in order, into the same Site or another, with POST /imports or the MCP server's import_content.

shell:

```
writavo export-site --raw > part-1.json
# more to come? read .source.export.next and pass it on
writavo export-site --cursor "$(jq -r .source.export.next part-1.json)" --raw > part-2.json
```

References between entries and articles travel by external id or slug, not by database id, so they resolve on the Site you import into. A reference whose target arrives in a later file is reported; importing that file again at the end fills it in.
