Now that we know the basics our stack, let’s start GETting and POSTing data from our database.
We want to be able to set custom page title and descriptions for all of our pages. For this first we set it in the root layout.
This means, if we don’t specify a change, all our pages have that ‘default’ metadata.
Metadata is the hidden page data that the browser and bots on the internet use to get information on the website.
- Page title: displayed on the tab
- Page description: displayed on google search results, and in small text in url previews.
- keywords: Just “tags” used for SEO.
We can see that Next.js has already given us some default metadata in app/layout.tsx
.
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
};
You can put this snippet on any page, and it will have those fields updated for that page.
Lets modify this by adding a logo/image and some keywords.
First copy over the logo from the public directory and put it in your project’s public directory.
Here we also add
metadataBase
property. This is because we are specifying which domain we are getting the images from.
export const metadata: Metadata = {
metadataBase: new URL('https://localhost:3000'),
title: 'Instr',
description: 'Twitter like application where you share posts.',
keywords: 'Instr, Twitter, Instagram, Reddit',
icons: '/logo.png',
openGraph: {
title: 'Instr',
description: 'Twitter like application where you share posts.',
images: '/logo.png',
},
};
Now if you refresh the page, you should see the new icon on your tab from the icons
property and when you share this url, the same logo next to it.
This piece of typescript code produced the following HTML.
<title>Instr</title>
<meta name="description" content="Twitter like application where you share posts."/>
<meta name="keywords" content="Instr, Twitter, Instagram, Reddit"/><meta property="og:title" content="Instr"/>
<meta property="og:description" content="Twitter like application where you share posts."/>
<meta property="og:image" content="http://localhost:3000/logo.png"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="Instr"/>
<meta name="twitter:description" content="Twitter like application where you share posts."/>
<meta name="twitter:image" content="http://localhost:3000/logo.png"/>
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16"/>
Note that to change the tab icon, we need to replace the default
favicon.ico
.
Let’s get some of the prebuilt Next.js css.
Go delete everything except the tailwind imports in globals.css
so it looks like this:
@tailwind base;
@tailwind components;
@tailwind utilities;
We want to use some icons later on, so lets install an icons package. I like tabler-icons.
yarn add @tabler/icons-react
You can find the list of icons on their website.
- Please go to
layout.tsx
and have a look at theclassName
’s on the html, body and main tags.
Some basic styling such as page background, text colour, and some nice padding on our
<main>
to give make it look better (also slightly responsive?!).
- Place your
NavMenu
component above the main tag rather than inside of it.
Our navbar resizing and styling are different to the contents of the page. So we need to not have it inside of the main tag.
- Update your
Navbar
component with the updated code.
Make it an actual navbar. By default almost everything in tailwind is unstyled like links, buttons and headings.
Let’s give our links underlines and buttons backgrounds from now on!
- Also update your
page.tsx
with the provided code.
Basic landing page. We also have some conditional rendering at the bottom.
You now have a basic home screen.
Let’s change our login button on the navbar now. I want to accomplish two things.
- It should go back to the page the user clicked the button from.
- Directly take us to the google account selector without the visiting
/api/auth/signin
.
Let’s first abstract this login button to its own component.
I am creating mine at @/components/auth/GoogleSignInButton.tsx
.
Lets create a simple react arrow function component which just returns the we already made.
I am also going to add Google logo from tabler-icons.
For the onClick()
action of this button we can specify that we want to use the google provider.
Here I will also provide a callbackUrl
which will inform signIn()
from next-auth where to redirect to after a user has loggedIn. This is just a little bit of a knowledge bomb.
I want this button to take in an argument for this redirect, but can optionally be nil. e.i. it will redirect to whatever page the button was pressed from.
import { IconBrandGoogle } from '@tabler/icons-react';
import { signIn } from 'next-auth/react';
import { useSearchParams } from 'next/navigation';
interface buttonProps {
callbackUrl?: string;
}
const GoogleSignInButton = (props: buttonProps) => {
const url = props.callbackUrl ?? useSearchParams().get('callbackUrl') ?? '';
return (
<button
className='flex items-center rounded-md bg-slate-900 p-2 transition duration-300 hover:bg-slate-950'
onClick={() => signIn('google', { callbackUrl: url })}
>
<p className='pr-2'>Login</p> <IconBrandGoogle height={25} width={25} />
</button>
);
};
export default GoogleSignInButton;
We want tit to be a guarded page.
First lets create the directory for this route app/feed
.
Then let’s create the file page.tsx
inside this folder.
You should now be used to creating new components.
We are going to be using server components where possible.
const Feed = () => {
return (
<div>
<p>Feed page</p>
</div>
);
};
export default Feed;
Lets continue by using an if statement.
import { getServerSession } from 'next-auth';
import { authOptions } from '../api/auth/[...nextauth]/route';
const Feed = async () => {
const session = await getServerSession(authOptions);
if (session) {
return (
<div>
<p>Feed page</p>
</div>
);
}
};
export default Feed;
For now lets just render one static postcard.
Let go add a new model in our schema called post.
model Post {
id String @id @default(cuid())
userId String
title String
published Boolean @default(true)
views Int @default(0)
likes Int @default(0)
user User @relation(fields: [userId], references: [id])
// If we want to delete all the users posts if they delete their account.
// user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
Now we run npx prisma migrate dev --name added-posts
.
Prisma is something that is running on the server side. This means that we cannot use prismaClient on the browser. So we will be creating API’s instead. This is one of the caveats of having a client side Session wrapper.
Checking auth state on the server side is a bit cumbersome but we can use our prisma
object so we don’t have to create an api for it.
Here we have the await
keyword in multiple places. Next.js 13 allows us to create a loading.tsx
with default export of Loading
and it will automatically display this loading screen until the data is fetched.
Let’s modify the file we created earlier app/feed/page.tsx
.
import { getServerSession } from 'next-auth';
import { authOptions } from '../api/auth/[...nextauth]/route';
import { redirect } from 'next/navigation';
import prisma from '@/lib/prisma';
import PostCard from '@/components/posts/PostCard';
const Feed = async () => {
const session = await getServerSession(authOptions);
let posts = await prisma.post.findMany();
if (session) {
return (
<div>
<div className='mx-auto w-1/2'>
{posts.map((post, index) => {
return (
<div className='my-2'>
<PostCard
title={post.title}
description={post.description}
id={post.userId}
key={index}
/>
</div>
);
})}
</div>
</div>
);
} else {
redirect('/');
}
};
export default Feed;
Try both of these loading screens out and keep what you like!
const Loading = () => {
return (
<div
className='fixed left-0 top-0 flex h-screen w-screen items-center
justify-center'
>
<div
className='h-16 w-16 animate-spin rounded-full border-b-2 border-t-2
border-gray-100'
></div>
</div>
);
};
export default Loading;
const Skeleton = () => {
return (
<div className='mx-auto my-2 w-1/2 animate-pulse rounded-md bg-slate-800 p-2'>
<h1 className='mx-auto my-1 h-6 w-48 rounded-md bg-slate-700 text-center text-xl font-bold'></h1>
<div className='mx-auto mt-2 h-4 w-3/4 rounded-md bg-slate-700 p-2'></div>
<div className='mx-auto mt-2 h-4 w-3/4 rounded-md bg-slate-700 p-2'></div>
<div className='mx-auto mt-2 h-4 w-3/4 rounded-md bg-slate-700 p-2'></div>
<div className='mx-auto mt-2 h-4 w-3/4 rounded-md bg-slate-700 p-2'></div>
<p className='mx-auto my-1 my-1 h-4 w-1/5 animate-pulse rounded-md bg-slate-700 text-center italic'></p>
</div>
);
};
const Loading = () => {
return (
<>
<Skeleton />
<Skeleton />
<Skeleton />
</>
);
};
export default Loading;
For now let us just create a dummy post using prisma studio (npx prisma studio
).
Let’s also make an api endpoint to get these posts (/api/posts
but file should be app/api/posts/route.ts
).
import prisma from '@/lib/prisma';
import { getServerSession } from 'next-auth';
import { NextResponse } from 'next/server';
import { authOptions } from '../auth/[...nextauth]/route';
export async function GET() {
// No authorisation required. But we can change that
const posts = await prisma.post.findMany();
setTimeout(() => {}, 2000);
return NextResponse.json(posts);
}
We can test this using multiple ways. My preferred method is using httpie
in the command line for small simple requests and for the more complex ones (providing a body for POST method or authorisation headers) I use Insomnium
.
- Windows download for Insomnium.
insomnium-bin
available from the AUR.
Note: Not Insomnia but Insomnium.
curl http://localhost:3000/api/posts
# Install it - Arch Linux
sudo pacman -S httpie
# using it
http GET http://localhost:3000/api/posts
Just type the url http://localhost:3000/api/posts
and select the GET
method. All you have to do is click send and you should get a response.
Now that we’ve verified that our API works, let’s get to making the client feed page. Make it accessible to this url /client
. I’ve also added a loading.tsx
just like earlier to give a nice animation whilst the posts load. But since it’s the server side component it will not automatically not wait for the fetch inside the component.
Therefore we will use useState()
to make a variable loading
. Depending on loading
we will either display the page or the loading screen.
'use client';
import { useEffect, useState } from 'react';
import Loading from './loading';
import { redirect } from 'next/navigation';
import type { Post } from '@prisma/client';
import { useSession } from 'next-auth/react';
const Feed = () => {
const { data: session } = useSession();
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/posts')
.then((res) => res.json())
.then((data) => {
setPosts(data as Post[]);
setLoading(false);
});
}, []);
// First check if a session exists.
if (session) {
// Then check if all the data has loaded.
if (loading) {
return <Loading />;
}
return (
<div>
<div className='mx-auto w-1/2'>
{posts.map((post, index) => {
return (
<div className='my-2 rounded-md bg-slate-800 p-2' key={index}>
<div className='font-3xl'>{post.title}</div>
<div>{post.description}</div>
</div>
);
})}
</div>
</div>
);
} else {
redirect('/');
}
};
export default Feed;
This is a UI library. We did not have a need for this, but as we introduce more and more components, this UI library that is very easily and extensively customisable will be very useful.
- Docs.
Lets install it with:
npx shadcn-ui@latest init
Lets also add the form and button components
npx shadcn-ui@latest add button
npx shadcn-ui@latest add form
shadcn doesn’t actually install any packages. Instead it creates files inside of components/ui
that you can use straight away. Or you can modify them to your liking.
Zod is a validation library. It helps us validate the input and provide error messages.
Let’s install it.
yarn add zod
Since we will want to validate the same types of objects in multiple locations, we define something called as a zod schema, which contains all the properties and their constraints.
Let’s create the folder lib/validations
for all these schema’s. Let’s also create a file in this folder post.ts
which contains the schema for posts.
import { z } from 'zod';
export const postSchema = z.object({
title: z.string(),
description: z.string().max(250),
});
Now let’s create a component for creating a post. There are a lot of patterns we can follow for this.
This also shows that you can “embed” a client
component into a server
component.
Server components cannot have any “interactivity” as it will have no clue about the user’s state. We can still use standard HTML form actions, but we are NOT using that.
Client components can have interactivity. We will be using
useState
anduseEffect
hooks. We will also be using a js/ts library calledzod
to validate the form data before performing any action and give validation errors.
Initialise the component as you would and lets create our form
object. Read the comments in the code snippet.
// Mark as client component
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { useForm } from 'react-hook-form';
import { postSchema } from '@/lib/validations/post';
const CreatePost = () => {
// We pass in postSchema to z.infer which is the method which will
// validate our models and provide errors.
// Define our form.
// useForm is from the react-hook-form library
const form = useForm<z.infer<typeof postSchema>>({
// We are making it use zod as it's resolver.
resolver: zodResolver(postSchema),
defaultValues: {
// If for example this is the profile page we can pre-populate
// the fields with their existing information
title: '',
description: '',
},
});
// Define a submit handler.
function onSubmit(values: z.infer<typeof postSchema>) {
// This will have the POST request to create the new post.
}
return (
<div><h1>Create Post Component</h1></div>
);
};
export default CreatePost;
First we wrap our form with Form
from our shadcn
and provide our form object.
We are using the ui elements from
shadcn
that we just installed. The required imports are:
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
<!-- Our form wrapper -->
<Form {...form}>
<!-- HTML form element -->
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-8'>
<!-- Each input will be wrapped in a FormField -->
<FormField
<!-- give the control to our form then .control -->
control={form.control}
name='title'
render={({ field }) => (
<FormItem>
<!-- Form label -->
<FormLabel>Title</FormLabel>
<!-- FormControl, could be multiple like radio buttons -->
<FormControl>
<Input
<!-- Here we are using just shadcn's input element -->
className='border-none bg-slate-800'
placeholder='Title'
{...field}
/>
</FormControl>
<!-- Description -->
<FormDescription>
This is your public display name.
</FormDescription>
<!-- Form message is for the errors -->
<FormMessage />
</FormItem>
)}
<!-- Closing tag for FormField lol -->
/>
<!-- repeat for all other fields -->
</form>
</Form>
Here’s its final form
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { useForm } from 'react-hook-form';
import { postSchema } from '@/lib/validations/post';
const CreatePost = () => {
const form = useForm<z.infer<typeof postSchema>>({
resolver: zodResolver(postSchema),
defaultValues: {
title: '',
description: '',
},
});
function onSubmit(values: z.infer<typeof postSchema>) {
// TODO
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-8'>
<FormField
control={form.control}
name='title'
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input
className='border-none bg-slate-800'
placeholder='Title'
{...field}
/>
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
className='border-none bg-slate-800'
placeholder='Description'
{...field}
/>
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit'>Submit</Button>
</form>
</Form>
);
};
export default CreatePost;
I haven’t had enough time to create the POST method, so we’ll do it together :)
To send a post request let’s go to the posts api route handler and add this.
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
const user = await prisma.user.findFirst({
where: {
email: session?.user?.email,
},
});
let post = await postSchema.parseAsync(await req.json());
let res = await prisma.post.create({
data: {
title: post.title,
description: post.description,
userId: user?.id!,
},
});
return NextResponse.json(res);
}
We can then change our handleSubmit()
as follows to do the following
- Optional: Add a loading state to the button.
- Send the API request
- If successful clear the form indicating its successful.
- If unsuccessful display an error message
async function onSubmit(values: z.infer<typeof postSchema>) {
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
if (response.ok) {
form.reset();
} else {
console.log('Error publishing post');
}
} catch (error) {
console.log('Error submitting post: ' + error);
}
}
I have run
npx shadcn-ui@latest add toast
to get a toast component which I display if form fails rather than just aconsole.log()
.
Make sure to also test that form work as expected when your api call errors.
You might want to put <CreatePost />
at the top of your posts or make a delete button on existing posts to get rid of the clutter.
Create a login page to replace the default one provided by next-auth.