Skip to content
zap.ts
Esc
navigateopen⌘Jpreview
On this page

Marketing

The public site — the landing page, the blog and the legal pages, in English and French.

apps/marketing is the site everyone can see. No sign-in, no session. You run it on your main domain, <domain>.

It holds three things. The landing page, a blog, and the legal pages: privacy and terms. Every page has a version in English and in French.

The site is built with TanStack Start and runs on Cloudflare Workers by default. Vercel is supported too; see Deployment. A Worker is a small server that Cloudflare runs close to your visitors. The pages live in src/routes. The name of a file there decides its URL.

This app never talks to the database. It reads its text from files. That is why it needs so few environment variables.

Running it

pnpm run dev:marketing

This starts the site on port 3001. Open http://localhost:3001.

pnpm run env:marketing

This prints the environment the site loads. An environment variable is a value you set outside the code, like a URL.

The pages

  • / — the landing page.
  • /blog — the list of posts.
  • /blog/$slug — one post.
  • /privacy and /terms — the legal pages.
  • /api/og — it draws the sharing image of a page.
  • /robots.txt — it tells search engines what they may read.

The landing page is built from sections: social proof, features, how it works, pricing, testimonials, and questions and answers. All the text comes from the translation files. The page itself holds no sentences.

The “Get started” button does not open a page of this app. It points at the web app, at SIGN_UP_URL. That value is APP_URL plus /sign-up.

Writing a blog post

Posts live in content/blog/<locale>/<slug>.mdx. MDX is Markdown that can also use React components.

Each post starts with a small block of information at the top:

---
title: Hello world
description: The first post of this blog, written in MDX.
date: "2026-09-07"
---

To add a post, do this:

  1. Create content/blog/en/my-post.mdx with the block above.
  2. Write the post under it.
  3. Create content/blog/fr/my-post.mdx with the same file name.

The file name is the address of the post. A post in en/my-post.mdx is served at /blog/my-post.

If a translation is missing, the site shows the English post instead. So you can publish in one language first.

The list of posts is sorted by date, newest first.

They work the same way. They live in content/legal/<locale>/<slug>.mdx, and the slugs are privacy and terms.

Their information block is a little different. It has title, description and updated. updated is the date you last changed the text.

Both files that ship hold placeholder text. Replace it with your own terms before you go live, or ask a lawyer to.

Languages

The site uses Paraglide, a tool that turns text files into typed functions.

The file project.inlang/settings.json lists the languages, en and fr. English is the base. The text lives in messages/en.json and messages/fr.json.

The site picks a language in this order:

  1. The language in the address.
  2. The language saved in a cookie.
  3. The language the browser asks for.
  4. English.

Run pnpm run i18n:compile to build the translations. The build and typecheck scripts already run it first. So a missing translation stops the build instead of showing an empty label.

Search engines

This app wants to be found, unlike the other two.

Each page carries a title, a description, and the tags that social networks read. Pages also say which languages they exist in, so a search engine shows the right one.

The sitemap is built at build time, in vite.config.ts. A sitemap is a file that lists every page of a site. The config lists /, /blog, /privacy and /terms, then reads content/blog and adds one line per post. You do not update it by hand.

The robots.txt names the crawlers of AI companies one by one, and allows them. There is a TODO comment above the list. To block them, change their Allow: / to Disallow: /.

The sharing image

/api/og draws an image for social networks. You pass it a title:

/api/og?title=Hello%20world

A blog post uses it when it has no cover in its information block. The image is cached for a day.

What it uses from the project

It uses no database package, and no authentication package.

What to change first

  1. SITE_URL and APP_URL in .env.schema. They point at the demo today.
  2. SITE_NAME, SITE_AUTHOR and SITE_TWITTER_HANDLE in src/lib/site.ts.
  3. The sections in src/routes/index.tsx, and the text in messages/en.json.
  4. The legal files in content/legal.
  5. The icons in public/.

Environment

The site needs two variables. Both are public, so you can read them in the browser.

SITE_URL=https://zap-ts.zapstudio.dev
APP_URL=https://app.zap-ts.zapstudio.dev

SITE_URL is this site. APP_URL is the web app, where the sign-up lives. The site also takes the PostHog and Sentry variables from the root .env.schema. See Environment to learn how varlock loads them.

Deploying

pnpm run deploy:marketing:cloudflare
pnpm run deploy:marketing:vercel

The host is part of the name, so a deploy never goes somewhere you did not name. There is no deploy on its own.

The Cloudflare one runs varlock-wrangler deploy. It sends your environment variables up with the site. You do not set them by hand in the Cloudflare dashboard. The Vercel one runs varlock run -- vercel deploy --prod, and there you set the variables yourself. See Deployment.

The site has no database, so there is nothing to migrate. A deploy is only new files.

Last updated on September 22, 2026

Was this page helpful?