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./privacyand/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:
- Create
content/blog/en/my-post.mdxwith the block above. - Write the post under it.
- Create
content/blog/fr/my-post.mdxwith 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.
The legal pages
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:
- The language in the address.
- The language saved in a cookie.
- The language the browser asks for.
- 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
@zap-ts/ui— the buttons, the layout and the colors@zap-ts/tanstack-start— shared page pieces@zap-ts/analytics— what visitors do@zap-ts/observability— errors you need to see
It uses no database package, and no authentication package.
What to change first
SITE_URLandAPP_URLin.env.schema. They point at the demo today.SITE_NAME,SITE_AUTHORandSITE_TWITTER_HANDLEinsrc/lib/site.ts.- The sections in
src/routes/index.tsx, and the text inmessages/en.json. - The legal files in
content/legal. - 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.
Related
- Web — the app the “Get started” button points at
- Admin — the back office
- UI — the components these pages use
- Environment — how varlock loads
.env.schema - Going to production