An Astro template for multi-page product sites – marketing pages, docs, downloads and support – that ships with a content admin UI, a database and working form endpoints. You start from a deployable site with the boring plumbing already wired, instead of a blank landing page you have to grow into one.
Live demo: https://data-nova.vercel.app/
- 17 pages, 4 content collections. Landing, about, contact, platform, five downloads pages and a support hub with articles, reference docs, a knowledge base, sample spreadsheets and whitepapers. Articles and reference are Markdoc; spreadsheets and whitepapers are JSON.
- Edit content in a CMS. Keystatic admin UI at
/keystatic. Writes to local files in development or to your GitHub repo in production. - Database included. Drizzle ORM on Turso/libSQL, with a local SQLite file in development. Powers the helpful / not-helpful feedback widget on every article and reference page.
- Forms that work on day one.
/api/contact,/api/quoteand/api/newsletterare each declared once as a field list that renders the form and validates the payload, rate-limit per IP and carry a honeypot. They run in demo mode until you paste a Formspree or webhook URL. - Hardened for production. CSP, HSTS and other security headers in
vercel.json; the CMS admin can be switched off in production; the database client refuses to fall back to a local file on Vercel. - Modern stack. Astro 7 (SSR, Vercel adapter), Tailwind CSS 4, Preline UI 5, React 19 and Svelte 5 islands, TypeScript. Node 22+, pnpm.
- SEO and performance wiring.
astro-seo, schema.org viaastro-seo-schema,astro-font, view transitions withClientRouter, prefetching and a dynamicrobots.txt. - CI. GitHub Actions runs Prettier, Vitest and
astro check+ build on every push and pull request.
git clone https://github.com/mearashadowfax/DataNova.git && cd DataNova
cp .env.template .env
pnpm install && pnpm db:push
pnpm dev
# ➜ http://localhost:4321 your site
# ➜ http://localhost:4321/keystatic content admin- Getting Started
- Deployment
- Project Structure
- Customization
- Content Management with Keystatic
- Database with Drizzle and Turso
- Contact and Newsletter Forms
- API Routes
- SEO, Fonts and Transitions
- Contributing
- License
You need Node.js 22 or newer and pnpm. If you don't have pnpm, corepack enable installs the version pinned in package.json.
1. Create your repository. Click Use this template on GitHub, then clone it:
git clone https://github.com/YOUR_USERNAME/YOUR_REPO.git
cd YOUR_REPO2. Create the environment file. Everything in .env.template is optional for local development – the defaults use a local SQLite file and demo-mode forms.
cp .env.template .env3. Install dependencies and create the database schema.
pnpm install
pnpm db:pushYou should see [✓] Changes applied. This creates .data/local.db with the Feedback table.
4. Start the dev server.
pnpm devOpen http://localhost:4321 for the site and http://localhost:4321/keystatic for the content admin.
| Command | What it does |
|---|---|
pnpm dev |
Start the Astro dev server |
pnpm build |
Run astro check, then build the server output for Vercel |
pnpm test |
Run unit tests with Vitest (pnpm test:watch for watch mode) |
pnpm check |
Typecheck .astro and .ts files |
pnpm format:check |
Check formatting with Prettier (pnpm format:fix to write) |
pnpm db:push |
Push the Drizzle schema straight to the database |
pnpm db:generate |
Generate versioned SQL migrations into drizzle/ |
pnpm db:migrate |
Apply migrations from drizzle/ |
Note
The Vercel adapter does not support astro preview. Use pnpm dev, or the Vercel CLI (vercel dev) to emulate the production runtime locally.
DataNova is set up for server-side rendering with the Vercel adapter. Connect your repository to Vercel, or deploy the template directly.
Set these environment variables in Vercel before the first deploy:
| Variable | Required | Purpose |
|---|---|---|
TURSO_DATABASE_URL |
Yes | Turso database URL. Database routes throw on Vercel without it – see Database. |
TURSO_AUTH_TOKEN |
Yes | Turso database token |
SKIP_KEYSTATIC=1 |
If using local CMS mode | Disables the /keystatic admin routes in production |
PUBLIC_KEYSTATIC_STORAGE_MODE=github + repo owner/name |
If using GitHub CMS mode | Lets editors publish through the admin UI on your live site |
FORMSPREE_CONTACT_ENDPOINT / FORMSPREE_NEWSLETTER_ENDPOINT |
No | Deliver form submissions. Forms run in demo mode when unset |
Also change site in astro.config.mjs from https://data-nova.vercel.app to your domain – it's used for canonical URLs and robots.txt.
Tip
Deploying elsewhere? Swap the adapter. For example, npx astro add netlify and then in astro.config.mjs:
import netlify from '@astrojs/netlify';
export default defineConfig({
// ...
output: 'server',
adapter: netlify(),
});Astro has official adapters for Netlify, Cloudflare and Node.js – see the adapter list. The security headers in vercel.json are Vercel-specific and need to be recreated for your platform.
├── astro.config.mjs # Integrations, SSR output, Vercel adapter
├── keystatic.config.ts # CMS collections and storage mode
├── drizzle.config.ts # Drizzle Kit (schema path, Turso credentials)
├── vercel.json # Security and cache headers
├── drizzle/ # Generated SQL migrations
├── public/ # Static assets served as-is
└── src/
├── assets/
│ ├── images/
│ └── styles/global.css # Tailwind theme: colors, fonts
├── components/
│ ├── common/ # Shared building blocks (hero, cards, breadcrumbs, MegaMenu/, PostFeedback.svelte)
│ ├── sections/ # Page sections (Navbar, Footer, FooterExpanded, HeroSection, Feature*, CTA)
│ └── ui/ # Button, forms/, icons/
├── content/ # Markdoc collections edited via Keystatic
│ ├── articles/
│ └── reference/
├── data/ # JSON collections
│ ├── spreadsheets/
│ └── whitepapers/
├── config.ts # Every env var, resolved once into a typed config
├── db/
│ ├── client.ts # libSQL client, opened on first use
│ └── schema.ts # Feedback table
├── feedback/store.ts # Helpful / not-helpful feedback: counts(), record()
├── forms/ # Form declarations, the submit pipeline and the delivery seam
├── layout/BaseLayout.astro # <head>, SEO, fonts, navbar and footer for every page
├── navigation.ts # Mega menus, top-level links and breadcrumb roots
├── pages/
│ ├── api/ # contact.ts, quote.ts, newsletter.ts, feedback.ts, health.ts
│ ├── downloads/ # Product, editions, licensing, quote pages
│ ├── platform/
│ ├── support/ # articles/, reference/, knowledge base, spreadsheets, whitepapers
│ ├── index.astro, about.astro, contact.astro, 404.astro
│ └── robots.txt.ts # Generated from `site`
├── support/ # docs.ts (what a support document is), load.ts (astro:content)
├── utils/ # rate-limit.ts, sanitize.ts, form-client.ts, reading-time.ts
└── content.config.ts # Astro content collection schemas
Path aliases are defined in tsconfig.json: @/, @common/, @sections/, @ui/, @layout/, @utils/, @styles/, @images/, @megaMenu/.
Note
Some mega-menu and CTA links intentionally point to # for showcase purposes. Replace them when adapting the template.
Top-level links live in src/navigation.ts:
export const navigationLinks = [
{ href: '/about', label: 'About' },
{ href: '/contact', label: 'Contact' },
];Navbar.astro renders the array and underlines the link matching Astro.url.pathname.
The whole navigation tree is one typed object, menus, in src/navigation.ts. Each menu has a label, an optional description, a pathPrefix that marks it active, and sections of items:
// src/navigation.ts
export const menus = {
downloads: {
id: 'downloads',
label: 'Downloads',
description: 'Get started with a free trial or explore licensing options',
pathPrefix: '/downloads',
layout: 'list',
sections: [
{
title: 'Download',
items: [
{
icon: 'download',
title: 'DataNova Core',
description: 'Download the free trial version.',
href: '/downloads/datanova-core',
},
],
},
],
},
// ...
} as const satisfies Record<string, Menu>;layout: 'list' renders an icon, title and description per link; layout: 'cards' renders an imported image per link (see the Platform menu). One component, MegaMenu.astro, renders every menu, and Navbar.astro loops over menuList. The expanded footer and the 404 page read the same tree, and pnpm test checks that every internal href has a page and every icon exists in icons.ts.
To add a menu: add an entry to menus. Nothing else changes.
Two footers ship with the template:
Footer.astro– compact: company info, contact details and a subscribe form.FooterExpanded.astro– adds link columns generated frommenusand a wider subscribe form.
Both render the newsletter form with <JsonForm form={newsletterForm} layout="inline" /> (see Contact and Newsletter Forms). Company name, description, address, phone and email live in src/site.ts (shipped as placeholder values – replace them with yours); the attribution line is a constant at the top of each footer. Switch footers by changing one import in src/layout/BaseLayout.astro:
---
import Footer from '@sections/Footer.astro'; // or '@sections/FooterExpanded.astro'
---Sections in src/components/sections/ and blocks in src/components/common/ follow the same pattern – copy is held in constants at the top of the file:
---
const title = 'My New Title';
const subTitle = '...';
const primaryCTA = { text: 'Get started', href: '/downloads/datanova-core' };
---Edit title, subTitle and the primaryCTA / secondaryCTA / tertiaryCTA objects; no template markup changes needed.
Tailwind CSS 4 is configured in CSS, not in a JS config file. Theme tokens live in src/assets/styles/global.css – change colors and font families there. See Tailwind's docs on customizing colors and font families. The fonts themselves (Google Fonts by default) are declared in the AstroFont config in BaseLayout.astro.
Keystatic gives editors a web UI for the articles and reference Markdoc collections. Collection shapes are defined in keystatic.config.ts; the matching Astro schemas are in src/content.config.ts. Both files build the articles and reference collections from one docCollection() helper, so a new field is added in two places, once per file. What a page derives from a document (table of contents, reading time, SEO, schema.org, breadcrumbs, feedback slug) lives in src/support/docs.ts.
- Local development: http://localhost:4321/keystatic – edits write to
src/content/. - Production (GitHub mode):
https://your-domain.com/keystatic– edits are committed to your repository.
Storage is chosen by environment variable, resolved in src/config.ts and read by keystatic.config.ts:
# .env
PUBLIC_KEYSTATIC_STORAGE_MODE=local # default; or "github"
PUBLIC_KEYSTATIC_GITHUB_REPO_OWNER=your-org # GitHub mode only
PUBLIC_KEYSTATIC_GITHUB_REPO_NAME=your-repo # GitHub mode onlyThese three carry the PUBLIC_ prefix on purpose: Keystatic's admin UI is bundled for the browser and must see the same storage mode as the server, and Astro only exposes PUBLIC_ variables to client code. None of them are secrets. GitHub mode also requires a Keystatic GitHub App whose client id and secret stay private – follow the Keystatic GitHub mode guide.
If you only edit content locally, keep /keystatic off your live site. astro.config.mjs already skips the integration when SKIP_KEYSTATIC is set:
integrations: [
react(),
markdoc(),
...(process.env.SKIP_KEYSTATIC ? [] : [keystatic()]),
svelte(),
],Important
Set SKIP_KEYSTATIC=1 in your hosting provider's environment variables. CI does the same when building.
The template renders on the server because Keystatic's API routes and the form/feedback endpoints need a runtime. If you edit content only in local mode and don't need the API routes, you can build a fully static site and host it anywhere:
-
In
astro.config.mjs, includekeystatic()only in development and setoutputaccordingly:const isDev = process.env.NODE_ENV === 'development'; export default defineConfig({ integrations: [ react(), markdoc(), ...(isDev ? [keystatic()] : []), svelte(), ], output: isDev ? 'server' : 'static', });
-
Add
getStaticPaths()to the dynamic routessrc/pages/support/articles/[id].astroandsrc/pages/support/reference/[id].astro– see building for static output. -
Remove or replace
src/pages/api/*and thePostFeedbackwidget, which require a server.
Further reading: Keystatic docs · Disable admin UI in production · Astro content collections
The feedback widget (PostFeedback.svelte) stores helpful / not-helpful counts per document. Feedback is keyed by collection/id (for example articles/my-post) so an article and a reference doc with the same file name never share a counter. The schema is one table in src/db/schema.ts:
export const feedback = sqliteTable('Feedback', {
slug: text().primaryKey(),
helpful: integer().default(0).notNull(),
notHelpful: integer().default(0).notNull(),
});All reads and writes go through src/feedback/store.ts – counts(slug) and record(slug, kind) – which is also what pnpm test exercises against an in-memory libSQL database. The connection itself comes from src/db/client.ts, opened on first use, with its URL resolved in src/config.ts: TURSO_DATABASE_URL when set, file:.data/local.db otherwise. On Vercel the config refuses to fall back to a local file, so a misconfigured deploy fails loudly instead of silently writing to a throwaway file.
Set up a production database:
-
Create a Turso database (free tier is fine).
-
Copy the database URL and create an auth token, then add both to
.envand to your hosting provider:TURSO_DATABASE_URL=libsql://your-db-name.turso.io TURSO_AUTH_TOKEN=your_token
-
Apply the schema. For a quick start, push it directly:
pnpm db:push
For shared environments, prefer versioned migrations:
pnpm db:generatewrites SQL intodrizzle/, andpnpm db:migrateapplies it. Both commands read the sameTURSO_*variables from.env.
Note
The legacy ASTRO_DB_REMOTE_URL and ASTRO_DB_APP_TOKEN variables are still accepted as fallbacks.
Important
Feedback rows created by earlier versions of this template are keyed by a bare document id. Migration drizzle/0001_namespace_feedback_slugs.sql rewrites them to collection/id; run pnpm db:migrate (not db:push, which only syncs the schema) against a database that already holds feedback, and check the collection list in that file first if you replaced the demo content.
Further reading: Drizzle ORM docs · Turso docs
Each form is declared once in src/forms/declarations.ts as a list of fields:
export const contactForm = declareForm({
name: 'contact',
endpoint: '/api/contact',
deliverTo: 'contact',
fields: [
{
name: 'name',
label: 'Name',
kind: 'text',
maxLength: 120,
required: true,
},
{
name: 'email',
label: 'Email',
kind: 'email',
maxLength: 254,
required: true,
},
{
name: 'message',
label: 'Message',
kind: 'textarea',
maxLength: 5000,
required: true,
},
],
messages: {
legend: 'Contact us',
sent: 'Thanks! Your message has been sent.',
},
});The same declaration renders the markup – <JsonForm form={contactForm} /> from JsonForm.astro – and derives the Zod schema the endpoint validates with, so a field the browser requires is always a field the server requires. The route file is one line: export const POST = formEndpoint(contactForm).
src/forms/submit.ts owns the whole pipeline for every form:
- rate-limits by client IP (5 requests/minute per form),
- validates the JSON payload against the derived schema,
- drops submissions that fill the hidden
websitehoneypot field, returning a silent success, - forwards the payload to a webhook, or logs it server-side in demo mode when no webhook is configured.
To deliver real submissions, set one variable per delivery target (the contact and quote forms share contact):
FORMSPREE_CONTACT_ENDPOINT=https://formspree.io/f/your-id
FORMSPREE_NEWSLETTER_ENDPOINT=https://formspree.io/f/your-id
# or any JSON webhook:
# FORM_WEBHOOK_CONTACT=
# FORM_WEBHOOK_NEWSLETTER=The CSP in vercel.json allows connect-src to formspree.io; add your own webhook host there if you use something else.
To add a form: add a declareForm(...) declaration, a one-line route under src/pages/api/, and render it with <JsonForm>.
| Route | Method | Purpose |
|---|---|---|
/api/contact |
POST | Contact form – { name, email, message } |
/api/quote |
POST | Quote form – { name, company, email, licenseType, message } |
/api/newsletter |
POST | Newsletter subscribe – { email } |
/api/feedback?slug=... |
GET | Read helpful / not-helpful counts for a document (articles/<id>) |
/api/feedback |
POST | Record feedback – { slug, type: 'helpful' | 'notHelpful' } |
/api/health |
GET | { ok, database } – returns 503 if the database is unreachable |
Form and feedback routes declare a rate-limit policy ({ name, limit, windowMs }) enforced by src/utils/rate-limit.ts, which answers 429 with a Retry-After header. Buckets live behind a RateLimitStore seam; the default in-memory store is per instance, so pass createRateLimiter({ store }) a KV- or Redis-backed store if you run many instances.
Everything global lives in src/layout/BaseLayout.astro:
-
astro-seorenders<title>, description, Open Graph and Twitter tags. Override per page by passingseo:--- const seo = { title: 'About DataNova', description: 'Learn more about DataNova...', }; --- <BaseLayout seo={seo}>...</BaseLayout>
-
astro-seo-schemaemits schema.org JSON-LD. Pass a typedschemaprop (WithContext<Thing>fromschema-dts) to replace the site default. -
astro-fontloads DM Sans and Work Sans from Google Fonts with preloading andfont-display: swap. Change fonts in theAstroFontconfig. -
ClientRouterfromastro:transitionsenables view transitions;prefetch: trueandexperimental.clientPrerenderinastro.config.mjsspeed up navigation further. -
robots.txtis generated atsrc/pages/robots.txt.tsfrom thesitevalue.
Sitemaps are not included. The official @astrojs/sitemap cannot see dynamic routes in SSR mode; if you need one, use the community Sitemap Extensions package.
Bug reports and feature requests go to the issue tracker; questions to Discussions. Pull requests are welcome – CI runs pnpm format:check, pnpm test and pnpm build, so run those locally first. See CODE_OF_CONDUCT.md.
MIT – see LICENSE.
The template has no affiliation with the companies whose logos appear in the demo. They are placeholders and must be replaced in your own site.
