Project Structure
A funnel is a small React project. You write the pages; Appfunnel builds and serves them. This page is the map: what each file is, which files you own, and which one is generated for you.
A funnel created with Start from Scratch is a working four-page flow (welcome → email → paywall → finish). When you create one in the editor or pull it down with the CLI, this is the tree you get:
- funnel.ts
- layout.tsx
- styles.css
- welcome.tsx
- email.tsx
- paywall.tsx
- finish.tsx
- mount.tsx
- package.json
- tsconfig.json
A funnel started from a gallery template has the same shape — same files, same rules — with more pages under pages/ and, usually, more than one offering slot.
The config spine — funnel.ts
funnel.ts is the single source of truth for the funnel’s shape: its id, the ordered list of pages, the routing between them, the response variables you capture, the offerings you sell, and the checkout provider. Everything else in the project is either a page component or an asset that funnel.ts ties together.
import { defineFunnel } from '@appfunnel-dev/sdk'
export const config = defineFunnel({
id: 'funnel',
checkout: 'stripe',
responses: {
email: { type: 'string', default: '' },
marketingConsent: { type: 'boolean', default: false },
},
offerings: { plan: null },
pages: [
{ key: 'welcome', type: 'default' },
{ key: 'email', type: 'email-capture' },
{ key: 'paywall', type: 'paywall' },
{ key: 'finish', type: 'finish' },
],
})Two rules keep this file working with the editor as well as at runtime:
- The export must be
export const config = defineFunnel({ ... }), and it must be a plain object literal — no computed keys, no spreads, no runtime logic. That’s what lets the editor read your flow back and show it in the Flow view. - Each page’s
keymust equal itspages/<key>.tsxfilename.{ key: 'welcome' }binds topages/welcome.tsx. Rename one and you rename the other.
The full field-by-field grammar — page types, the routing Condition operators, the offerings slot map, locales — is in Funnel Config, with exhaustive tables in the funnel definition reference.
Pages — pages/*.tsx
One file per page, one default-exported component per file, each wrapped in definePage:
import { definePage, useNavigation } from '@appfunnel-dev/sdk'
export default definePage(function Welcome() {
const { next } = useNavigation()
return (
<section>
<h1 className="text-3xl font-bold tracking-tight">Welcome</h1>
<button type="button" onClick={next}>Get started</button>
</section>
)
})There is no sdk prop and no context object passed in. A page reaches everything — the current answer state, navigation, offerings, translations — through hooks. That’s the whole authoring model: plain React components plus hooks from @appfunnel-dev/sdk. See Pages & Navigation for the page contract and State for the state hooks.
Persistent chrome — layout.tsx
layout.tsx wraps every page. It’s mounted once — inside the navigation context but outside the page swap — so it does not remount when the visitor moves between pages, and it can read navigation state. The template uses it for a header and a progress bar:
import { type ReactNode } from 'react'
import { usePage } from '@appfunnel-dev/sdk'
export default function Layout({ children }: { children: ReactNode }) {
const page = usePage()
return (
<div className="min-h-dvh bg-white text-slate-900">
<header className="flex items-center gap-4 border-b px-5 py-4">
<span className="font-bold">Your funnel</span>
<div className="h-1.5 flex-1 rounded-full bg-slate-200">
<div
className="h-full rounded-full bg-indigo-600"
style={{ width: `${page.progressPercentage}%` }}
/>
</div>
</header>
<main className="mx-auto max-w-xl px-5 py-12">{children}</main>
</div>
)
}Because it persists across navigation, usePage().progressPercentage animates smoothly rather than resetting each page. The layout contract is covered in Pages & Navigation.
Styles — styles.css
The Tailwind 4 entry. It’s one line plus whatever you can’t express as a utility class:
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--color-brand: #4f46e5;
}Keep it small. Style pages with Tailwind utility classes in JSX; reach for CSS only for web fonts, @theme design tokens, and keyframes. Full conventions in Styling & Assets.
Translations — messages/<locale>.json
Optional. Each supported locale is a flat key → string catalog at messages/<locale>.json. You don’t create these by hand — enabling localization in the editor extracts every hardcoded string in your pages into t('key') calls and writes the source catalog for you. A single-locale funnel has no messages/ folder at all. See State for the t() teaser and Localization for the full flow.
Assets
Images, logos, and other static files aren’t stored in the project tree. You upload them in the editor, and reference them by logical name with useAsset:
import { useAsset } from '@appfunnel-dev/sdk'
const logo = useAsset('logo.svg') // the served URL for that asset
return <img src={logo} className="h-6" />How uploaded assets are served is described in Styling & Assets.
package.json — mostly yours
This file is yours: it’s where you add any npm packages your pages import. The one thing you don’t touch is the @appfunnel-dev/sdk version — it’s pinned to an exact version for you (no ^, no ~):
{
"dependencies": {
"@appfunnel-dev/sdk": "2.0.0-canary.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}The pin is deliberate: your funnel always builds and serves against one known SDK version, so it never drifts.
Leave the @appfunnel-dev/sdk version exactly as it is. It’s pinned for you; changing it by hand can break the build.
Adding npm dependencies
Need a package in your pages — a date library, a carousel, an icon set? Add it under dependencies and it’s installed and bundled into your funnel when you publish. Two constraints:
- Install scripts don’t run. A dependency that relies on a
postinstallor a native build step won’t work. devDependenciesare ignored. Anything a page imports at runtime must live independencies.
Leave the @appfunnel-dev/sdk, react, and react-dom entries as they are. Add only your own packages.
Generated for you — leave alone: mount.tsx
mount.tsx is generated from funnel.ts. It wires up the runtime for you — your pages, your checkout provider, and the state and asset plumbing — so you never write it by hand.
Don’t hand-edit mount.tsx. It’s regenerated from funnel.ts, so any manual change is overwritten. In the editor it’s hidden from the file tree for exactly this reason.
Preview vs. the real thing
The editor’s live preview and the CLI’s appfunnel dev both give you a fast, close approximation of your funnel as you edit. What visitors actually get is the published version, built the same way every time. Treat the preview as a quick check and the published funnel as the ground truth. See Preview and Publishing for how each fits into your workflow.
There is no “publish from code” step. You write funnel.ts and pages, preview locally or in the editor, and publish through the dashboard or the CLI. Appfunnel builds and serves it from there.