This site is not a template or a WordPress theme. It is a single Next.js application with Payload CMS inside it, a Turso database and images on Cloudflare R2. We built it this way because it is the way of working we offer clients, and we would rather tell you what actually happened than draw a pretty diagram.
One application
Next.js 16 with the App Router and incremental static regeneration for the public side, and Payload 3 installed in the same app: the admin lives at /admin and there is no separate CMS server. That means one repository, one deployment on Vercel and TypeScript types shared between content and pages. If we change a field in a collection, the compiler tells us where it breaks.
Queries don't go over HTTP. Pages use Payload's Local API, which is a function call inside the same process. It is faster and saves us from building an API that only we would consume.
Why we picked Payload over Strapi or Directus is in Payload CMS vs Strapi vs Directus in 2026, and the comparison with WordPress is in WordPress headless vs Payload CMS.
What goes in code and what in the CMS
Not everything is in the admin, and that is on purpose.
- In code: the home page, the service pages, the retainer, contact and the legal texts. They rarely change and we want them to go through Git and review.
- In Payload: the blog, projects, menus, site settings, forms and redirects. That is what gets edited often.
The site is in Spanish and English. URLs come from a single routes file and are never written by hand in components, because a malformed URL on a two-language site is noticed late and costs rankings.
The database: SQLite locally, Turso in production
In development we use a SQLite file. In production, Turso. The code is the same Payload adapter in both cases; only two environment variables change:
db: sqliteAdapter({
client: {
url: process.env.DATABASE_URL ?? 'file:./data/payload.db',
authToken: process.env.DATABASE_AUTH_TOKEN,
},
}),DATABASE_URL points to file:./data/payload.db locally and to libsql://… in production. In development the adapter syncs the schema on its own; in production only migrations run, and the build script runs them before compiling: payload migrate && next build.
Turso is not the only option, nor always the best one. We cover it in more detail in the articles in this series about Turso and about which databases Payload supports.
Images: Cloudflare R2
Images are not stored on the server. Payload uploads them to an S3-compatible bucket and, in production, that bucket is Cloudflare R2 with a custom domain. We use the official S3 storage plugin and, locally, MinIO in a Docker container, which speaks the same protocol. For the code it makes no difference.
When a public domain is configured, the browser requests the image straight from the bucket and Payload stays out of it. Without that domain, which is what happens locally, Payload serves them itself.
Cloudflare: what we use and what we don't
To be clear: from Cloudflare we use DNS and R2. codegea.com does not go through Cloudflare's proxy and has no WAF rules turned on. Cloudflare as a firewall is another story that we have set up on client projects, and we will tell it separately.
Details we like
- Live Preview: while editing an article you see the real result as you type, using Next.js Draft Mode.
- Markdown version of every article: adding
.mdto the URL of a post or project returns the clean text, meant for agents and AI tools. - MCP: the CMS exposes an MCP server with API keys, so an assistant can read and create content without going through the admin.
- Legacy redirects: the previous site had URLs indexed on Google. We kept them identical, and the ones that no longer exist answer 410 instead of redirecting to the home page, so Google forgets them without creating soft 404s. We go through that in how to migrate your website without losing Google rankings.
What went wrong
Some things didn't work the first time and are worth writing down.
disk I/O errorwhen seeding data. The local SQLite file is shared between the dev server and the seed script. If the server is open and we seed the same database, it fails. We now verify with a separate database and build.- Uploads that went missing. When creating images from a script, the second upload in the process never reached the bucket. The S3 plugin keeps state in the request
context; reusing it between calls left it in a bad state. The fix was to pass a freshcontexton every upload. - Text that doesn't exist in one language. An article without a translation is not advertised in the other language, instead of showing text in the wrong one. It is less content, but it is correct content.
Is this for your project?
It depends. If your site is a blog that rarely changes, WordPress is still reasonable. If you have two languages, structured content, a team that uses TypeScript and you want a single deployment, this combination holds up. If you need a database with heavy concurrent writes, Turso is not the first option we would look at.
If you want something similar, see how we work on custom software development.
Written by
Derian Pinto
Founder & Full Stack Developer
Founder of Codegea. Builds custom websites, stores and platforms, and integrates and supports Arc XP for media companies.