Introduction

What is Vueload?

Vueload 1.0 Alpha is an open-core, code-first CMS engine built exclusively for Vue, Nuxt 4, and Node.js. You define content schemas in TypeScript — Vueload introspects them at boot, syncs PostgreSQL, mounts authenticated REST endpoints, and ships a polished admin panel via Nuxt layers.

Unlike traditional CMS platforms where the content model lives in a database UI, Vueload keeps your schema in version control. Every collection, field, block model, access rule, and hook is explicit, reviewable, and type-safe. The playground in this repository demonstrates production patterns: polymorphic page layouts, media uploads, native SEO injection, and silent GEO tracking on lead capture.

  • Code-first schemas with full TypeScript inference via defineCollection
  • Automatic Postgres table sync on startup — no manual migrations for field changes
  • JWT cookie auth, role-based access control, and server-side hooks
  • REST API at /api/collections/:slug plus Nuxt admin at /admin
  • Polymorphic blocks layout engine with dynamic public page rendering
  • Enterprise SEO and GEO features wired out of the box

Architecture

Vueload is organized as a monorepo with three cooperating layers. The configuration file is the single source of truth — everything downstream derives from it.

@vueload/core

The engine. Parses vueload.config.ts, validates payloads, syncs PostgreSQL column types (including JSONB for blocks and groups), mounts Express REST routes, handles JWT auth, media uploads, and collection CRUD with hooks.

@vueload/admin-layer

A Nuxt layer providing the admin UI — collection list views, dynamic form builders, block editors, media gallery, and relationship pickers. Reads the same config your API uses; no duplicate schema definitions.

Your Nuxt app

