You are browsing as a guest. Sign up (or log in) to start making projects!

← Mission home

Stardance Blog Mission

Getting Started

Hello fellow astronaut, glad to see you on board with this mission! By the end of this (hopefully easy to follow and not too long) guide, you’ll learn how to work with Next.js, TypeScript, MongoDB, Better Auth, and other important web dev technologies, to make a functional, deployed, fullstack, and completely customized blog site for yourself!

This could be a bit difficult, especially for web dev beginners and even those at an intermediate level, so feel free to DM me (@Tony) on Slack or do your own research if you encounter any issues along the way.

You can also use this minimal example repo (live demo) as a reference — but don’t just blindly copy it or your project will be rejected. Please add your own input and customize it as much as you can! You can also check out the additional suggestions at the end of this guide to further improve and make the blog site yours.

Note: this guide assumes you have a basic understanding of React, and obviously JavaScript and HTML. You don’t necessarily need to be proficient at CSS or Tailwind, since styling isn’t a focus here.

Creating the project

Next.js is a powerful modern web framework that makes developing an interactive frontend and an optimized backend super easy, so we’ll be using it for the blog.

  1. Create the template project with TypeScript:
npx create-next-app@latest
  1. Open the project, then install the dependencies you’ll need later:
npm i mongoose better-auth
  1. Start the dev server:
npm run dev

You should now see the landing page live at localhost:3000!

Basic Setup

Adding a nav bar

Our blog needs a nav bar, so put this in /components/Nav.tsx — feel free to customize it with icons, styling, whatever, go crazy:

import Link from "next/link";

function Nav() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/blogs">Blogs</Link>
      <Link href="/post">Post</Link>
      <Link href="/about">About</Link>
      <Link href="/signin">Sign in</Link>
    </nav>
  );
}

export default Nav;

Since Next.js is based on React for its frontend, you should recognize this as a React functional component. It imports Link, which is basically the <a> tag but for Next.js routing, and adds four links to the nav bar.

Displaying it on every page

Now that we have the nav bar, we need to actually display it. Go to layout.tsx, import the Nav component, and put it between the <body> tags like so:

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html
      lang="en"
      className="h-full antialiased"
    >
      <body>
        <Nav />
        {children}
      </body>
    </html>
  );
}

Think of the root layout.tsx as the base template HTML for every single page on the site, so we put globally shared components (like nav bars, footers, and sidebars) here to ensure they are on every page. The children prop is passed by Next.js internally to render each page’s different content as a React.ReactNode.

Creating the pages

Now we need to actually create those pages. The root page.tsx handles the homepage, so that’s already done. To create the Blogs, Post, and About pages, create three folders in the app directory — blogs, post, and about — each with a page.tsx file.

In Next.js’s app router, every folder that’s not wrapped in parentheses becomes a path. So the blogs folder creates the path /blogs, and if you were to create another folder called subpath inside blogs, it would create the path /blogs/subpath. The folder structure determines all the paths, which makes routing super easy and manageable.

Put this placeholder template in your page.tsx files for now:

function Page() {
  return <div>Page</div>;
}

export default Page;

Creating the dynamic blog route

We have all the major tabs created, but we still need to create each individual blog page. You could certainly create one folder containing a page.tsx for each blog post you have, but that would be extremely tedious, repetitive, and hard to manage. What if you have 100 posts — are you going to create 100 structurally identical files? Of course not.

Instead, we’ll create one dynamic page which serves as the base for all blog posts. It takes a post ID from a URL param, uses that ID to fetch blog content from the database, and renders the content on the base page.

To create a dynamic route, create the folder [id] (yes, with the brackets) in the blogs folder, and make a page.tsx file inside it, so the folder structure looks like /app/blogs/[id]/page.tsx.

The id param here can be used by the page: when you visit /blogs/5, Next.js takes 5 as the id of the post, and when you visit /blogs/some-page, some-page becomes the id param.

Adding the Database

We created all the frontend routes we’ll need for the project, but we still need to set up a database, connect to it, and handle blog management operations (create, edit, delete) with our backend.

Creating a MongoDB Atlas database

  1. Go to mongodb.com, create an account, make a new project, and create a cluster.
  2. Go with the free plan and choose a cloud provider closest to your location.
  3. Follow the setup prompts to create a new user, choose connect with driver, and copy the connection string.
  4. Paste it into the .env file at the root of your project (if you don’t have one, make it).

