Build a Blog with Nextly in 10 Minutes

N
Nextly Team
··4 min read

This walks through a working blog: two collections, an admin to edit them, and two pages that render them. Every snippet below is compiled against the release it names, 0.0.2-alpha.62, by a check that runs in our CI.

You need Node 20.19 or later, 22.12 or later, or 24, which is what nextly declares it supports. You also need a database: Postgres is recommended, and SQLite works without a server.

1. Create the app

npx create-nextly-app@0.0.2-alpha.62 my-blog
cd my-blog

Point it at a database by setting two variables in .env:

DB_DIALECT=postgresql
DATABASE_URL=postgresql://user:password@localhost:5432/my_blog

For SQLite instead:

DB_DIALECT=sqlite
DATABASE_URL=file:./data/my-blog.db

Nextly reads these on boot and builds the adapter itself. There is no adapter to construct in your config.

2. Model the content

Two collections: posts, and categories to group them.

// collections/Categories.ts
import { defineCollection, text, textarea } from "nextly/config";

export const Categories = defineCollection({
  slug: "categories",
  admin: { useAsTitle: "name" },
  fields: [
    text({ name: "name", required: true }),
    text({ name: "slug", required: true, unique: true }),
    textarea({ name: "description" }),
  ],
});
// collections/Posts.ts
import {
  defineCollection,
  relationship,
  richText,
  text,
  textarea,
  upload,
} from "nextly/config";

export const Posts = defineCollection({
  slug: "posts",
  admin: { useAsTitle: "title" },
  fields: [
    text({ name: "title", required: true }),
    text({ name: "slug", required: true, unique: true }),
    textarea({ name: "excerpt" }),
    richText({ name: "content" }),
    upload({ name: "featuredImage", relationTo: "media" }),
    relationship({ name: "categories", relationTo: "categories", hasMany: true }),
  ],
  // Gives every post a draft and published state, and a `status` field to
  // filter on when you read them back.
  status: true,
});

Register both:

// nextly.config.ts
import { defineConfig } from "nextly/config";

import { Categories } from "./collections/Categories";
import { Posts } from "./collections/Posts";

export default defineConfig({
  collections: [Posts, Categories],
});

3. Create the tables and start

npm run dev

The CLI reconciles your schema on boot in development. Open /admin, create the first user, and add a category and a post. Leave the post as a draft for now; you will use that in a moment.

4. Read the posts

The list page. find returns { items, meta }, and items is the page of documents:

// app/blog/page.tsx
import Link from "next/link";
import { getNextly } from "nextly";

import config from "@/nextly.config";

export default async function BlogListPage() {
  const nextly = await getNextly({ config });

  const { items: posts } = await nextly.find({
    collection: "posts",
    where: { status: { equals: "published" } },
    sort: "-createdAt",
    depth: 1,
  });

  return (
    <main>
      <h1>Blog</h1>
      {posts.map((post) => (
        <Link key={String(post.id)} href={`/blog/${String(post.slug)}`}>
          <h2>{String(post.title)}</h2>
          <p>{String(post.excerpt ?? "")}</p>
        </Link>
      ))}
    </main>
  );
}

Two things worth noticing. where: { status: { equals: "published" } } is what keeps that draft off the page: without it, drafts render publicly. And depth: 1 resolves the related categories and the uploaded image into objects rather than leaving them as ids.

The single post page:

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getNextly } from "nextly";

import config from "@/nextly.config";

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const nextly = await getNextly({ config });

  const { items } = await nextly.find({
    collection: "posts",
    where: { slug: { equals: slug }, status: { equals: "published" } },
    limit: 1,
    depth: 1,
  });

  const post = items[0];
  if (!post) notFound();

  return (
    <article>
      <h1>{String(post.title)}</h1>
    </article>
  );
}

notFound() rather than an empty page matters: an unpublished or mistyped slug should answer 404, not 200 with nothing on it.

5. Get types for your content

So far every field access has needed a cast, because Nextly does not know your collections until you ask it to look:

npx nextly generate:types

That writes a types file from your schema. Point typescript.outputFile at where you want it, import the generated types, and post.title is a string rather than unknown.

Where to go next

  • Publish the draft in /admin and watch it appear on the list page.
  • Add @nextlyhq/plugin-seo to get a meta field group on posts and a generated sitemap.
  • Read the docs for access control, hooks, and the page builder.

If a step here did not work, that is a bug in this tutorial and we would like the issue.

Start building with Nextly

Free, open source, and yours to own. No sign-up required.

Terminal