Master Guides

Tutorial: Build a Blog

From empty folder to a working blog — models, API, and UI.

This tutorial builds a small but complete blog: posts with comments, a validated JSON API, an integration test, and a Next.js interface. You’ll use generators, migrations, relationships, typed results, and the frontend api() helper.

1. Create the app#

terminal
$ master new grayskull-blog
$ cd grayskull-blog
$ master db migrate # initialize the SQLite database
$ master dev # API on :3001 (Swagger UI at /docs), web on :3000

2. Scaffold the Post resource#

terminal
$ master g scaffold post title:string body:text published:boolean

This creates the Post model (registered in AppContext.js), a RESTful postsController with validation and typed results, five routes with a typed :id(int) parameter, and a /posts page. Generate the migration and apply it — no enable step needed:

terminal
$ master db new AddPosts
$ master db migrate

The generated files:

backend/app/models/Post.js
export default class Post {
  id(db) { db.integer().primary().auto(); }
  title(db) { db.string(); }
  body(db) { db.text(); }
  published(db) { db.boolean(); }
}
backend/app/routes.js (appended)
router.route('/posts', 'posts#index', 'get');
router.route('/posts/:id(int)', 'posts#show', 'get');
router.route('/posts', 'posts#create', 'post');
router.route('/posts/:id(int)', 'posts#update', 'put');
router.route('/posts/:id(int)', 'posts#destroy', 'delete');
backend/app/controllers/postsController.js (generated, abridged)
export default class PostsController {
  static schemas = {
    create: { body: { title: { type: 'string', required: true }, body: { type: 'string', required: true }, published: { type: 'boolean' } } },
    update: { body: { title: { type: 'string' }, body: { type: 'string' }, published: { type: 'boolean' } } },
  };
  // static authorize = { create: true, update: true, destroy: true };

  constructor(requestObject) { this.requestObject = requestObject; }

  async index() { this.ok({ data: await this.db.Post.toList() }); }

  async show(obj) {
    const item = await this.db.Post.find(obj.params.id);
    if (!item) return this.notFound('Post not found');
    this.ok({ data: item });
  }

  async create() {
    const item = this.db.Post.new();
    Object.assign(item, this.model);          // validated body
    await this.db.saveChanges();
    this.created(`/posts/${item.id}`, { data: item });
  }
  // update / destroy …
}

Already the API validates input (POST /posts with an empty body → 400 with a ValidationProblemDetails body), rejects /posts/abc with 404, and documents itself at /docs.

3. Add comments with a relationship#

terminal
$ master g model Comment body:text author:string

Add the association by hand in the two models:

backend/app/models/Post.js
export default class Post {
  id(db)        { db.integer().primary().auto(); }
  title(db)     { db.string().notNullable(); }
  body(db)      { db.text(); }
  published(db) { db.boolean().default(false); }
  Comments(db)  { db.hasMany('Comment'); }
}
backend/app/models/Comment.js
export default class Comment {
  id(db)     { db.integer().primary().auto(); }
  body(db)   { db.text(); }
  author(db) { db.string(); }
  Post(db)   { db.belongsTo('Post'); }  // creates post_id
}
terminal
$ master db new AddComments
$ master db migrate

4. Customize the API#

Make index return only published posts, newest first, and show include the comments. Everything goes through this.db — the request’s own AppContext:

backend/app/controllers/postsController.js (edited actions)
// GET /posts — only published, newest first
async index() {
  const data = await this.db.Post
    .where((p) => p.published == true)
    .orderByDescending((p) => p.id)
    .toList();
  this.ok({ data });
}

// GET /posts/:id(int) — with comments
async show(obj) {
  const post = await this.db.Post
    .where((p) => p.id == $$, obj.params.id)
    .include('Comments')
    .single();
  if (!post) return this.notFound('Post not found');
  this.ok({ data: post });
}

Now add a comments endpoint. Generate a controller and give it a schema and a route:

terminal
$ master g controller comments create
backend/app/controllers/commentsController.js
export default class CommentsController {
  static schemas = {
    create: { body: { body: { type: 'string', required: true }, author: { type: 'string', required: true } } },
  };

