Docs/Tutorials

Tutorial: build a small content application

This tutorial composes Veap's kernel and database package in a Next.js App Router project. It is deliberately limited to APIs observed in the repositories: the kernel bootstraps…

4 min read

This tutorial composes Veap's kernel and database package in a Next.js App Router project. It is deliberately limited to APIs observed in the repositories: the kernel bootstraps providers, and the database package exposes an Active Record Model over Knex. It does not assume that Veap generates Next.js routes or UI for you.

1. Install dependencies#

bash
npm install @veap/kernel @veap/database better-sqlite3

Use SQLite for local development only if the native driver is supported by your Node/Bun version and operating system. For a persistent production deployment, configure PostgreSQL and install pg.

2. Create the server-only composition root#

Create src/server/veap.ts:

ts
import "server-only";

import { Application } from "@veap/kernel";
import { DatabaseServiceProvider } from "@veap/database";

const application = Application.configure()
  .use(DatabaseServiceProvider)
  .create();

let bootstrapPromise: Promise<void> | undefined;

export async function ensureVeap(): Promise<void> {
  bootstrapPromise ??= application.bootstrap().catch((error) => {
    bootstrapPromise = undefined;
    throw error;
  });
  await bootstrapPromise;
}

The local promise avoids repeating the application's bootstrap from every server action or route handler in the same process. The kernel also deduplicates bootstrap globally, but the local helper makes the call site explicit.

3. Configure the database#

For local SQLite:

dotenv
DATABASE_URL=./dev.sqlite

The database provider checks its explicit constructor config first, then ConfigService, DATABASE_URL, and DATABASE_FILE. If no value is configured, its implementation falls back to dev.sqlite. Do not rely on that fallback in production.

For PostgreSQL:

dotenv
DATABASE_URL=postgres://USER:PASSWORD@HOST:5432/DATABASE

Use a secret manager or deployment environment settings for credentials. Never commit a real connection string.

4. Create a model#

Create src/server/models/Article.ts:

ts
import { Model } from "@veap/database";

export class Article extends Model {
  static table = "articles";
  static fillable = ["title", "slug", "body", "published"];

  declare title: string;
  declare slug: string;
  declare body: string;
  declare published: boolean;
}

The table property is static table. Define your database schema separately; declaring a model does not create its table.

5. Create the table with a migration#

Veap's migration runner accepts migration objects with name, up(db, schema), and optional down(db, schema). The runner provides a Knex-compatible database adapter and a Schema helper. For application-owned migrations, the database package exposes runMigrations(scope, migrations) and rollbackMigrations(scope, migrations) from its migration entry point.

ts
// src/server/migrations/001_create_articles.ts
import type { Migration } from "@veap/database/migrations";

export const createArticles: Migration = {
  name: "001_create_articles",
  async up(db) {
    await db.schema.createTable("articles", (table: any) => {
      table.string("id").primary();
      table.string("title").notNullable();
      table.string("slug").notNullable().unique();
      table.text("body").notNullable();
      table.boolean("published").notNullable().defaultTo(false);
      table.timestamp("created_at").notNullable().defaultTo(db.fn.now());
      table.timestamp("updated_at").notNullable().defaultTo(db.fn.now());
    });
  },
  async down(db) {
    await db.schema.dropTableIfExists("articles");
  },
};

Run application migrations only after the database provider has booted:

ts
import { runMigrations } from "@veap/database/migrations";
import { createArticles } from "./001_create_articles.js";

await ensureVeap();
await runMigrations("app", [createArticles]);

Migration helper signatures are part of the database package's migration subpath. Keep migration names stable once applied. The migration runner records names by scope and rolls back the latest batch, not an arbitrary named migration.

6. Query the model in a server action#

ts
"use server";

import { ensureVeap } from "@/server/veap";
import { Article } from "@/server/models/Article";

export async function listPublishedArticles() {
  await ensureVeap();

  return Article.query()
    .where("published", true)
    .orderBy("created_at", "desc")
    .get();
}

Only return serializable data from a Server Action. Model instances can contain internal state and methods; map the result to a plain object shape if your UI boundary requires it.

7. Add a page#

tsx
// app/articles/page.tsx
import { listPublishedArticles } from "./actions";

export default async function ArticlesPage() {
  const articles = await listPublishedArticles();

  return (
    <main>
      <h1>Articles</h1>
      <ul>
        {articles.map((article) => (
          <li key={String(article.id)}>{article.title}</li>
        ))}
      </ul>
    </main>
  );
}

Keep request-specific authorization inside the server-side data access path. Do not expose administrative or unpublished content simply because a component is server-rendered.

8. Verify before production#

  • Run migrations explicitly during deployment or a controlled release job.
  • Test the migration against the same database engine used in production.
  • Add validation and authorization to create/update actions.
  • Add indexes based on real query patterns.
  • Configure backups and a persistent database service.
  • Run package builds and tests, and compile the example against the exact locked package versions.

See the database guide, transactions, and production checklist.

veap
Preparing0%