This Astro starter kit ships bilingual (English/French) by default, with several pages built from CodeStitch components, a blog powered by Astro's content collections, and Decap CMS pre-wired for content editing. Every optional piece — i18n, the CMS, dark mode, demo content — can be stripped out with one interactive script, so the same kit works just as well for single-language projects as multilingual ones.
View Live Result
- Overview
- Getting Started
- Features
- Project Structure
- i18n System
- Content Management & Blog
- Deployment
- Acknowledgments
- Conclusion
This kit runs on Astro v7 with reusable components and centralized data, giving you room to scale as a client's site grows. It ships bilingual by default, with internationalization powered by Astro's built-in i18n routing, scalable to as many locales as you need. The blog runs on Decap CMS and Astro's Content Collections.
Every optional feature (i18n, Decap CMS, dark mode, demo content) can be removed with an interactive script (see Commands), so the same kit works just as well for a single-language site as a multilingual one. An example website is included, built from CodeStitch's vanilla component library for easy section swaps — deployment is possible in as little as two minutes.
There are two ways you can bootstrap your starter kit:
- At the top right of the GitHub Repository, click the green Use this template button, then click Create a new repository.
- Follow the instructions to create a new repository, using this repo as a template.
- When created, clone the repository to your local machine.
Run one of these commands to initialize a new project from this template:
npm create astro@latest -- --template CodeStitchOfficial/Advanced-Astro-i18nUsing yarn or pnpm instead
yarn create astro@latest --template CodeStitchOfficial/Advanced-Astro-i18npnpm create astro@latest --template CodeStitchOfficial/Advanced-Astro-i18nOnce you have the code, via either method above:
npm install
npm run devOpen localhost:4321 — you should see the demo site running. From there, jump to Set up your project to strip out any features you don't need.
npm run setup-projectThis is the main onboarding command: it asks which optional features to keep (i18n, Decap CMS, demo content, dark mode) and, if i18n is kept, offers to configure your locales right after. Under the hood it calls the scripts below — they're not exposed as npm run commands, but you can also run them directly at any time (e.g. to reconfigure locales later)
Once you've run setup-project, these are the files most projects need to personalize before writing any new code:
| File | What to update |
|---|---|
src/data/client.ts |
Business name, email, phone, address (BUSINESS object) |
src/data/siteConfig.ts |
Domain, description, social share image (SITE, OG) |
astro.config.ts |
site — your production domain |
src/styles/root.less |
Brand colors/fonts via CSS variables (--primary, --secondary, --headerColor, etc.) |
src/components/Settings/Settings.astro |
Swap or remove the dark-mode toggle / language switcher |
src/data/navData.json |
Nav links, and per-locale translated paths if i18n is kept |
public/admin/config.yml |
Decap CMS repo + DecapBridge auth endpoints, if keeping the CMS |
See Pre-Deployment Checklist for the full list to double-check right before going live (production domain, favicons, sitemap, etc.).
All commands are run from the root of the project, from a terminal:
| Command | Action |
|---|---|
npm install |
Installs dependencies |
npm run dev |
Starts local dev server at localhost:4321 |
npm run build |
Build your production site to ./dist/ |
npm run preview |
Preview your build locally, before deploying |
npm run setup-project |
Interactively choose which features to keep/remove, then configure locales |
npm run create-page -- "Page Name" |
Scaffolds a new page for every locale — see Scaffolding New Pages |
node scripts/config-i18n.js |
Reconfigure locales interactively (default locale, additional locales, URL prefixing) |
node scripts/remove-i18n.js |
Permanently removes the i18n system |
node scripts/remove-decap.js |
Removes Decap CMS integration |
node scripts/remove-demo.js |
Removes demo/placeholder content |
node scripts/remove-dark-mode.js |
Removes dark mode components and styles |
- Polyvalent: every optional feature below can be removed with
npm run setup-project(or its individualremove-*script), so this one kit covers single-language and multilingual projects alike - Runs on Astro v7
- Bilingual by default (English/French) with Astro's built-in i18n routing and custom utilities — add more locales anytime
- Optional Decap CMS integration for blog management (removable via
node scripts/remove-decap.js) - Dark mode (removable via
node scripts/remove-dark-mode.js) - Astro's
<ClientRouter />integration for view transitions - Astro Fonts API
- Astro's content collections to supercharge your Astro pages and content
- Automatic sitemap generation at build time
- CodeStitch HTML and CSS blocks to build the UI
- Perfect Lighthouse scores
.
├── public/
│ ├── admin/
│ │ ├── config.yml
│ │ └── decap-preview-styles.css
│ ├── assets/
│ ├── _redirects
│ └── robots.txt
├── scripts/
├── src/
│ ├── assets/
│ ├── components/
│ ├── content/
│ │ └── blog/
│ │ ├── en/
│ │ └── fr/
│ ├── data/
│ │ ├── client.ts
│ │ ├── siteConfig.ts
│ │ └── navData.json
│ ├── features/
│ │ ├── i18n/
│ │ ├── darkmode/
│ │ ├── decapCMS/
│ │ └── demo/
│ ├── icons/
│ ├── js/
│ ├── layouts/
│ │ └── BaseLayout.astro
│ ├── locales/
│ │ ├── en/
│ │ └── fr/
│ ├── pages/
│ │ ├── fr/
│ │ │ ├── blog/
│ │ │ │ ├── [...page].astro
│ │ │ │ └── [...slug].astro
│ │ │ ├── projets/
│ │ │ │ ├── projet-1.astro
│ │ │ │ └── projet-2.astro
│ │ │ ├── 404.astro
│ │ │ └── index.astro
│ │ ├── blog/
│ │ │ ├── [...page].astro
│ │ │ └── [...slug].astro
│ │ ├── projects/
│ │ │ ├── project-1.astro
│ │ │ └── project-2.astro
│ │ ├── 404.astro
│ │ └── index.astro
│ ├── styles/
│ └── content.config.ts
├── astro.config.ts
└── tsconfig.json
public/— Static assets that won't be processed by Astro (Decap admin, favicons,_redirects,robots.txt).src/components/— Reusable Astro components (Meta/,Header/,Footer/,Settings/, etc.).src/content/blog/— Blog posts, one folder per locale (en/,fr/).src/data/— Site-wide data (client.ts,siteConfig.ts,navData.json).src/icons/— SVGs used by the<Icon />component.src/layouts/— Page layouts.BaseLayout.astrowraps all pages.src/locales/— Translation JSON files, one folder per locale (en/,fr/).src/pages/— Astro page files. English pages live at the root, French pages underfr/with translated slugs.src/styles/— CSS/LESS stylesheets.
Internationalization runs on Astro's built-in i18n routing, plus a small set of helpers in src/features/i18n/. Two languages ship out of the box: English (default) and French.
// astro.config.ts
i18n: {
defaultLocale: "en",
locales: ["en", "fr"],
routing: { prefixDefaultLocale: false },
},prefixDefaultLocale: false means English pages have clean URLs (/about/) while French ones get a prefix (/fr/a-propos/). It is this kit's default setting.
Note: This kit's i18n is opinionated, and not the only valid way to do it here. Full page duplication (see Page Structure) is already enough on its own to serve translated content — each locale's pages could just contain hardcoded copy in their own language. The JSON translation layer (
src/locales/) exists on top of that so shared components (Hero, CTA, the header, etc.) can serve every locale without duplicating their markup. If your components diverge a lot per locale anyway, or you'd rather edit copy directly in place, skipping the JSON layer and hardcoding is a perfectly reasonable alternative.
Tip: Run
npm run setup-projectfor an interactive setup instead of doing this by hand.
To add a locale manually (e.g. Spanish es):
astro.config.ts— add"es"tolocalessrc/features/i18n/i18nConfig.ts— addestolocales,localeMap, andlanguageSwitcherMapsrc/locales/es/— copy the JSON files fromen/and translate themsrc/pages/es/— copy the pages fromsrc/pages/fr/and translate themsrc/data/navData.json— add anesentry to each nav item'surlsandlabelsrc/content/blog/es/— add translated blog posts (see Localizing Blog Post Slugs)
Each locale gets its own copy of every page: default locale pages sit at the root of src/pages/, secondary locale pages live inside a sub-folder
src/pages/
├── about.astro → /about/
├── contact.astro → /contact/
├── index.astro → /
├── fr/
│ ├── a-propos.astro → /fr/a-propos/
│ ├── contact.astro → /fr/contact/
│ └── index.astro → /fr/
Every page starts with one call — getSiteContext(Astro.url) — which figures out the locale from the URL and hands back that locale's translated content. See Using Translations
Note: if
prefixDefaultLocale: true(see Overview and Config), the default locale also moves into its own sub-folder (src/pages/en/) instead of sitting at the root.
Rather than copying _template.astro into each locale folder by hand, npm run create-page does it for every locale at once, from one command:
npm run create-page -- "Page Name"It reads src/pages/_template.astro (and each secondary locale's own _template.astro), derives a slug and title from the name you give it ("About Us" → about-us.astro / "About Us"), and for every page it creates it also:
- Adds an entry to
src/data/navData.json(skipped if that page is already registered) - Registers the per-locale slugs in
src/features/i18n/routeTranslations.ts, if i18n is enabled (see Localizing Route Slugs) - Skips any file that already exists, rather than overwriting it
Arguments (everything after --):
| Position | Example | Meaning |
|---|---|---|
| 1st — page name(s) | "Contact" or "Contact, About, Services" |
Required. Comma-separated names in the default locale — one page per name. |
| 2nd — secondary-locale name(s) | "Contactez-nous" or "Contactez-nous, À propos" |
Optional. Comma-separated names for the first secondary locale, matched positionally to the names above. Any other locales fall back to the default-locale slug/title. |
# Multiple pages at once
npm run create-page -- "Contact, About, Services"
# Skip the interactive prompt below by supplying the French name directly
npm run create-page -- "Contact" "Contactez-nous"
# Same, for multiple pages — positional: 1st name pairs with 1st page, etc.
npm run create-page -- "Contact, About" "Contactez-nous, À propos"If you omit the 2nd argument and run the command in a terminal, it prompts you for each secondary locale's name per page (press Enter to reuse the default-locale name). In a non-interactive context (CI, piped input) with no 2nd argument, it silently reuses the default-locale slug and title for every secondary locale.
src/features/i18n/i18nConfig.ts is the single source of truth for locale setup:
export const locales = ["en", "fr"] as const;
export const defaultLocale: Locale = "en";
export const localeMap = { en: "en-US", fr: "fr-FR" }; // for og:locale / hreflang
export const languageSwitcherMap = { en: "EN", fr: "FR" }; // labels on the toggleInfo: This file is automatically populated when you set up your project with
npm run setup-project.
Route translations (e.g. about → a-propos) aren't written by hand: they're generated automatically from src/data/navData.json, where each nav entry already has a translated URL per locale. To change a translated route, edit navData.json; you never need to touch the generated map directly.
Translations live in src/locales/{locale}/, one JSON file per namespace:
src/locales/
├── en/
│ ├── common.json
│ ├── home.json
│ ├── contact.json
│ ├── blog.json
│ └── reviews.json
└── fr/
├── common.json
├── home.json
├── contact.json
├── blog.json
└── reviews.json
JSON files for each locale must have the same structure and keys — only the translated values differ.
Example — src/locales/en/common.json:
{
"ctaComponent": {
"title": "Get It Done",
"subtitle": "With Us Today",
"message": "Say something encouraging...",
"cta": "Get a Quote"
}
}Example — src/locales/fr/common.json:
{
"ctaComponent": {
"title": "Confiez votre projet",
"subtitle": "à nos experts",
"message": "Dites quelque chose d'accrocheur...",
"cta": "Obtenir un devis"
}
}Call getSiteContext(Astro.url) and read from content, namespaced by filename (common.json → content.common, home.json → content.home, etc.):
---
import { getSiteContext } from "@js/getSiteContext";
const { content } = await getSiteContext(Astro.url);
---
<h2>{content.common.ctaComponent.title}</h2>
<h1>{content.home.hero.title}</h1>Route translation is driven by src/data/navData.json: each nav entry stores a per-locale URL, so look up the slug for the current locale there and pass it to getRoute(locale, path) to add the correct locale prefix:
---
import navData from "@data/navData.json";
import { getSiteContext } from "@js/getSiteContext";
import { getRoute } from "@js/routes";
const { locale } = await getSiteContext(Astro.url);
const aboutEntry = navData.find((entry) => entry.key === "about");
const aboutUrl = aboutEntry.urls[locale] ?? aboutEntry.urls.en;
---
<a href={getRoute(locale, aboutUrl)}>About</a>
<!-- "/about/" for EN, "/fr/a-propos/" for FR -->Important
This only translates routes that exist in src/data/navData.json and have a matching page file (e.g. src/pages/fr/a-propos.astro).
Adding a page with a translated slug is a 3-step combo:
- Create the English page:
src/pages/my-page.astro - Create the French page:
src/pages/fr/ma-page.astro - Add it to
navData.json:
{
"key": "my-page",
"urls": { "en": "/my-page", "fr": "/ma-page" },
"label": { "en": "My Page", "fr": "Ma Page" }
}That's it — every URL helper picks this up automatically.
Link translations of the same post with a matching mappingKey in frontmatter:
# src/content/blog/en/first-post-in-english.md
title: First blog post in English
mappingKey: "post-1"# src/content/blog/fr/premier-article-en-francais.md
title: Premier article de blog en français
mappingKey: "post-1"With the same mappingKey, but a different slug per locale, the language switcher uses it to jump from /blog/first-post-in-english/ straight to /fr/blog/premier-article-en-francais/.
Two ready-made components live in src/features/i18n/LanguageSwitch/:
TwoLocalesSelect.astro— simple toggle, best for 2 locales (default).MultiLocalesSelect.astro— dropdown, best for 3+ locales.
Both always link to the correct translated URL automatically. To switch which one is active, change the import in src/components/Settings/Settings.astro.
This kit ships with Decap CMS pre-configured, giving clients a user-friendly admin interface to manage blog posts in multiple languages. Authentication is handled by DecapBridge.
Astro Content Collections are the best way to manage sets of content in any Astro project: blog posts, product descriptions, character profiles, recipes, or any structured content. Collections help to organize and query your documents, enable Intellisense and type checking in your editor, and provide automatic TypeScript type-safety for all of your content.
Blog posts live in src/content/blog/ organized by locale:
src/content/blog/
├── en/
│ ├── first-post-in-english.md
│ └── ...
└── fr/
├── premier-article-en-francais.md
└── ...
This kit's blog collection is configured in src/content.config.ts and require schemas for Typescript validation.
Important
If you are using Decap CMS, the collection schema in content.config.ts and the field definitions in public/admin/config.yml must stay in sync. Adding a field to one without updating the other will cause validation errors or missing data.
The CMS configuration lives in public/admin/config.yml. This file controls:
- Backend — authentication method, GitHub repo, and branch
- Media — where uploaded images are stored (
src/assets/images/blog/) - i18n — locale structure for multilingual content
- Collections — the fields available in the admin UI for each content type
After completing the DecapBridge setup, replace the backend block in config.yml with the snippet from your DecapBridge dashboard. See the Decap CMS docs for a full reference on collection fields and widget types.
The CMS mirrors this kit's bilingual blog structure. In config.yml, the i18n block uses multiple_folders:
i18n:
structure: multiple_folders
locales: [en, fr]
default_locale: enThis maps to src/content/blog/en/ and src/content/blog/fr/ on disk. When an editor creates a post, Decap saves language variants into the corresponding locale folder automatically.
The mappingKey field (set to i18n: duplicate) links the English and French versions of the same post. It must be identical across translations — this is how getLocalizedPathname() resolves the equivalent post URL when switching locales. See Localizing Blog Post Slugs for details.
Once deployed and configured, navigate to /admin on your live site to access the CMS. Log in with your DecapBridge credentials. Clients you invite via the DecapBridge dashboard can log in the same way.
Set featured: true in a post's frontmatter (or toggle the Featured switch in the CMS) to surface that post as featured in the frontend. The featured field is i18n: duplicate, so toggling it in one locale applies to both.
Decap CMS renders a live preview of posts as editors type. Two files control this:
public/admin/decap-preview-styles.css— CSS applied inside the preview iframe. Edit this to match your site's typography and colours. Note: CSS must be flat (no nesting), as the preview iframe does not run a CSS preprocessor.src/pages/admin.astro— Registers the preview template and injects the stylesheet into Decap. Edit the preview template here to change the preview layout.
To run Decap CMS locally without deploying (useful for content entry during development):
- Add
local_backend: trueto the top ofpublic/admin/config.yml:
local_backend: true
backend:
# ... rest of your backend config- Install the required packages:
npm install --save-dev npm-run-all
npm install decap-server- Update
package.jsonscripts:
"scripts": {
"astro": "astro dev",
"decap": "npx decap-server",
"dev": "npm-run-all --parallel astro decap",
...
}- Run
npm run devas usual. The CMS admin will be available athttp://localhost:4321/adminwithout requiring a login.
Note
Remove local_backend: true before deploying to production.
Before going live, confirm the following are updated for your client's project:
astro.config.ts— set thesitefield to your production URLsrc/data/client.ts— fill in business name, address, phone, email, and social linkssrc/data/siteConfig.ts— fill in site title, description, production URL, and social share imagepublic/robots.txt— update theSitemapURL to your production domainpublic/assets/favicons/— replace placeholder favicons with the client's brandingpublic/admin/config.yml— complete the DecapBridge setup (see below) and setsite_urlto the production URL
Once updated, test the production build locally:
npm run build && npm run previewThen deploy: Netlify is the recommended host. Navigate to your Netlify Admin Panel, click Add new site → Import an existing project, and connect your GitHub repository.
Note
If you choose a different host, update the _redirects file to match that host's 404 redirect syntax.
DecapBridge provides GitHub OAuth for Decap CMS without requiring Netlify Identity. Follow these steps after deploying your site:
1. Create a DecapBridge account
Go to decapbridge.com and sign up.
2. Create a new site in DecapBridge
In your dashboard, click Create New Site and fill in:
- GitHub repository —
your-github-username/your-repo-name - CMS URL — your deployed site's URL (e.g.
https://yoursite.netlify.app/admin)
3. Generate a GitHub Personal Access Token
Go to GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens and create a token with:
- Repository access — select your repo
- Permissions —
Contents: Read and write,Pull requests: Read and write
Paste the token into the DecapBridge site setup form.
4. Paste the backend snippet into config.yml
DecapBridge will generate a backend configuration snippet. This kit is pre-configured for the PKCE auth format (the newer, recommended option). Paste your snippet into the backend block in public/admin/config.yml:
# PKCE format (used in this kit)
backend:
name: git-gateway
repo: your-github-username/your-repo-name
branch: main
auth_type: pkce
base_url: https://auth.decapbridge.com
auth_endpoint: /sites/<your-site-id>/pkce
auth_token_endpoint: /sites/<your-site-id>/token
gateway_url: https://gateway.decapbridge.comNote
DecapBridge also supports a legacy auth format (without auth_type: pkce), which uses identity_url and gateway_url only. Either format works — this kit ships pre-configured for PKCE. Use whichever format your DecapBridge dashboard provides.
5. Push and test
Commit and push the updated config.yml. Visit /admin on your live site and log in with your DecapBridge credentials to verify the connection.
6. Invite clients
From your DecapBridge dashboard, invite client email addresses. They'll receive a login link and can access the CMS at /admin without a GitHub account.
The author would like to acknowledge:
- Starlight - The ThemeProvider and Select components are derived from Starlight.
I hope that this kit will prove useful to you. If you have any questions or would like to connect, feel free to reach out on GitHub or at buckybuck on Discord.
Happy coding! Geoffrey