Skip to content

Latest commit

 

History

History
793 lines (618 loc) · 22.6 KB

session2.org

File metadata and controls

793 lines (618 loc) · 22.6 KB

Session2

Preface

Now that we know the basics our stack, let’s start GETting and POSTing data from our database.

Table of Contents

Page metadata

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.

Default metadata

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.

Creating a home page

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;

Icons

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.

Basic styling.

  1. Please go to layout.tsx and have a look at the className’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?!).

  1. 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.

  1. 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!

  1. 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.

More on components.

Let’s change our login button on the navbar now. I want to accomplish two things.

  1. It should go back to the page the user clicked the button from.
  2. 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;

Feed page

Creating the page

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;

Guard the route.

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.

Creating the Post model.

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.

Dummy post

Server side

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!

Loading 1

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;

Loading 2

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;

Client side

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.

Note: Not Insomnia but Insomnium.

curl

curl http://localhost:3000/api/posts

httpie

# Install it - Arch Linux
sudo pacman -S httpie
# using it
http GET http://localhost:3000/api/posts

Insomnium

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.

./insomnium.png

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;

shadcn and zod

shadcn

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.

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

Zod is a validation library. It helps us validate the input and provide error messages.

Let’s install it.

yarn add zod

Using 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),
});

CreatePost Component.

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 and useEffect hooks. We will also be using a js/ts library called zod 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;

Quick crash course on defining our form.

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>

CreatePost.tsx

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 :)

Post Request

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

  1. Optional: Add a loading state to the button.
  2. Send the API request
  3. If successful clear the form indicating its successful.
  4. 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 a console.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.

Solo activity

Create a login page to replace the default one provided by next-auth.