Getting started
What Ginko is, when to use it, and how to build a first site in five minutes.
What Ginko is
Ginko connects a Nuxt site with its content. The site stays normal Nuxt code. The content comes from a bundle. A bundle is one JSON response that holds all content of the site in one language.
The content has two possible sources:
- Platform. Owners edit content on the hosted Ginko platform, for example opening hours, a menu, or events. The site fetches the bundle from there.
- Files. Developers keep texts as Markdown files in the repo, for example these docs or legal pages. The build turns them into the same bundle.
A site can mix both sources. The site reads both the same way, with useGinko.
Ginko uses four terms:
- Brick: a type of content, for example "Menu". You describe it in
ginko.config.ts. - Field: one value in a brick, for example a title or a price.
- Entry: one record of a brick, for example one dish.
- Bundle: all bricks with their values, as the site reads them.
When to use Ginko
Use Ginko when a site has content that someone changes often, and that person should not touch code. Examples: opening hours, a lunch menu, events, job ads.
Use the file source for texts that developers maintain: docs, a blog, legal pages.
Ginko is not a page builder. You define the bricks and fields. The layout stays code in your Nuxt site. A site without changing content does not need Ginko.
Your first site in five minutes
The first site uses the file source. It needs no platform and runs right away. You need Node.js 22.18 or newer (22, 24 or 26) and npm, pnpm or bun.
The fastest way is the starter:
npm create ginko@latest my-site
cd my-site
npm install
npm run devOpen http://localhost:3000. The page shows the sample text from content/start.md.
The starter has more options. Write them after --, or npm keeps them for itself:
npm create ginko@latest my-site -- --locale de
npm create ginko@latest my-restaurant -- --preset gastro
npm create ginko@latest my-docs -- --preset docs--locale de creates the same site in German. --preset gastro starts a restaurant site with opening hours and a menu (see "Presets"). --preset docs starts a documentation site. The command always says which starter it used.
The steps below build the same site by hand and explain every file the starter creates.
1. Create the package
{
"name": "my-site",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@lupinum/ginko": "^0.4.0",
"@lupinum/ginko-content": "^0.4.0",
"nuxt": "^4.6.0",
"vue": "^3.5.0"
}
}The package @lupinum/ginko-content reads the Markdown files. You only need it for bricks with source: 'files'.
Then run npm install. With pnpm 11, first add a pnpm-workspace.yaml with allowBuilds: and esbuild: true below it. pnpm otherwise stops, because esbuild downloads its binary in an install script. The starter has this file already.
2. Describe the bricks
// ginko.config.ts
import { defineSite, f, single } from '@lupinum/ginko'
export default defineSite({
site: 'my-site', // identifier: lowercase letters, digits, hyphens
name: 'my-site', // the site's name for people
url: 'https://example.com', // address of the live site
timeZone: 'Europe/Vienna',
locales: ['en'], // the first locale is the main locale
currency: 'EUR',
pages: { home: '/' }, // fixed pages: key and path
bricks: {
start: single({
label: 'Start page',
source: 'files',
fields: {
title: f.text('Title'),
body: f.markdown('Text', { profile: 'basic' }),
},
}),
},
})single describes a brick with exactly one entry. Each field gets a label and optional settings. The profile basic allows paragraphs, headings, lists, quotes and links in the text, but no images or tables. The page "Bricks" has the details.
3. Turn on the Nuxt module
// nuxt.config.ts
import ginko from './ginko.config.ts'
export default defineNuxtConfig({
compatibilityDate: '2026-10-01',
modules: ['@lupinum/ginko/nuxt'],
ginko,
})The module checks the configuration at startup. An error in it stops the build. An unknown key, for example redirect instead of redirects, is an error too.
4. Write a page that reads useGinko
<!-- app/app.vue -->
<template>
<NuxtPage />
</template><!-- app/pages/index.vue -->
<script setup lang="ts">
const start = useGinko('start')
</script>
<template>
<main>
<h1>{{ start.data.title }}</h1>
<GinkoMarkdown :value="start.data.body" />
</main>
</template>For a single, useGinko('start') returns data with the fields. The types come from your ginko.config.ts. The module imports useGinko and GinkoMarkdown for you.
5. Add the content as a file
<!-- content/start.md -->
---
title: Hello Ginko
---
This text lives in `content/start.md`. Change it, and the page shows the new text.The file name is the brick name. The frontmatter fills the fields. The text below it fills the Markdown field.
Start the site with npm run dev. The page shows "Hello Ginko". In dev mode, Ginko reads changed files in content/ again.
A site with platform bricks uses the same composables. You leave out source: 'files', give the single's required fields a default or make them optional (f.markdown, f.image and f.file cannot have a default, so set optional: true on them), and set platform in ginko.config.ts to the platform address. The pages "Reading content" and "Platform and CLI" have the details.
To run a files site from the platform without changing its code, for example a copy of a template in a demo, set GINKO_CONTENT=platform for the build and for ginko release. Every brick then reads the platform, as if source: 'files' were gone.
Next: a collection and a second language
The first site has one single in one language. Most sites need more: a list of entries with their own pages, and a second language. This section adds both to the site from above: news posts in English and German.
1. Add the locale and the collection
// ginko.config.ts
import { collection, defineSite, f, single } from '@lupinum/ginko'
export default defineSite({
site: 'my-site',
name: 'my-site',
url: 'https://example.com',
timeZone: 'Europe/Vienna',
locales: ['en', 'de'], // en stays the main locale
currency: 'EUR',
pages: { home: { en: '/', de: '/de' } }, // one path per locale
bricks: {
start: single({
label: 'Start page',
source: 'files',
fields: {
title: f.text('Title'),
body: f.markdown('Text', { profile: 'basic' }),
},
}),
news: collection({
label: 'News',
source: 'files',
max: 100, // most entries allowed (up to 500)
order: { field: 'date', dir: 'desc' }, // newest first
route: { en: '/news/[slug]', de: '/de/neuigkeiten/[slug]' },
fields: {
title: f.text('Title'),
date: f.date('Date'),
slug: f.slug('Address', { from: 'title' }),
body: f.markdown('Text'),
},
}),
},
})npm run dev restarts by itself when ginko.config.ts changes. A collection has many entries. route gives each entry its own page. The path of every locale already includes its prefix, and it needs [slug]. The slug field must be called slug.
2. Write the entries and the translations
content/
start.md
start.de.md # German version of the start page
news/
opening.md # one entry, main locale
opening.de.md # the same entry in German<!-- content/news/opening.md -->
---
title: We are open
date: 2026-10-12
---
The new site is live.<!-- content/news/opening.de.md -->
---
title: Wir haben geöffnet
slug: wir-haben-geoeffnet
---
Die neue Seite ist online.A translation has the same file name plus the locale code. It holds only fields that are translatable, so the date stays in opening.md. Without slug:, the slug comes from the file name: opening. Write start.de.md the same way, with a German title and text.
3. Add the entry page
<!-- app/pages/news/[slug].vue -->
<script setup lang="ts">
definePageMeta({ i18n: { paths: { de: '/neuigkeiten/:slug()' } } })
const route = useRoute()
const post = useGinkoEntry('news', () => route.params.slug)
</script>
<template>
<article>
<h1>{{ post.data.title }}</h1>
<GinkoMarkdown :value="post.data.body" />
</article>
</template>The file sets the path of the main locale. definePageMeta sets the German path; ginko-i18n adds the prefix /de itself. A wrong slug gives a real 404.
4. List the entries and switch the language
<!-- app/pages/index.vue -->
<script setup lang="ts">
const start = useGinko('start')
const news = useGinko('news')
</script>
<template>
<main>
<h1>{{ start.data.title }}</h1>
<GinkoMarkdown :value="start.data.body" />
<ul>
<li v-for="item in news.items" :key="item.id">
<NuxtLink :to="item.url">{{ item.data.title }}</NuxtLink>
</li>
</ul>
</main>
</template><!-- app/app.vue -->
<script setup lang="ts">
const { alternates } = useI18n()
</script>
<template>
<nav aria-label="Language">
<NuxtLink v-for="a in alternates" :key="a.code" :to="a.path" :hreflang="a.language" :aria-current="a.current ? 'page' : undefined">
{{ a.code.toUpperCase() }}
</NuxtLink>
</nav>
<NuxtPage />
</template>Each item carries its address in the current locale in url, and in every locale it is translated into in urls. When an untranslated entry is listed through untranslated: 'fallback', url points to its main-language page. Use url for links to entries, not a path you build yourself. alternates is the current page in every locale, so the switcher on /news/opening leads to /de/neuigkeiten/wir-haben-geoeffnet.
Open http://localhost:3000 and http://localhost:3000/de. Both pages list the post in their language. Run npx ginko check before a build. It checks the configuration, the content files and the links between them.
What to read next
- "Bricks" explains fields, routes, visibility, and order.
- "Reading content" shows all composables and components.
- "Languages" sets up more than one language.
- "Content from files" describes Markdown bricks in detail.
- "Platform and CLI" explains release and backup.