Extends the admin layer, hosts public routes (catch-all pages, landing), and maps CMS payloads to Vue components. In production, Nitro serves both the frontend and /api/* on a single port.

Boot sequence: load config → connect Postgres → sync tables → mount API router → Nuxt serves admin + public site. Collection documents flow from admin forms or REST clients into validated JSONB/text columns, then back out through the same REST surface to your frontend composables.

Core Philosophy

Vueload follows a schema-driven, open-core model. The MIT-licensed core gives you collections, fields, auth, REST, and database sync. Premium patterns — polymorphic blocks, media pipeline, SEO groups, GEO enrichment — ship as first-class examples in the playground, ready to copy into your project.

Schema in Git

Content models are code. Review them in pull requests, branch them with features, and deploy them with your application — not through a separate CMS admin export/import cycle.

Vue-native

Built for Nuxt 4 composables, SSR cookie forwarding, and Vue SFC block renderers — not generic React widgets bolted onto a PHP backend.

Postgres-first

JSONB columns store blocks and field groups natively. Relational fields enforce foreign keys. Uploads reference a dedicated media table.

Progressive complexity

Start with text fields and a blog collection. Add blocks, SEO groups, and GEO middleware when you need them — without swapping CMS platforms.

Configuration

Collections

A collection is Vueload's fundamental content unit — one Postgres table, one REST resource, and one admin sidebar entry. Declare a slug, labels, access rules, optional hooks, and fields; the engine handles routing, validation, and persistence.

vueload.config.ts

import { defineConfig, defineCollection } from "@vueload/core";

export default defineConfig({
  db: {
    client: "pg",
    connectionString: process.env.DATABASE_URL!,
  },
  auth: {
    jwtSecret: process.env.JWT_SECRET!,
    jwtExpiresIn: process.env.JWT_EXPIRES_IN || "7d",
    cookieName: process.env.COOKIE_NAME || "vueload_token",
  },
  collections: [Users, Leads, Media, Pages],
});

Pages collection

const Pages = defineCollection({
  slug: "pages",
  labels: { singular: "Page", plural: "Pages" },
  admin: {
    useAsTitle: "title",
    defaultColumns: ["title", "slug", "updated_at"],
    group: "Content",
  },
  access: {
    read: () => true,
    create: isEditor,
    update: isEditor,
    delete: isAdmin,
  },
  timestamps: true,
  fields: [
    { name: "title", type: "text", required: true, maxLength: 160 },
    { name: "slug", type: "text", required: true, unique: true },
    { name: "layout", type: "blocks", blocks: pageLayoutBlocks },
    pageSeoGroup,
  ],
});

Each collection exposes GET/POST /api/collections/:slug and GET/PUT/PATCH/DELETE /api/collections/:slug/:id. Query filters use Payload-style syntax, e.g. where[slug][equals]=pricing. The admin UI reads the same config to render list views, forms, and relationship pickers automatically.

Field Types

Fields describe document shape. Vueload maps each type to a Postgres column, validation rules, and an admin widget. Supported types include text, textarea, number, boolean, select, date, email, json, upload, relationship, blocks, and group.

Field definitions

fields: [
  { name: "title", type: "text", required: true, maxLength: 120 },
  { name: "excerpt", type: "textarea" },
  { name: "views", type: "number", defaultValue: 0 },
  { name: "published", type: "boolean", defaultValue: false },
  {
    name: "status",
    type: "select",
    defaultValue: "draft",
    options: [
      { label: "Draft", value: "draft" },
      { label: "Published", value: "published" },
    ],
  },
  { name: "publishDate", type: "date" },
  { name: "authorEmail", type: "email" },
  { name: "metadata", type: "json" },
  { name: "cover", type: "upload", relationTo: "media" },
  { name: "author", type: "relationship", relationTo: "users" },
]

Use required, maxLength, unique, and defaultValue for constraints. Admin options like admin.hidden, admin.readOnly, and admin.description control the dashboard experience without affecting the public API shape.

Upload & Media Relations

Upload fields store a foreign key to the media collection. Enable uploads on a collection with upload: true — Vueload creates a dedicated table, handles multipart POST at /api/collections/media, and serves assets at /api/media/:id.

Media collection + upload field

const Media = defineCollection({
  slug: "media",
  upload: true,
  labels: { singular: "Asset", plural: "Media" },
  admin: {
    useAsTitle: "filename",
    defaultColumns: ["filename", "mime_type", "size", "created_at"],
    group: "Assets",
  },
  access: {
    read: () => true,
    create: isEditor,
    update: isEditor,
    delete: isAdmin,
  },
  timestamps: true,
  fields: [
    { name: "altText", type: "text", maxLength: 320 },
  ],
});

// Upload fields reference media by numeric ID:
{
  name: "heroImage",
  type: "upload",
  relationTo: "media",
  mimeTypes: ["image/*"],
}

In the admin, upload fields render a media picker modal. On the public frontend, resolve the numeric ID to a URL via fetchMedia(id) and mediaAssetUrl(path) from the useVueload composable — the same pattern used for SEO ogImage resolution.

Layout Engine

The Blocks Field

The blocks field type stores a polymorphic JSON array in PostgreSQL JSONB. Each entry has a blockType slug and a flat payload of field values — identical to Payload CMS block patterns. Editors arrange sections in the admin BlocksInput; the public site renders them in order.

Blocks are defined once as reusable models (typically in a client-safe blocks.config.ts) and referenced on any collection field:

pages.fields

{ name: "layout", type: "blocks", blocks: pageLayoutBlocks }

At write time, Vueload validates each block against its model schema — required fields, upload relations, and unknown block types are rejected before the JSONB column is updated.

Block Models

Define block models with a slug, labels, and nested fields. Hero sections carry headlines and CTA labels; Content sections carry rich text and optional media; Contact Form blocks embed lead capture without hardcoding forms into page templates.

blocks.config.ts

// blocks.config.ts — client-safe block models
import type { Block } from "@vueload/core";

export const HeroBlock = {
  slug: "hero",
  labels: { singular: "Hero", plural: "Heroes" },
  fields: [
    { name: "headline", type: "text", required: true },
    { name: "subheadline", type: "textarea" },
    { name: "image", type: "upload", relationTo: "media" },
    { name: "ctaLabel", type: "text" },
  ],
} satisfies Block;

export const ContentBlock = {
  slug: "content",
  labels: { singular: "Content", plural: "Content Sections" },
  fields: [
    { name: "heading", type: "text", required: true },
    { name: "body", type: "textarea", required: true },
    { name: "image", type: "upload", relationTo: "media" },
  ],
} satisfies Block;

export const pageLayoutBlocks = [HeroBlock, ContentBlock, ContactFormBlock];

A persisted layout array looks like this in Postgres:

pages.layout (JSONB)

// Stored as JSONB on pages.layout in PostgreSQL
[
  {
    "id": "blk_01",
    "blockType": "hero",
    "headline": "Ship content at the speed of code",
    "subheadline": "Schema-driven CMS for Vue teams.",
    "image": 42,
    "ctaLabel": "Get started"
  },
  {
    "id": "blk_02",
    "blockType": "content",
    "heading": "Why Vueload",
    "body": "Define collections in TypeScript…",
    "image": 43
  },
  {
    "id": "blk_03",
    "blockType": "contact-form",
    "heading": "Talk to us",
    "description": "We respond within one business day."
  }
]

Dynamic Frontend Route

The catch-all route app/pages/[...slug].vue fetches the page document by slug, iterates the layout array, and dynamically resolves each block to a Vue component. Unknown block types render a graceful fallback instead of crashing the page.

resolveBlockComponent.ts

// app/utils/resolveBlockComponent.ts
import HeroBlockView from "~/components/blocks/HeroBlock.vue";
import ContentBlockView from "~/components/blocks/ContentBlock.vue";
import ContactFormBlockView from "~/components/blocks/ContactFormBlock.vue";

const BLOCK_COMPONENTS: Record<string, Component> = {
  hero: HeroBlockView,
  content: ContentBlockView,
  "contact-form": ContactFormBlockView,
};

export function resolveBlockComponent(blockType: string) {
  return BLOCK_COMPONENTS[blockType] ?? null;
}

app/pages/[...slug].vue

<!-- app/pages/[...slug].vue -->
<script setup lang="ts">
const { data } = await useFetch(apiUrl); // where[slug][equals]=…
const layout = computed(() => page.value?.layout ?? []);

// SEO group → Nuxt head pipeline (see Enterprise section)
useSeoMeta({ title: () => pageTitle.value, ogImage: () => ogImageUrl.value });
</script>

<template>
  <main>
    <component
      :is="resolveBlockComponent(block.blockType)"
      v-for="(block, i) in layout"
      :key="block.id ?? i"
      :data="block"
    />
  </main>
</template>

Each block component receives its payload via a data prop. Upload fields arrive as numeric media IDs — fetch and display them inside the block view, keeping the renderer decoupled from storage details.

Enterprise Features

SEO Pipeline

Pages include a native seo field group — configured once in vueload.config.ts, edited in a dedicated SEO tab in the admin, and consumed automatically on the public catch-all route. No third-party SEO plugin or manual <Head> tags per page.

SEO field group

const pageSeoGroup = {
  name: "seo",
  type: "group",
  label: "SEO",
  fields: [
    { name: "metaTitle", type: "text", maxLength: 70 },
    { name: "metaDescription", type: "textarea" },
    {
      name: "ogImage",
      type: "upload",
      relationTo: "media",
      mimeTypes: ["image/*"],
    },
    { name: "robots", type: "text", defaultValue: "index, follow" },
  ],
} as const;

On the frontend, Nuxt 4's useHead and useSeoMeta composables map the stored values into live browser metadata — title, description, robots, canonical URL, Open Graph, and Twitter cards. The ogImage upload is resolved to an absolute URL at runtime.

Head injection in [...slug].vue

// Public catch-all reads page.seo and maps to Nuxt 4 head composables
const seo = computed(() => page.value?.seo ?? {});
const ogImageUrl = ref<string | null>(null);

watch(() => seo.value.ogImage, async (id) => {
  if (typeof id === "number") {
    const asset = await vueload.fetchMedia(id);
    ogImageUrl.value = asset ? vueload.mediaAssetUrl(asset.url) : null;
  }
}, { immediate: true });

useHead({
  title: () => seo.value.metaTitle || page.value?.title,
  meta: [{ name: "robots", content: () => seo.value.robots || "index, follow" }],
  link: [{ rel: "canonical", href: canonicalUrl }],
});

useSeoMeta({
  title: () => seo.value.metaTitle,
  description: () => seo.value.metaDescription,
  ogTitle: () => seo.value.metaTitle,
  ogDescription: () => seo.value.metaDescription,
  ogImage: () => ogImageUrl.value ?? undefined,
  twitterCard: () => (ogImageUrl.value ? "summary_large_image" : "summary"),
});
  • metaTitle<title>, og:title, twitter:title
  • metaDescriptionmeta description, og:description
  • ogImageog:image, twitter:image (large card when present)
  • robotsmeta name="robots" (defaults to index, follow)

GEO Tracking

Lead capture endpoints automatically enrich submissions with geographic footprint data — no hidden form fields, no client-side geolocation APIs, no privacy-invasive browser prompts. Vueload reads reverse-proxy headers injected by Cloudflare, Vercel, Railway, or similar CDNs at request time.

geoFromRequest.ts

// server/utils/geoFromRequest.ts
export function extractGeoFromHeaders(headers) {
  const countryCode =
    headers["cf-ipcountry"] ??
    headers["x-vercel-ip-country"] ??
    headers["cloudfront-viewer-country"];

  const cityRaw =
    headers["cf-ipcity"] ??
    headers["x-vercel-ip-city"] ??
    headers["x-city"];

  if (countryCode && cityRaw) {
    return { countryCode: countryCode.toUpperCase(), city: decodeURIComponent(cityRaw) };
  }

  // Local dev fallback when CDN headers are absent
  return { countryCode: "RS", city: "Kragujevac, Serbia" };
}

Supported headers include cf-ipcountry, cf-ipcity, x-vercel-ip-country, x-vercel-ip-city, and cloudfront-viewer-country. During local development, when no CDN headers are present, the engine falls back to Kragujevac, Serbia (RS) so you can verify the pipeline without deploying.

Lead capture enrichment

// server/routes/api/collections/leads.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const payload = enrichLeadPayload(event, body);
  // payload now includes countryCode + city before Postgres write
  const doc = await createCollectionDocument(collection, pool, collections, payload, user);
  return { doc };
});

// Leads schema — hidden fields populated server-side
{
  name: "countryCode",
  type: "text",
  admin: { hidden: true, readOnly: true },
},
{
  name: "city",
  type: "text",
  admin: { hidden: true, readOnly: true },
}

The enriched countryCode and city fields are stored on the lead record in Postgres and visible in the admin list view — giving your sales team location context on every inbound request, silently and server-side.

Installation

Quickstart

Scaffold a project, connect PostgreSQL, and launch the dev environment. Vueload syncs your schema on boot and exposes the admin at /admin.

Installation & Quickstart — coming at launch

The npx vueload init installer and full quickstart workflow will ship with the official Vueload 1.0 production release. Until then, use the live playground sandbox to explore the admin, blocks engine, SEO pipeline, and API — the steps below are a preview of the post-launch flow.

1. Initialize a project

Terminal

npx vueload init my-app
cd my-app
pnpm install

2. Configure environment & run

Terminal

cp .env.example .env
pnpm vueload create-user
pnpm run dev

The playground runs Nuxt on port 3000 with the API co-located. In production, a single Nitro process serves both the UI and /api/* on one port. Explore the live sandbox to see blocks, SEO, media uploads, and GEO tracking in action.

Open Admin Sandbox