This content originally appeared on flaviocopes.com and was authored by flaviocopes.com
In this project I’m going to create a Web Application using Next.js.
To goal is to build an app where people can view the latest posts from a list of blogs we selected.
Think of this like “I select a list of sources and I create a website to automatically aggregate the latests content from them”
This is the end result we’ll get to:
People can click the “Add a new blog” link and submit a new blog to us, the admins, though a form:
We’ll store the data using Airtable as our backend of choice, and we’ll use it to approve the blogs proposed by our users.
The list of blog posts will be fetched from the blog posts RSS/Atom feeds.
And here comes into play one feature of Next.js that will be at the core of our app: the home page and the form pages will be static pages, generated at build time, but at the same time our API will be handled by the server-side code.
We must generate the home page at build time, in particular, because otherwise we’d quickly hit the Airtable API limits, and also it would take a lot of time to keep parsing the RSS feeds, and we’d be causing a waste of resources.
After the app is deployed on Now we’ll be able to set up a deploy hook to rebuild the application every hour, for example.
Start with create-next-app
Go into the folder you use for your projects, and run npx create-next-app
.
You’ll be asked a name, you can use project-next-blogposts
Wait until the app is ready:
Now run
cd project-next-blogposts
then run
npm run dev
to start the development environment. This will make the sample application ready on port 3000:
The homepage
The sample Next.js app that’s been created by create-next-app
contains one page: pages/index.js
.
Open this file, remove everything that’s returned by the component, and get it to a bare bones state:
import Head from 'next/head'
const Home = () => (
<div></div>
)
export default Home
I’m now going to add some HTML I’ve taken from the Tailwind UI repository of UI components, so we have a good structure to start from:
import Head from 'next/head'
const Home = () => (
<div>
<Head>
<title>Latest posts</title>
<link rel='icon' href='/favicon.ico' />
<link
rel='stylesheet'
href='https://cdn.jsdelivr.net/npm/@tailwindcss/ui@latest/dist/tailwind-ui.min.css'
/>
</Head>
<div>
<header className='bg-white shadow'>
<div className='max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8'>
<div className='flex justify-between'>
<h1 className='text-3xl font-bold leading-tight text-gray-900'>
Latest posts
</h1>
</div>
</div>
</header>
<main>
<div className='max-w-7xl mx-auto py-6 sm:px-6 lg:px-8'>
<div className='px-4 py-4 sm:px-0'>
<div className='border-4 rounded-lg'>
<div className='flex flex-col'>
<div className='-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8'>
<div className='align-middle inline-block min-w-full shadow overflow-hidden sm:rounded-lg'>
<table className='min-w-full'>
<thead>
<tr>
<th className='px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider'>
Post
</th>
<th className='px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider'>
Date
</th>
</tr>
</thead>
<tbody className='bg-white'>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
)
export default Home
I included the Head
component from next/head
, so we can add the Tailwind UI CSS file, and added some static HTML.
Here’s the result:
Fetch data from the RSS feed
Now we need to feed the component some data.
To start with, I’m going to use my own blog RSS feed, available at https://flaviocopes.com/index.xml.
Install the rss-parser npm library:
npm install rss-parser
and at the top of index.js
add
import Parser from 'rss-parser'
Now create a new exported getStaticProps
function from the component:
export async function getStaticProps(context) {
}
We use this function to export an object that contains a props
property. This will be feeded to the component:
//...
const Home = (props) => (
)
export async function getStaticProps(context) {
return {
props: {
//...
}
}
}
export default Home
In particular, we’ll be exporting a posts
array that contains the lists of posts we want to display.
We use the parser library we installed to get the posts from https://flaviocopes.com/index.xml
, like this:
export async function getStaticProps(context) {
const parser = new Parser()
const data = await parser.parseURL('https://flaviocopes.com/index.xml')
const posts = []
data.items.slice(0, 10).forEach((item) => {
posts.push({
title: item.title,
link: item.link,
date: item.isoDate,
name: 'Flavio Copes'
})
})
return {
props: {
posts
}
}
}
Now in the component JSX we iterate through the posts, in the table body, and we print their data:
<tbody className='bg-white'>
{props.posts
.sort((a, b) => new Date(b.date) - new Date(a.date))
.map((value, index) => {
return (
<tr key={index}>
<td className='px-6 py-4 whitespace-no-wrap border-b border-gray-200'>
<div className='flex items-center'>
<div className='ml-4'>
<div className='text-sm leading-5 font-medium text-gray-900 underline'>
<a href={value.link}>{value.title}</a>
</div>
<div className='text-sm leading-5 text-gray-500'>
{value.name}
</div>
</div>
</div>
</td>
<td className='px-6 py-4 whitespace-no-wrap border-b border-gray-200'>
<div className='text-sm leading-5 text-gray-900'>
{new Date(value.date).toDateString()}
</div>
<div className='text-sm leading-5 text-gray-500'></div>
</td>
</tr>
)
})}
</tbody>
Notice how I use the sort()
function to order the posts by date. The feed should already return posts in the order we want, but this will be more useful as we’ll get data from multiple feeds later on.
Here is the result so far:
Thanks to Tailwind, this is automatically nicely formatted just by adding classes to the JSX.
This is a great starting point for the rest of our application.
We now need to get multiple feeds, so we can get data from multiple sources.
To do so, we’re going to use Airtable.
Create the Airtable database
Airtable is one of my favorite go-to solutions as a quick database for prototypes and small apps.
The reason I like it is that we don’t have to setup anything, and it has a nice API.
Create a new table, and add 6 fields:
name
email
blogurl
feedurl
notes
approved
All text fields, except approved which is a checkbox.
We’ll be able to manually approve blogs from the Airtable interface, simply by clicking the column.
We’re going to fill the Airtable table by letting our users submit new blogs, which will be added through a form.
Let’s build the form!
Create the form
Create a new page in Next.js, in pages/form.js
:
export default function () {
return (
<div>
</div>
)
}
Try and visit http://localhost:3000/form
, you’ll see an empty page.
Like we did before, let’s add some JSX to generate the HTML structure of the page:
import Head from 'next/head'
export default function () {
return (
<div>
<Head>
<title>Add new blog</title>
<link rel='icon' href='/favicon.ico' />
<link
rel='stylesheet'
href='https://cdn.jsdelivr.net/npm/@tailwindcss/ui@latest/dist/tailwind-ui.min.css'
/>
</Head>
<div>
<header className='bg-white shadow'>
<div className='max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8'>
<div className='flex justify-between'>
<h1 className='text-3xl font-bold leading-tight text-gray-900'>
Add new blog
</h1>
</div>
</div>
</header>
<main>
<p className='text-center pb-5'></p>
<div className='max-w-7xl mx-auto sm:px-6 lg:px-8'>
<div>
<div className='max-w-3xl mx-auto sm:px-6 lg:px-8'>
<form
className='mt-5 md:mt-0 md:col-span-2'
action=''
method='POST'
>
<div className='shadow sm:rounded-md sm:overflow-hidden'>
<div className='px-4 py-5 bg-white sm:p-6'>
<label className='block text-sm font-medium leading-5 text-gray-700'>
Blog name / owner name
</label>
<input
required
className='mb-5 mt-1 form-input block w-full py-2 px-3 border border-gray-300 rounded shadow-sm focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5'
/>
<label className='block text-sm font-medium leading-5 text-gray-700'>
Email address
</label>
<input
required
type='email'
className='mb-5 mt-1 form-input block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5'
/>
<label className='block text-sm font-medium leading-5 text-gray-700'>
Blog URL
</label>
<input
type='url'
required
className='mb-5 mt-1 form-input block w-full py-2 px-3 border border-gray-300 rounded shadow-sm focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5'
placeholder='https://www.example.com'
/>
<label className='block text-sm font-medium leading-5 text-gray-700'>
RSS Feed URL
</label>
<input
type='url'
required
className='mb-5 mt-1 form-input block w-full py-2 px-3 border border-gray-300 rounded shadow-sm focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5'
placeholder='https://www.example.com/feed'
/>
<label
htmlFor='about'
className='block text-sm leading-5 font-medium text-gray-700'
>
Notes
</label>
<div className='rounded-md shadow-sm'>
<textarea
rows='3'
className='form-textarea mt-1 block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5'
placeholder='Anything you want to tell us!'
></textarea>
</div>
<p className='mt-2 text-sm text-gray-500'>
Your submission will be approved before appearing on the
site
</p>
</div>
<div className='px-4 py-3 bg-gray-50 text-right sm:px-6'>
<span className='inline-flex rounded-md shadow-sm'>
<button
type='submit'
className='inline-flex justify-center py-2 px-4 border border-transparent text-sm leading-5 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-500 focus:outline-none focus:border-blue-700 focus:shadow-outline-blue active:bg-blue-700 transition duration-150 ease-in-out'
>
Save
</button>
</span>
</div>
</div>
</form>
</div>
</div>
</div>
</main>
</div>
</div>
)
}
I included the Head
component from next/head
, so we can add the Tailwind UI CSS file, and added some static HTML.
Here’s the result so far:
One thing we need to do now is to link the 2 pages, the posts list and the form.
In both pages, add
import Link from 'next/link'
at the top, then in index.js
right after the h1
tag add:
<p>
<Link href='/form'>
<p className='underline cursor-pointer mt-2'>
<a>Add a new blog</a>
</p>
</Link>
</p>
in form.js
:
<p>
<Link href='/'>
<p className='underline cursor-pointer mt-2'>
<a>Back</a>
</p>
</Link>
</p>
The form submit
Now let’s handle the form submit process.
We import useState
from react
import { useState } from 'react'
Then we can define local state hooks for each form element:
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [blogurl, setBlogurl] = useState('')
const [feedurl, setFeedurl] = useState('')
const [notes, setNotes] = useState('')
Now I use them for each input element, adding the value as the value
attribute, and the update function in the onChange
handler function, like this:
value={name}
onChange={(event) => setName(event.target.value)}
Full code:
<form
className='mt-5 md:mt-0 md:col-span-2'
action=''
method='POST'
>
<div className='shadow sm:rounded-md sm:overflow-hidden'>
<div className='px-4 py-5 bg-white sm:p-6'>
<label className='block text-sm font-medium leading-5 text-gray-700'>
Blog name / owner name
</label>
<input
required
value={name}
onChange={(event) => setName(event.target.value)}
className='mb-5 mt-1 form-input block w-full py-2 px-3 border border-gray-300 rounded shadow-sm focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5'
/>
<label className='block text-sm font-medium leading-5 text-gray-700'>
Email address
</label>
<input
required
type='email'
value={email}
onChange={(event) => setEmail(event.target.value)}
className='mb-5 mt-1 form-input block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5'
/>
<label className='block text-sm font-medium leading-5 text-gray-700'>
Blog URL
</label>
<input
type='url'
required
value={blogurl}
onChange={(event) => setBlogurl(event.target.value)}
className='mb-5 mt-1 form-input block w-full py-2 px-3 border border-gray-300 rounded shadow-sm focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5'
placeholder='https://www.example.com'
/>
<label className='block text-sm font-medium leading-5 text-gray-700'>
RSS Feed URL
</label>
<input
type='url'
required
value={feedurl}
onChange={(event) => setFeedurl(event.target.value)}
className='mb-5 mt-1 form-input block w-full py-2 px-3 border border-gray-300 rounded shadow-sm focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5'
placeholder='https://www.example.com/feed'
/>
<label
htmlFor='about'
className='block text-sm leading-5 font-medium text-gray-700'
>
Notes
</label>
<div className='rounded-md shadow-sm'>
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
rows='3'
className='form-textarea mt-1 block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5'
placeholder='Anything you want to tell us!'
></textarea>
</div>
<p className='mt-2 text-sm text-gray-500'>
Your submission will be approved before appearing on the
site
</p>
</div>
<div className='px-4 py-3 bg-gray-50 text-right sm:px-6'>
<span className='inline-flex rounded-md shadow-sm'>
<button
type='submit'
className='inline-flex justify-center py-2 px-4 border border-transparent text-sm leading-5 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-500 focus:outline-none focus:border-blue-700 focus:shadow-outline-blue active:bg-blue-700 transition duration-150 ease-in-out'
>
Save
</button>
</span>
</div>
</div>
</form>
Handle the form submit
Now I add an onSubmit
event handler to the form, which is assigned a function called handleSubmit
:
<form
className='mt-5 md:mt-0 md:col-span-2'
action=''
method='POST'
onSubmit={handleSubmit}
>
here’s the function:
const handleSubmit = async (event) => {
event.preventDefault()
//...
}
Inside the function, we’re going to hit an API we’re soon going to define, that will answer to a POST request on the /api/blog
URL:
const handleSubmit = async (event) => {
event.preventDefault()
try {
const res = await fetch('/api/blog', {
method: 'POST',
body: JSON.stringify({ name, email, blogurl, feedurl, notes }),
headers: { 'Content-Type': 'application/json' },
})
const json = await res.json()
}
While I’m here I’m also adding a bit of error checking and positive / negative feedback handling from the API. If the API contains a success
property, we’re going to fire an alert()
and redirect back to the homepage.
Add import Router from 'next/router'
to use Router.push('/')
We’re going to use a response
local state through hooks.
Define
const [response, setResponse] = useState('')
and we’ll update it when the response is back:
const handleSubmit = async (event) => {
event.preventDefault()
try {
const res = await fetch('/api/blog', {
method: 'POST',
body: JSON.stringify({ name, email, blogurl, feedurl, notes }),
headers: { 'Content-Type': 'application/json' },
})
const json = await res.json()
if (json.success) {
alert('Thank you for submitting your blog!')
Router.push('/')
} else {
setResponse(json.message)
}
} catch (error) {
setResponse('An error occured while submitting the form')
}
}
Now in the JSX, I’m printing the response in the empty element we have right after the opening main
tag:
<p className='text-center pb-5'></p>
Like this:
<p className='text-center pb-5'>{response}</p>
The POST /api/blog API endpoint
Let’s now create the API endpoint.
Create a file pages/api/blog.js
, and add a default export that accepts a req and res objects:
export default (req, res) => {
}
Those are the familiar objects that you get in any Node.js server app. We’re going to first disable any request that’s not a POST request:
export default (req, res) => {
if (!req.method === 'POST') {
res.status(405).end() //Method Not Allowed
return
}
}
Then we extract the values we need from the request body:
const { name, email, blogurl, feedurl, notes } = req.body
and we send them using the Airtable API.
On Airtable, in the base you just created, you can see the HELP menu. Click that, and then select API documentation:
This will give you ready-to-use examples to interact with the table.
The nice thing is that they automatically include the API key and the base ID you need to use.
We’re going to use this code:
const Airtable = require('airtable')
const base = new Airtable({ apiKey: process.env.APIKEY }).base(
'appYOURBASEID'
)
base('Table 1').create(
[{ fields: { name, email, blogurl, feedurl, notes } }],
(err) => {
if (err) {
console.error(err)
res.status(500).end()
return
}
}
)
Make sure you run
npm install airtable
to install the Airtable official library that we require.
Note that I entered my base id
in the code.
The Airtable API key, however, is listed as process.env.APIKEY
because I’m going to pass it from the command line, so it’s not included in the code.
Stop the process in the terminal, and run
APIKEY=keyYOURAPIKEY npm run dev
You can find your API key in the Airtable API documentation
Finally, we return the response back to the user:
res.json({
success: true
})
Here’s the full pages/api/blog.js
file content:
export default (req, res) => {
if (!req.method === 'POST') {
res.status(405).end() //Method Not Allowed
return
}
const { name, email, blogurl, feedurl, notes } = req.body
const Airtable = require('airtable')
const base = new Airtable({ apiKey: process.env.APIKEY }).base(
'appYOURBASEID'
)
base('Table 1').create([{ fields: { name, email, blogurl, feedurl, notes } }], (err) => {
if (err) {
console.error(err)
res.status(500).end()
return
}
})
res.json({
success: true
})
}
Now try filling the form: the data should be saved on Airtable.
Fetching blogs using the Airtable API
Now that we have the database set up, it’s time to use the Airtable API in the index.js
page, to fetch the list of blogs approved.
Tip: add a few blogs, and approve them manually on Airtable by clicking the checkbox in the
approved
column.
The getStaticProps()
function now exports the list of blog posts from my blog, because we hardcoded that address.
export async function getStaticProps(context) {
const parser = new Parser()
const data = await parser.parseURL('https://flaviocopes.com/index.xml')
const posts = []
data.items.slice(0, 10).forEach((item) => {
posts.push({
title: item.title,
link: item.link,
date: item.isoDate,
name: 'Flavio Copes'
})
})
return {
props: {
posts,
}
}
}
We’re going to refactor this code, retrieving the list of feed URLs.
Here’s how I retrieve the first 100 results from Airtable:
const Airtable = require('airtable')
const base = new Airtable({ apiKey: process.env.APIKEY }).base(
'appYOURBASEID'
)
const records = await base('Table 1').select({
view: 'Grid view',
}).firstPage()
We only get the first page of results and we limit to the first 100 blogs listed in Airtable, as that’s the maxiumum items we can get for each API call, but I think that to start with this is a good number.
Airtable will always serve the items from item #1 to item #100, so the new blogs that will be added, even if unapproved, will not affect our results. They will not “push down” existing and approved blogs.
Now we can get the data from the records:
const feeds = records.filter((record) => {
if (record.get('approved') === true) return true
}).map((record) => {
return {
id: record.id,
name: record.get('name'),
blogurl: record.get('blogurl'),
feedurl: record.get('feedurl'),
}
})
and we can process each blog RSS feed in a loop:
const posts = []
for (const feed of feeds) {
const data = await parser.parseURL(feed.feedurl)
data.items.slice(0, 10).forEach((item) => {
posts.push({
title: item.title,
link: item.link,
date: item.isoDate,
name: feed.name,
})
})
}
Awesome! This should be the result, a mix of blog posts ordered by date:
This content originally appeared on flaviocopes.com and was authored by flaviocopes.com
flaviocopes.com | Sciencx (2022-03-15T05:00:00+00:00) Build a RSS reader using Next.js and Airtable. Retrieved from https://www.scien.cx/2022/03/15/build-a-rss-reader-using-next-js-and-airtable/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.