Your .env file should look something like this:

MONGO_URI=mongodb+srv://my_db_user:[email protected]/?appName=Cluster0

Now in the Atlas dashboard, click Database & Network Access in the sidebar, select IP Access List, and add the 0.0.0.0/0 IP to conveniently whitelist all addresses to connect to your database.

Note: this isn’t actually a security best practice, since normally you would only allow the backend’s actual IP (for example Vercel’s IP ranges), but it’s fine for small personal projects.

Finally, click finish — that’s all you need to do to create the database!

Connecting to the database

Now we actually need to connect to this database, and this is where the Mongoose library you installed earlier comes in. Create the file /lib/mongoose.ts and paste this code:

import mongoose, { Mongoose } from "mongoose";

declare global {
  var mongoose:
    | {
        conn: Mongoose | null;
        promise: Promise<Mongoose> | null;
      }
    | undefined;
}

const MONGO_URI = process.env.MONGO_URI as string;

let cached = global.mongoose;

if (!cached) {
  cached = global.mongoose = { conn: null, promise: null };
}

async function dbConnect() {
  if (cached!.conn) return cached!.conn;

  if (!cached!.promise) {
    const opts = {
      bufferCommands: false,
    };

    cached!.promise = mongoose.connect(MONGO_URI, opts).then((mongoose) => {
      return mongoose;
    });
  }

  try {
    cached!.conn = await cached!.promise;
  } catch (e) {
    cached!.promise = null;
    throw e;
  }

  return cached!.conn;
}

export default dbConnect;

I know this code looks pretty scary with some TypeScript and Node.js nuances, and you don’t need to fully understand it. Just know that every time your backend tries to connect to the database using the dbConnect function, it uses a cached connection first and only creates a new connection if there is no cache. This keeps the server connected to the database with only one connection, and prevents it from opening a new connection every time you reload.

Defining the Post schema

Mongoose serves as the “middleman” between TypeScript (your app) and MongoDB (the database). It simplifies a lot of database operations into simple methods, and provides something called schemas. A schema is basically a model or a blueprint that defines what a database object should look like.

Create the file PostModel.ts in the lib folder and code it like so:

import { Schema, model, models } from "mongoose";

const PostSchema = new Schema(
  {
    id: {
      type: String,
      required: true,
    },
    date: {
      type: Date,
      required: true,
    },
    title: {
      type: String,
      required: true,
    },
    content: {
      type: [String],
      required: true,
    },
  },
  { timestamps: true },
);

const Post = models.Post || model("Post", PostSchema);

export default Post;

Here, using Mongoose’s Schema, model, and models, we define the fields and their types of a Post object (id, date, title, content), and set them all as required. The timestamps field tells Mongoose to also keep track of the exact times the Post object is created and updated. Finally, we add the schema model to Mongoose and export it for our backend.

Setting up Auth

We’ve finally set up the backend database connection, but we still need an authentication system for you and only you (and anyone else who might be posting on the blog) to post blog posts.

Implementing a secure and functional auth system is an integral part of any app, but it is also arguably one of the most challenging aspects of web dev — so feel free to check out the Better Auth docs and the example repo as you follow along this long section.

Adding the environment variables

Remember the better-auth npm package we installed earlier? We’ll be using Better Auth as the auth library because it’s very easy to use and is fully compatible with TypeScript and Mongoose.

First, add these two environment variables to the .env file (you can generate the secret hash here):

BETTER_AUTH_URL="http://localhost:3000"
BETTER_AUTH_SECRET="<YOUR_SECRET_HASH>"

Creating the auth instance

We’ll be using email and password as the sign-in method for simplicity, but feel free to add additional methods from OAuth providers like Google, GitHub, etc. to the Better Auth instance configuration.

Create the file lib/auth.ts and paste the new instance with the MongoDB adapter, so Better Auth knows how to save user, account, and session data to the database:

import { betterAuth } from "better-auth";
import { mongodbAdapter } from "better-auth/adapters/mongodb";
import dbConnect from "./mongoose";

export async function initAuth() {
  const mongooseInstance = await dbConnect();
  const client = mongooseInstance.connection.getClient();
  const instance = betterAuth({
    database: mongodbAdapter(client.db()),
    emailAndPassword: { enabled: true },
  });

  return instance;
}

Creating the API endpoint

Now create the file app/api/auth/[...all]/route.ts and paste the following code to create an API endpoint for the frontend to send the user’s login requests and exchange cookies with the auth backend:

