Build a Non-Profit App with Next.js and Cosmic
There are a number of local and global issues happening right now and most of the time it feels like there's not much we can do to help. But there's always something we can do!
That's why we're going to build a simple non-profit app that will show off potential students and their stories and it will allow anyone who wants to donate to do so using Stripe. By the time you finish this tutorial, you'll have a basic template for a student-donor website that uses modern tools to build quickly and scale easily.
Tools we’ll be using
To build this app, we’re going to use the following technologies:
- Next.js - A React framework that makes it easy to spin up a full-stack application.
- Cosmic - A headless CMS that gives us the ability to manage our student and donation data quickly.
- Stripe - A payment system that will allow us to accept donations.
- Tailwind CSS - A style framework that lets us make responsive layouts.
TL;DR
Install the App Template
View the live demo
Check out the code
Making a Cosmic account
The first thing you'll need to set up is a free Cosmic account. Then you'll be prompted to create a new project. Make sure that you select the "Start from scratch" option. The name of the project is non-profit-cms
, but feel free to call it anything you want. You can leave the bucket environment as "Production".
Next, we'll need to make a few object types for our donors and students. In the Cosmic dashboard, go to "Add Object Type". You'll see this screen.
Make sure you choose the "Multiple" object option. You only have to fill in the "Singular Name" with Donor
and the other two fields auto-generate. Further down, we need to define the metafields in the "Content Model".
We'll have a few different fields for our donors: a student name, donation amount, the session id from Stripe, and optionally, a donor name and message. You should have the following metafields when you're done.
We'll add new donor objects every time a donation is made through Stripe and then we'll be able to show donations for each student once we start building the Next app. Before we get there, let's finish up the object types we'll need by adding another type called Student
.
You'll go back to your Cosmic dashboard and create a "New Object Type". It will also have the "Multiple" type and this time the "Singular Name" will be Student
. Once again, we need to create some metafields for this object type. So scroll down to the "Content Model" section and add these metafields: the student name, a major, a university, their story, and a headshot. Here's what all of the metafields should look like when you're finished.
Now when you get data for your students and donors, you should see something similar to this for the students in your dashboard.
And something similar to this for the donors in your dashboard.
That's all we need to get everything set up in Cosmic.
Getting some values for the Next app
Now that we have Cosmic configured as we need, let's get a few environment variables we'll need for the Next app we are about to build. Go to your Cosmic Dashboard and go to Bucket > Settings > API Access
. This will give you the ability to access, read, and write to your Cosmic project. We'll be working with the students and donors so that we're able to maintain good records of who to send the proper student updates.
Before we make the Next project, there's one more service we need to get configured correctly. We need to have Stripe so that we can accept donations.
Setting up your Stripe account
You'll need to go to the Stripe site to create a free account. The main things you'll want to make sure of here are that your dashboard is left in test mode and that you add a "Public business name" in Settings > Account Details
.
Now that your dashboard is configured, you can get the last two environment variables we'll need for the app. Go to [Developers > API keys](https://dashboard.stripe.com/test/apikeys)
and get your Publishable key
and Secret key
.
With these values, we're ready to make this Next app.
Setting up the Next.js app
Lucky for us, there is a yarn
command to generate a new Next app with the configs in place. That way we can just jump into writing code. To generate this project, run the following command in your terminal:
$ yarn create next-app --typescript
Then we can add the packages we'll be working with the following command:
$ yarn add cosmicjs tailwindcss stripe postcss @heroicons/react
There's just one last piece of setup we need to do before we can dive into the code.
Adding the .env file
Remember those values we grabbed from our Cosmic dashboard and our Stripe dashboard? We're going to add them to the project in a .env
file. At the root of the project, add a new .env
file. Inside that file, add the following values:
# .env
READ_KEY=your_cosmic_read_key
WRITE_KEY=your_cosmic_write_key
BUCKET_SLUG=your_cosmic_bucket_slug
STRIPE_SECRET_KEY=your_stripe_secret_key
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key
With all of these values finally in place, we can get to the fun part of building the app.
Setting up Tailwind CSS
In order to take advantage of the Tailwind CSS package we install, there are a few configurations we need to add. There should be a tailwind.config.js
file in the root of your project. Open that file and replace the existing code with the following.
// tailwind.config.js
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
container: {
center: true,
},
fontFamily: {
"sans": ["Helvetica", "Arial", "sans-serif"],
}
},
plugins: [],
}
Now take a look in the styles
folder and you should see a global.css
file. This is how we will enable TailwindCSS in our project. Open this file and replace the existing code with the following.
// global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
That's all we need for our styles to work. Of course, you could write the CSS yourself, but sometimes it's just as good to go with an existing styles package.
A couple of utility components
Now that we can style the app, let's add a few components that will help tie the pages together. We're going to add a navigation bar so that we can get back to the home page all the time and there will be a branding footer so that you can always show the name of your organization. At the root of the project, add a new folder called components
.
We'll start by making the navigation, so inside the components
folder add a new file called Navigation.tsx
. This will render a link back home. Add the following code to create this component.
// Navigation.tsx
import Link from 'next/link'
import { HomeIcon } from '@heroicons/react/solid'
export default function Navigation() {
return (
<header className="p-4 border-b-2">
<Link passHref href={'/'}>
<div className="flex hover:cursor-pointer gap-2">
<HomeIcon className="h-6 w-6 text-blue-300" />
<div>Home</div>
</div>
</Link>
</header>
)
}
The last little component we need to add is the footer. In the components
folder, add a new file called Footer.tsx
. This will render some text and an icon image at the bottom of each page. In this new file, add the following code.
// Footer.tsx
export default function Footer() {
return (
<footer className="p-4 border-t-2">
<a
href="https://www.cosmicjs.com?ref=non-profit-cms"
target="_blank"
rel="noopener noreferrer"
>
<div className="flex gap-2">
<div>Powered by</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt="Cosmic logo"
src="https://cdn.cosmicjs.com/049dabb0-8e19-11ea-81c6-b3a804bfff46-cosmic-dark.png"
width="100"
height="100"
/>
</div>
</a>
</footer>
)
}
Those are the only two components that we needed to make. Now you'll need to update your _app.tsx
file to include the Footer
component. That way it will show on every page of the app. So open this file and update the existing component to match this:
// _app.tsx
...
import Footer from '../components/Footer'
function MyApp({ Component, pageProps }: AppProps) {
return (
<div className="flex flex-col h-screen justify-between">
<Component {...pageProps} />
<Footer />
</div>
)
}
...
Notice that there is a new import statement and that the whole app is now wrapped in a styled div
that also contains that footer element. We're only adding the Navigation
element to individual student pages, which we'll get to later.
Displaying all of the students
We can start working on the Next app to display all of the students to anyone who visits the website. We'll start by updating the existing index.tsx
file to import and use Cosmic to pull in the student data. So add the following code right below the existing imports in the file.
// index.tsx
...
import Cosmic from 'cosmicjs'
const api = Cosmic()
const bucket = api.bucket({
slug: process.env.BUCKET_SLUG,
read_key: process.env.READ_KEY,
})
...
Then, we'll need to add the getStaticProps
function to fetch the student data from Cosmic:
// index.tsx
...
export async function getStaticProps() {
const query = {
type: 'students',
}
const studentsReq = await bucket.getObjects({ query })
const students: Student[] = studentsReq.objects
return {
props: {
students,
},
}
}
...
This function only runs at build time for a page, so you won't be making a request each time. Inside this function, we're defining the query
that we'll send in the Cosmic request. Then we make the request to the bucket
we defined earlier and we get all of the student objects returned. Finally, we send the students
array to the props of the page component.
Now that we have this data, we can render some elements to the home page. You can remove all of the current code that's inside the Home
component and replace it with the following:
// index.tsx
...
const Home: NextPage = ({ students }) => {
if (!students) {
return <div>Loading our incredible students...</div>
}
return (
<div>
<Head>
<title>Student Raiser</title>
<meta
name="description"
content="A website dedicated to helping students receive the funding they need for college"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<h1 className="px-11 pt-11 text-2xl">Students in your area</h1>
<div className="flex flex-wrap gap-4 p-11">
{students.map((student: Student) => (
<div
className="hover:cursor-pointer w-64"
key={student.metadata.name}
>
<Link
passHref
href={`/student/${encodeURIComponent(student.slug)}`}
>
<div
key={student.slug}
className="border-2 rounded max-w-sm rounded overflow-hidden shadow-lg"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${student.metadata.student_headshot.imgix_url}?w=400`}
alt={student.metadata.name}
className="w-full"
style={{ backgroundPosition: 'cover' }}
/>
<div className="p-4">
<div className="text-amber-800 p-1">
{student.metadata.name}
</div>
<div className="border-b-2 p-1">
{student.metadata.major}
</div>
<div className="p-1">{student.metadata.university}</div>
</div>
</div>
</Link>
</div>
))}
</div>
</main>
</div>
)
}
...
It's mapping over the students
array to create an element to highlight each student. Each of these elements can be clicked on to learn more about a particular student and that's the page we're going to work on now. We'll come back and add more styling, but if you run the app with yarn dev
, you should see something similar to this.
Making a page for individual students
Now, we'll use Next's built-in dynamic routing to create pages for each student. Go ahead and add a new folder in the pages
directory called student
. Inside that folder, add a new file called [name].tsx
.
Let's start by adding the imports we'll need to get this page working. At the top of the [name].tsx
file, add the following lines.
// [name].tsx
import { useEffect, useState } from 'react'
import Cosmic from 'cosmicjs'
import { Donor, Student } from '../../types'
import Navigation from '../../components/Navigation'
import {
BadgeCheckIcon,
ExclamationIcon,
UserCircleIcon,
UserIcon,
} from '@heroicons/react/solid'
...
Don't worry about the types
file yet. We'll be adding that shortly. For now, let's add a skeleton for the Student
component below our imports.
// [name].tsx
...
function Student({ student, donors }) {
return (
<>
<h2 className="container text-3xl py-8">{student.metadata.name}</h2>
</>
)
}
export default Student
We'll be adding a lot more to this component, but we have to get the student
and donors
data first. We'll use the getServerSideProps
function to pull the data for a specific student from Cosmic each time this route is called. None of this is happening in the browser, so the data is still secure.
// [name].tsx
...
export async function getServerSideProps(context) {
const slug = context.params.name
const studentRes = await bucket.getObjects({
props: 'metadata,id',
query: {
slug: slug,
type: 'students',
},
})
const student: Student = studentRes.objects[0]
try {
const donorsRes = await bucket.getObjects({
props: 'metadata',
query: {
type: 'donors',
'metadata.student': slug,
},
})
let total
const donors: Donor[] = donorsRes ? donorsRes.objects : null
if (donors.length === 1) {
total = donors[0].metadata.amount
} else {
total = donors
.map((donor) => donor.metadata.amount)
.reduce((prev, curr) => prev + curr, 0)
}
return {
props: {
student,
donors,
total,
},
}
} catch {
return {
props: {
student,
donors: null,
total: 0,
},
}
}
}
Then we'll pass this data to the component to highlight a specific student to the users and potential donors. In the Student
component, we're going to do a few things. First, we'll check to see if the student page has been accessed via a redirect from the Stripe checkout page. Then we'll display the student info we have stored in Cosmic. Next, we'll have a form for donors to fill out if they want to make a donation to this particular student. Finally, we'll have a list of all the donors for this particular student.
So you can replace the outline of the Student
component with the following, complete code.
// [name].tsx
...
function Student({ student, donors, total }) {
const [query, setQuery] = useState<string>('')
useEffect(() => {
// Check to see if this is a redirect back from Checkout
const query = new URLSearchParams(window.location.search)
if (query.get('success')) {
setQuery('success')
console.log('Donation made! You will receive an email confirmation.')
}
if (query.get('canceled')) {
setQuery('canceled')
console.log(
'Donation canceled -- something weird happened but please try again.'
)
}
}, [])
return (
<div>
<Navigation />
{query === 'success' && (
<div
className="bg-green-100 rounded-lg py-5 px-6 mb-3 text-base text-green-700 inline-flex items-center w-full"
role="alert"
>
<BadgeCheckIcon className="w-4 h-4 mr-2 fill-current" />
Donation made! You will receive an email confirmation.
</div>
)}
{query === 'canceled' && (
<div
className="bg-yellow-100 rounded-lg py-5 px-6 mb-3 text-base text-yellow-700 inline-flex items-center w-full"
role="alert"
>
<ExclamationIcon className="w-4 h-4 mr-2 fill-current" />
Donation canceled -- something weird happened but please try again.
</div>
)}
<h2 className="container text-3xl py-8">{student.metadata.name}</h2>
<div className="container flex gap-4">
<div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${student.metadata.student_headshot.imgix_url}?w=800`}
alt={student.metadata.name}
style={{ backgroundPosition: 'cover' }}
/>
<div className="container border-y-2 my-4">
<p className="font-bold pt-4 px-2">
Major: {student.metadata.major}
</p>
<p className="font-bold border-b-2 pb-4 px-2">
University: {student.metadata.university}
</p>
<p className="py-4 px-2">{student.metadata.story}</p>
</div>
</div>
<div>
<p className="font-bold text-xl pb-4">Total raised: ${total}</p>
<form
action="/api/donation"
method="POST"
className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
>
<input name="student_id" type="hidden" value={student.id} />
<div className="mb-4">
<label
className="block text-gray-700 text-sm font-bold mb-2"
htmlFor="amount"
>
Donation Amount
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
name="amount"
type="number"
defaultValue={100}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Name
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
name="name"
type="text"
defaultValue="Anonymous"
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2">
Message
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
name="message"
type="text"
defaultValue="Good Luck!"
/>
</div>
<div>
<button
type="submit"
role="link"
className="hover:bg-lime-400 text-white font-bold py-2 px-4 border-b-12 border-lime-700 hover:border-lime-500 rounded-full text-lg bg-lime-500 w-64 mt-6 mx-8"
>
Make a Donation
</button>
</div>
</form>
<div className="flex flex-col gap-4 pt-4 w-full">
{donors ? (
donors.map((donor: Donor) => (
<div
key={donor.slug}
className="border-b-2 rounded p-4 w-64 flex gap-4"
>
<UserCircleIcon className="h-12 w-12 text-green-300" />
<div>
<p>{donor.metadata.name}</p>
<p>${donor.metadata.amount}</p>
</div>
</div>
))
) : (
<div className="border-2 rounded p-4 w-64 flex gap-4">
<UserCircleIcon className="h-12 w-12 text-green-300" />
<p>Be the first donor!</p>
</div>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-4 p-11 w-full">
<h2 className="text-xl font-bold">Encouragement</h2>
{donors ? (
donors.map((donor: Donor) => (
<div
key={donor.slug}
className="flex flex-col border-b-2 rounded p-4 gap-4"
>
<div className="w-64 flex gap-4 w-full">
<UserIcon className="h-12 w-12 text-green-300" />
<div className="flex flex-col">
<p>{donor.metadata.name}</p>
<p>${donor.metadata.amount}</p>
</div>
</div>
<p>{donor.metadata.message}</p>
</div>
))
) : (
<div>You can do it!</div>
)}
</div>
</div>
)
}
...
If you run the app now with yarn dev
you should see something similar to this for one of the students.
Now that we've gotten all of the functionality filled out, let's go ahead and add that types.ts
file so that we don't get any TypeScript errors.
Adding the types file
Having defined types for our data helps us know when APIs have changed and we won't get left with as many unexpected errors in production. At the root of your project, create a new file called types.ts
and add the following code:
// types.ts
export interface Student {
metadata: {
name: string
student_headshot: {
url: string
imgix_url: string
}
major: string
university: string
story: string
}
slug: string
}
export interface Donor {
slug: string
metadata: {
name: string
amount: number
message: string
}
}
This helps us define the data that we expect to use from our API calls to Cosmic.
Adding the Stripe checkout functionality
The last thing we need to add is the API that gets called with the donation form is submitted and we're going to use Stripe to handle this. If you look in the pages > api
directory in your project, you'll see a file called hello.ts
. You can delete this placeholder file and create a new file called donation.ts
.
Let's open this new file and the following imports.
// donation.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import Cosmic from 'cosmicjs'
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY)
Since we only have to handle one POST request, our handler function can be relatively simple. We'll do a quick check to make sure a POST request is being made. If any other type of request is made, then we will throw an error.
After that request check, we'll make a try-catch statement that will first see if we can make a connection to our Cosmic bucket to add a new donor. After that, we make a checkout session with Stripe using the form information passed from the front-end. Then we get the session from Stripe to add their data to Cosmic.
Lastly, we create the metafield data to add a new donor to our Cosmic dashboard and use the addObject
method to make sure this donor gets written to the correct object. Go ahead and add the following code to do all of this work.
// donation.ts
...
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'POST') {
try {
const api = Cosmic()
const bucket = api.bucket({
slug: process.env.BUCKET_SLUG,
read_key: process.env.READ_KEY,
write_key: process.env.WRITE_KEY,
})
const { student_id, amount, name, message } = req.body
const student = (
await bucket.getObject({ id: student_id, props: 'id,title,slug' })
).object
// Create Checkout Sessions from body params.
const session = await stripe.checkout.sessions.create({
line_items: [
{
amount: amount * 100, // Cents
currency: 'usd',
quantity: 1,
name: `Donation - ${student.title}`,
},
],
mode: 'payment',
success_url: `${req.headers.referer}/?success=true`,
cancel_url: `${req.headers.referer}/?canceled=true`,
})
const donorParams = {
title: name,
type: 'donors',
metafields: [
{
title: 'Name',
type: 'text',
value: name,
key: 'name',
},
{
title: 'Student',
type: 'text',
value: student.slug,
key: 'student',
},
{
title: 'Amount',
type: 'number',
value: Number(amount),
key: 'amount',
},
{
title: 'Message',
type: 'text',
value: message,
key: 'message',
},
{
title: 'Stripe Id',
type: 'text',
value: session.id,
key: 'stripe_id',
},
],
}
await bucket.addObject(donorParams)
res.redirect(303, session.url)
} catch (err) {
res.status(err.statusCode || 500).json(err.message)
}
} else {
res.setHeader('Allow', 'POST')
res.status(405).end('Method Not Allowed')
}
}
Finished code
You can find all of the code for this project in this repo.
Deploy this to Vercel
You can deploy this template to Vercel by clicking here.
Conclusion
Now you have a fully-integrated donation website that you can customize for any type of fundraiser-donor non-profit. Feel free to clone this and change the styles to match your own organization's needs.
Subscribe to my newsletter
Read articles from Milecia McGregor directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by