  constructor(requestObject) { this.requestObject = requestObject; }

  // POST /posts/:id(int)/comments
  async create(obj) {
    const post = await this.db.Post.find(obj.params.id);
    if (!post) return this.notFound('Post not found');
    const comment = this.db.Comment.new();
    Object.assign(comment, this.model, { post_id: post.id });
    await this.db.saveChanges();
    this.created(`/posts/${post.id}`, { data: comment });
  }
}

The generator registered GET /comments/create; replace that line in routes.js with the nested route:

backend/app/routes.js
router.route('/posts/:id(int)/comments', 'comments#create', 'post');

5. Test it#

Scaffolded apps ship with backend/test/health.test.js; add a second file for the blog API. The test boots the real pipeline in-process and registers the context the way server.js does:

backend/test/posts.test.js
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import master from 'mastercontroller';
import AppContext from '../app/models/AppContext.js';

let client;
after(() => client && client.close());

test('create a post, comment on it, read it back', async () => {
  process.env.MC_DISABLE_DEFAULT_LOG_PROVIDER = '1';
  client = await master.createTestClient({
    root: dirname(dirname(fileURLToPath(import.meta.url))),
    app: 'app',
    environment: 'test',
    configure: (m) => { m.addScoped('db', AppContext); },
  });

  const created = await client.post('/posts', { body: { title: 'I Have the Power', body: '...', published: true } });
  assert.equal(created.status, 201);
  const id = created.json().data.id;

  const commented = await client.post(`/posts/${id}/comments`, { body: { body: 'Nice!', author: 'Orko' } });
  assert.equal(commented.status, 201);

  const shown = await client.get(`/posts/${id}`);
  assert.equal(shown.json().data.Comments.length, 1);
});
terminal
$ master db migrate --env test # schema for the test database (db/test/)
$ master test

6. Build the UI#

frontend/app/posts/page.tsx
import Link from 'next/link';
import { api } from '../lib/api';

interface Post { id: number; title: string }

export default async function PostsPage() {
  const { data } = await api<{ data: Post[] }>('/posts');
  return (
    <main style={{ padding: '2rem' }}>
      <h1>Grayskull Blog</h1>
      <ul>
        {data.map((p) => (
          <li key={p.id}><Link href={`/posts/${p.id}`}>{p.title}</Link></li>
        ))}
      </ul>
    </main>
  );
}
frontend/app/posts/[id]/page.tsx
import { api } from '../../lib/api';

interface Comment { id: number; author: string; body: string }
interface Post { id: number; title: string; body: string; Comments: Comment[] }

export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const { data: post } = await api<{ data: Post }>(`/posts/${id}`);
  return (
    <main style={{ padding: '2rem' }}>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <h2>Comments</h2>
      <ul>
        {post.Comments.map((c) => (
          <li key={c.id}><strong>{c.author}</strong>: {c.body}</li>
        ))}
      </ul>
    </main>
  );
}

7. Run it#

terminal
$ master dev

Create a post and a comment, then open http://localhost:3000/posts:

bash
curl -X POST http://localhost:3001/posts \
  -H 'Content-Type: application/json' \
  -d '{"title":"I Have the Power","body":"...","published":true}'

curl -X POST http://localhost:3001/posts/1/comments \
  -H 'Content-Type: application/json' \
  -d '{"author":"Orko","body":"Nice!"}'

8. Lock down writes (optional)#

Give the app a JWT secret and uncomment static authorize in the controllers. Writes now need a bearer token; reads stay public:

terminal
$ master secrets set Auth:JwtSecret "$(openssl rand -hex 32)"
backend/app/controllers/postsController.js
static authorize = { create: true, update: true, destroy: true };   // ASP.NET [Authorize]

Issuing tokens (a login endpoint) is covered in Authentication & Authorization.

You built a full-stack app
Models, migrations, a relational API with validation and typed results, an in-process test, and a React UI — wired together, no glue code. From here, add real-time updates with sockets, background work with hosted services, and ship it via deployment.