import { initAuth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

const auth = await initAuth();

export const { POST, GET } = toNextJsHandler(auth);

The [...all] folder acts like a catch-all route, so anything that hits the endpoint /api/auth/* can access the Better Auth GET and POST handlers located in route.ts.

Creating the client instance

We made the backend endpoint, but we still need something on the frontend to talk to it for auth requests and handshakes to work. Create the client instance at lib/auth-client.ts:

import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({});

Building the sign-in page

Now all that’s left for auth is to make a login page for you to sign in with your email and password! Create the page file app/signin/page.tsx and paste this code:

"use client";

import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { useState } from "react";

interface UserType {
  email: string;
  name: string;
  password: string;
  signUp: boolean;
}

function Page() {
  const [user, setUser] = useState<UserType>({
    email: "",
    name: "",
    password: "",
    signUp: false,
  });
  const router = useRouter();

  async function handleSignIn(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    if (user.signUp) {
      await authClient.signUp.email(
        { ...user },
        { onSuccess: () => router.push("/") },
      );
    } else {
      await authClient.signIn.email(
        { ...user },
        { onSuccess: () => router.push("/") },
      );
    }
  }

  return (
    <form onSubmit={handleSignIn}>
      <h1>Sign In</h1>
      <input
        placeholder="Email"
        value={user.email}
        onChange={(e) => setUser({ ...user, email: e.target.value })}
      />
      {user.signUp && (
        <input
          placeholder="Name"
          value={user.name}
          onChange={(e) => setUser({ ...user, name: e.target.value })}
        />
      )}
      <input
        type="password"
        placeholder="Password"
        value={user.password}
        onChange={(e) => setUser({ ...user, password: e.target.value })}
      />
      <label>
        <input
          type="checkbox"
          checked={user.signUp}
          onChange={(e) => setUser({ ...user, signUp: e.target.checked })}
        />
        Sign up
      </label>
      <button type="submit">Submit</button>
    </form>
  );
}

export default Page;

This is just a simple form with a user object state that keeps track of the login information. Depending on whether the user is signing up or signing in, it calls the Better Auth client’s sign-up or sign-in method respectively to actually sign the user in once submitted. If the operation is successful, the page then redirects the user to the homepage with the onSuccess callback using Next.js’s useRouter hook.

Try signing up for an account and you should be able to see a new User entry in the user collection in MongoDB Atlas! Better Auth should have also created the session and account collections to keep track of other data — you usually don’t have to worry about those.

Heads up: I sort of lied about the form being the last auth thing we have to do — but I actually didn’t lie. Adding the form was the last thing for implementing authentication (checking if someone’s credentials are correct and signing them in), but we still need to add authorization: actually checking whether the currently signed-in user has permission to post blog posts. We’ll be adding this when we get to the post creation page.

Creating the Backend

Now we’re finally ready to start posting some blogs with Next.js’s server actions! Server actions are basically hidden API endpoints disguised as async functions that come with many Next.js optimization, fetching, and caching features that feel like magic.

Since we’ll be posting blogs with the /post route, add the file actions.ts in the post directory:

"use server";

import Post from "@/lib/PostModel";
import dbConnect from "@/lib/mongoose";

export interface PostType {
  id: string;
  date: Date;
  title: string;
  content: string[];
}

export async function postBlog(post: PostType) {
  try {
    await dbConnect();
    const newPost = await Post.create(post);
    console.log(newPost);
  } catch (error) {
    console.error("Error: " + error);
  }
}

Remember to put the "use server" directive at the top, as it tells Next.js to treat the postBlog function as an endpoint. The file defines an interface for what the argument data should look like, establishes a connection with the database (remember the scary mongoose.ts file?), creates a new Post object following the Post Mongoose schema we defined earlier, and prints out the newly created post.

Congrats, you just finished everything you need to do for the backend! Well — not really. You’ll still need to add the edit and delete methods in this file, but I’ll leave it to you to figure that out, good luck ;)

Posting Your First Blog

Your frontend is now finally ready to talk to the backend and post some blog posts!

The post page

Open the page.tsx file in the post folder and put in this code:

import { postBlog } from "./actions";
import { initAuth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import PostForm from "@/components/PostForm";

async function Page() {
  const auth = await initAuth();
  const session = await auth.api.getSession({ headers: await headers() });
  // Replace this with your own email address!
  if (!session || session.user.email !== "YOUR_EMAIL_ADDRESS") redirect("/");

  return (
    <div>
      <h1>Create Blog</h1>
      <PostForm postBlog={postBlog} />
    </div>
  );
}

export default Page;

Remember the user authorization thing I mentioned before? The page first fetches the currently signed-in user using Next.js’s headers method (which sends cookies to Better Auth) to fetch the user data (session.user). It redirects the user back to the homepage if they’re not signed in, or if their email doesn’t match yours — preventing unauthorized users from accessing the page.

(That email check is optional. You can remove it if you want everyone to be able to post, or compare against a hardcoded array of allowed email addresses.)

Challenge: the postBlog server action is actually a public POST endpoint that currently trusts all requests without checking the current user identity. If someone sends a request to it directly without signing in, they can still post blog posts. Can you think of a way to fix this? Also, use the same logic to add something to the site (e.g. an avatar icon on the nav bar) so the user knows they’re currently signed in.

The post form

The page also imports the postBlog server action we just created, and passes it to the form component we’re about to create. Make the file PostForm.tsx in the components folder and paste this template:

"use client";

import type { PostType } from "@/app/post/actions";
import { useState } from "react";
import { redirect } from "next/navigation";

function PostForm({
  postBlog,
}: {
  postBlog: (post: PostType) => Promise<void>;
}) {
  const [newPost, setNewPost] = useState<PostType>({
    id: crypto.randomUUID(),
    date: new Date(),
    title: "",
    content: [""],
  });

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    await postBlog(newPost);
    redirect("/blogs/" + newPost.id);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Title"
        value={newPost.title}
        onChange={(e) => setNewPost({ ...newPost, title: e.target.value })}
      />
      {newPost.content.map((paragraph, i) => (
        <textarea
          key={i}
          placeholder="Content"
          value={paragraph}
          onChange={(e) => {
            const content = [...newPost.content];
            content[i] = e.target.value;
            setNewPost({ ...newPost, content: content });
          }}
        ></textarea>
      ))}
      <button
        type="button"
        onClick={() =>
          setNewPost({ ...newPost, content: [...newPost.content, ""] })
        }
      >
        Add paragraph
      </button>
      <button type="submit">Post</button>
    </form>
  );
}

export default PostForm;

This component takes in the postBlog server action as a prop (notice its type), calls it when the user submits the form, and redirects to the path of the new blog post. Other than the "use client" directive at the top — which marks this as a client component that handles React hooks and user interactions — everything else should look familiar, so I won’t explain further.

If you’ve followed the guide and set everything up correctly, when you create a new post on the /post page you should see a new Post object in the posts collection of the test database when you click Browse collections in MongoDB Atlas! Amazing job so far, we only have a little more to go before your site is fully up and running!

Making the Blog Page

The individual blog page

You may have already noticed that after you create a blog, you get redirected to an empty page. This is because we haven’t added anything to the blog page yet, so go to /app/blogs/[id]/page.tsx and paste this in:

import type { PostType } from "@/app/post/actions";
import { redirect } from "next/navigation";
import Post from "@/lib/PostModel";
import dbConnect from "@/lib/mongoose";

async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  await dbConnect();
  const post = (await Post.findOne({ id: id })) as PostType;
  if (!post) redirect("/");

  return (
    <div>
      <h1>{post.title}</h1>
      <div>{post.date.toLocaleDateString()}</div>
      <div>
        {post.content.map((paragraph, i) => (
          <p key={i}>{paragraph}</p>
        ))}
      </div>
    </div>
  );
}

export default Page;

This is a server page component (since it doesn’t have the "use client" directive at the top), which is why it can be async to await the dynamic [id] param we’re passing as a prop to this page (notice its type). We then call dbConnect to reestablish the database connection and use the Mongoose model’s findOne method to get the post with the id we passed as the param, and cast it to the PostType type. If there isn’t a post with that id in the database, we redirect the user back to the homepage.

The blogs list page

Now we just need to display all the blogs on the Blogs page! Go to /app/blogs/page.tsx and put this in:

import type { PostType } from "../post/actions";
import Post from "@/lib/PostModel";
import dbConnect from "@/lib/mongoose";
import Link from "next/link";

async function Page() {
  await dbConnect();
  const allPosts = (await Post.find().sort({ date: -1 })) as PostType[];

  return (
    <div>
      {allPosts.length > 0 ? (
        allPosts.map((post) => (
          <Link href={`/blogs/${post.id}`} key={post.id}>
            <h2>{post.title}</h2>
            <div>{post.date.toLocaleDateString()}</div>
          </Link>
        ))
      ) : (
        <Link href="/post">No blogs yet, create one here!</Link>
      )}
    </div>
  );
}

export default Page;

Similar to the blog page, we’re connecting to the database and fetching all the items in the Post Mongoose model by using the find method with no argument, sorted by created date. A Link to the post page will be displayed instead if there are no posts.

Congrats, you’ve just finished basically everything for the blog site! If you’ve followed the guide and understood everything, you now have a complete fullstack system for you to post and view blogs!

Before moving on to the last section, deployment, there are still a few more things you need to do. First of all, the homepage is still empty — adding sections for recent and popular posts and navigation would be a good idea. Adding a sidebar to the individual blog page, or sorting/filtering/searching to the blogs page, could also let your users browse through all your posts more easily. These are just a few ideas; of course you are free to add whatever else you want to make your blog more complete!

Launching your Blog

Good job building your own spaceship from those blueprints I gave you, astronaut. Now it’s finally time to launch this rocket and let other people see it!

To deploy your live Next.js blog site to the internet, you don’t actually have to buy a server or even a domain. In fact, most of the process has already been done for you!

Deploying to Vercel

We will be using Vercel, the company that built Next.js itself, as the hosting service. Because Next.js is literally their product, all you need to do is:

  1. Upload your code to a GitHub repo (git init has already been done!).
  2. Add a new project in Vercel.
  3. Allow it to access and connect to your repo.

Vercel will automatically recognize the repo as a Next.js app and try to deploy it instantly!

Adding your environment variables

However, the first initial deployment will fail, since the MongoDB connection URI we set in the .env file is sensitive (gitignored) and isn’t committed to the repo — meaning Vercel can’t access it.

  1. Click Environment Variables in the left sidebar.
  2. Click Add Environment Variable and import the .env file to securely store these secrets.
  3. Change BETTER_AUTH_URL to the actual deployed URL (it’ll be something like <your-blog-name>.vercel.app) instead of localhost.
  4. Save the variables and click redeploy for the changes to take effect.

Wait for a few minutes (check the logs for any other potential errors), go to the dashboard, and you should find your successfully deployed site and its domain. Click on it, and you’ll see the public live blog with everything working properly!

Closing Thoughts

Thank you so much for choosing this mission and staying with me for this entire duration. I’m sure this journey was quite long and you certainly encountered a few frustrating moments, but I’m also sure you learned a lot more about Next.js and backend in general, and had fun customizing and making the blog site truly your own. Making your own fullstack site from scratch is no easy feat, and you should be proud of yourself.

That’s all from me — submit your blog for review when you’re done personalizing it and adding custom assets, and go get the mission reward!

Ideas to take it further

If you want to put your newly acquired web dev skills to good use, here are some potential suggestions and ideas for further improving your blog site, in no particular order:

  1. Implement a global search bar for blog posts that searches by title, content, tags, and other stuff.
  2. Add post engagement features like liking, commenting, and sharing.
  3. Right now the user can still click the nav bar sign-in link and go to the sign-in page (and either sign in or sign up) even when they’re already logged in — fix this.
  4. If a user tries to sign in without an account, inputs the wrong credentials, or if there’s a backend issue, nothing happens. Add an error warning to the login form.
  5. Add a way for users to log out.
  6. Add responsive design so the site looks and works well on all screen sizes.
  7. Add Markdown/MDX support for creating blog posts, to make the content more interesting and not just plain text.
  8. Add dark/light modes and a button to switch between them.
  9. Add a favicon to the blog site.
  10. Modify the auth system so certain accounts can also log in and post their own or collaborate on blog posts.
  11. Add tags to help people sort and filter blog posts more easily.
  12. Add image uploads so you can create thumbnails and insert photos in your posts.
  13. Add dynamically generated SEO for individual blog post pages, and static metadata to other pages, so search engines recognize your blog site.
  14. Make blog post URL paths more readable by using a “slug” instead of the UUID as the param.
  15. Make the 404 page more interesting by adding and customizing the app/not-found.tsx file.
  16. Instead of using Mongoose and MongoDB, use an ORM (e.g. Prisma or Drizzle) with a SQL database (e.g. PostgreSQL or MySQL) for relations, advanced operations, and native type safety.