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-blogPoint it at a database by setting two variables in .env:
DB_DIALECT=postgresql
DATABASE_URL=postgresql://user:password@localhost:5432/my_blogFor SQLite instead:
DB_DIALECT=sqlite
DATABASE_URL=file:./data/my-blog.dbNextly 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 devThe 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:typesThat 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
/adminand watch it appear on the list page. - Add
@nextlyhq/plugin-seoto 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.