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 JSON API, and a Next.js interface. You’ll use generators, migrations, relationships, and the frontend api() helper.

1. Create the app#

terminal
$ master new grayskull-blog
$ cd grayskull-blog
$ master db migrate # initialize the SQLite database

2. Scaffold the Post resource#

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

This creates the Post model, a RESTful postsController, five routes, and a /posts page. Generate the migration and apply it:

terminal
$ master db new AddPosts
$ master db migrate

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. A custom API action#

Add an endpoint that returns a post with its comments:

backend/app/controllers/postsController.js
import db from '../models/db.js';

export default class PostsController {
  constructor(requestObject) { this.requestObject = requestObject; }

  // GET /posts  — only published, newest first
  async index() {
    const data = await db.Post
      .where((p) => p.published == true)
      .orderBy((p) => p.id, 'desc')
      .toList();
    this.returnJson({ data });
  }

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

  // POST /posts
  async create(obj) {
    const post = db.Post.new();
    Object.assign(post, obj.params.formData || {});
    await post.save();
    this.returnJson({ data: post });
  }
}

Register the routes (the scaffold added most; ensure show is mapped):

backend/app/routes.js
router.route('/posts', 'posts#index', 'get');
router.route('/posts/:id', 'posts#show', 'get');
router.route('/posts', 'posts#create', 'post');

5. 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>
  );
}

6. Run it#

terminal
$ master dev

Create a post and watch it appear:

bash
curl -X POST http://localhost:3001/posts \
  -H 'Content-Type: application/json' \
  -d '{"title":"I Have the Power","body":"...","published":true}'
You built a full-stack app
Models, migrations, a relational API, and a React UI — wired together, no glue code. From here, add auth with an action filter, real-time updates with sockets, and ship it via deployment.