Build a blog with built-in Google authentication 

Topics covered: Authentication, React frontends
Time to build: ~25 minutes

In this tutorial, we will build a simple blog application with a built-in Google authentication system. Our blog will have two sections: a public section where readers can view published blog posts and a private section where authors can create and publish new blog posts. Gadget will handle all parts of our application stack, including the database, backend API, and React frontend.

Let's get started!

You can fork this Gadget project and try it out yourself!

Fork on Gadget

Step 1: Create a new application 

Head over to to create a new Gadget app! You will have 3 choices for app templates - for this tutorial, we can use the Web app template that comes with built-in authentication.

A screenshot of the web app template tile highlighted on the model that is displayed when creating a new Gadget app.

Once the new Gadget app is created, move on to Step 2.

Step 2: Create a new model 

The first thing we can tackle is creating a model to represent our blog posts and store blog post data in our database.

Our blog's schema is going to need a post model where each record represents a blog post. To create the post model click on the + icon to the right of Data Models and name the model post.

Every model in Gadget comes with a set of non-configurable system fields:

  • id for identifying the record with an automatically assigned unique number
  • createdAt and updatedAt for tracking when the record was created and last updated.

Add fields 

Let's teach Gadget that beyond these system fields, our blog posts have additional attributes. We can do this by adding fields.

  1. Click on the + button at the top of the list of FIELDS
  2. Name the new field title
  3. Set the field type to string

Now, each blog post will have a title value stored in this field.

Finally, let's add a couple of validations to this field.

  1. In the Validations section, we can require every blog post to have a title by adding a Required validation
  2. Also add a Uniqueness validation to ensure that none of our posts use duplicate titles

Once finished, our title field should look like this:

A screenshot of the completed title field, with the string type selected and required and uniqueness validations applied

Let's add some more fields to our post model to store the blog content, and published state:

  1. Add a new field with the API identifier content and a field type of rich text. This field will store the body of each blog post
  2. Add a new field with the API identifier isPublished and a field type of boolean. This field will store the published state of each blog post. Set the default value to False so that new blog posts are not published by default

Add a relationship 

Finally, let's keep track of what user wrote each blog post by adding a relationship field to post. Relationships allow us to model data in a normalized fashion, so we can create relationships between models that are otherwise unrelated to each other. Like foreign keys in SQL, one model receives and stores the id in its data to set up the relationship.

When we selected the Web app blog post template, Gadget created a user model for us to store information about our users. We can use this model to store information about the authors of our blog posts. Let's create a relationship between the post and user models.

One user can write many blog posts, so we need a one-to-many relationship where user has many posts:

  1. Add a new field with the API identifier user and make the field a belongs to relationship. This field will store the author of each blog post
  2. Select the user model in the relationship widget that appears. This will create a relationship between the post and user models
  3. We are prompted to select the inverse relationship on the user model. Select the posts field on the user model so that user has many posts

The relationship should look like this:

A screenshot of the relationship between post and user models, so that each user has many posts

You are done with data modeling! Now we move on to set our backend API permissions.

Step 3: Actions and API permissions 

As we've been building our model and adding fields, Gadget has been automatically generating a set of actions for us. Actions are the API endpoints that we can use to create, read, update, and delete records in our database. We will use this generated CRUD API to build our blog. These actions can be found in the ACTIONS panel above the FIELDS when viewing a model.

Add custom actions

You aren't limited to using the default CRUD actions that Gadget generates for you. You can also add custom actions to your models to perform any type of operation you want. For example, you can add an action to handle publishing a blog post instead of using the update action like we will use in this tutorial.

You can also add global actions when you want to run code that requires data from multiple records or models. For more information on actions, see our documentation.

But before we start building our frontend, we need to make sure we have the correct permissions set for different types of users. We want to make sure that only signed-in users can create and publish blog posts, and that anyone can read published blog posts.

  1. Click on Settings in the left navigation bar
  2. Click Roles & Permissions nested under Settings

By default, users who use Gadget's built-in authentication will be assigned the signed-in role. This is where we can configure what actions users with this role can perform. We also have an unauthenticated role that we can configure for users who are not signed in. These roles do not have permissions to custom model actions by default, so we will need to add permissions to be able to create, update, and read blog posts.

Start by granting unauthenticated users read permission on the post model. This will allow anyone to read published blog posts:

  1. Click the checkbox for the read action in the post model under the unauthenticated role

Now anyone will be able to read published posts!

Let's update the signed-in role to allow users to create and update blog posts:

  1. Click the checkbox for the read, create, and update actions in the post model under the signed-in role
A screenshot of the permissions page. The signed-in role has access granted to the post model's read, create, update, and delete actions, and unauthenticated role can read post records

Now signed-in users will be able to read, create, and update blog posts! This tutorial does not cover the deletion of posts, but you could also set that permission if you wish.

Finally, let's make sure that unauthenticated users can only read published blog posts. We can do this by adding a custom Gelly filter to the post model:

  1. Hover over the read action permission for the post model under the unauthenticated role and click the + Filter link that appears
  2. Enter published for the file name, and a new Gelly file will be created for us at post/filters/published.gelly
  1. Add the following Gelly filter to the file:
fragment Filter($user: User, $session: Session) on Post {
[where isPublished]

This Gelly snippet filters results returned from any read action called on the post model by unauthenticated users against the isPublished field on post. This means that only published blog posts will be returned to unauthenticated users.

What is Gelly? Can I eat it?

Gelly is Gadget's data access language, a superset of GraphQL that allows us to make queries (or in this case, filters!) against the database. For more info on Gelly, check out our docs!

Our API permissions are set, now we can build our frontend!

Step 4: Install npm packages 

Now that we have our data model and API permissions set up, we can build our frontend. Gadget is built on Node.js, so we can install npm packages like we would in any other Node project. Let's start by installing our dependencies.

Our frontend will make use of the Chakra UI component library to handle layout and styling, chakra-ui-markdown-renderer + react-markdown + react-rte to help us display and edit blog post content. To install these dependencies, we will use the Gadget command palette:

  1. Open the Gadget command palette using P or Ctrl P
  2. Enter > in the palette to allow you to run yarn commands
  3. Enter the following snippet and click Run... (or press Enter on your keyboard!):
yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion react-markdown chakra-ui-markdown-renderer react-rte

The packages will be installed for you. You can view and edit these dependencies inside your project's package.json file like you would with any other Node project.

Now that we have our dependencies installed, let's start building our frontend!

Set up ChakraProvider 

Before we get into building out our routes, pages, and components, we should set up our ChakraProvider. This will allow us to use Chakra UI components in our app.

  1. Open frontend/main.jsx
  2. Import the ChakraProvider component from @chakra-ui/react:
import { ChakraProvider } from "@chakra-ui/react";
  1. Wrap your App component with the ChakraProvider:
2 <React.StrictMode>
3 <ChakraProvider>
4 <Provider api={api} auth={{ signOutActionApiIdentifier: "signOut", signInPath: "/" }}>
5 <App />
6 </Provider>
7 </ChakraProvider>
8 </React.StrictMode>

Step 5: Set up frontend routes 

Finally, we can start building our frontend! The first thing to put together is our frontend routing and we will use react-router-dom to manage this. We will have two routes in our app: the root route / where anyone can read blog posts, and a route for logged-in users to write and publish blog posts at /admin.

We will make use of Gadget's @gadgetinc/react package to help make requests to our backend and handle authentication. This package has a suite of useful hooks and components we can use to protect routes behind authentication and make requests to our backend.

Gadget currently supports authentication via Google's OAuth 2.0 client. When you start building, you can use Gadget-supplied credentials so you don't need to worry about setting up an OAuth Client in the Google Cloud Console. The default frontend app has a sign-in button and a UserCard component which will allow you to test out the default authentication flow and gives you a taste of what using the useUser hook looks like. We will use these default credentials to build and test, but you will need to set up your own OAuth Client in the Google Cloud Console to use auth in production.

We can open frontend/App.jsx to set up our required routes:

  1. Replace the existing router component with some placeholder DOM elements:
1const router = createBrowserRouter(
2 createRoutesFromElements(
3 <Route path="/" element={<Layout />}>
4 <Route index element={<div>blog</div>} />
5 <Route
6 path="admin"
7 element={
8 <SignedInOrRedirect>
9 <div>admin</div>
10 </SignedInOrRedirect>
11 }
12 />
13 </Route>
14 )

The SignedInOrRedirect component can be used to protect routes, and will redirect unauthenticated users to the route defined in signInPath.

Right now, our routes are set up to render some placeholder DOM elements. You can preview your app by:

  • Clicking on the app domain name at the top of the left nav bar
  • Hovering over Go to app and clicking Development

We just see a page with a header, the default app background, and the word "blog" displayed. This probably isn't the blog content we want to display, so let's go ahead and replace this with a proper navigation component so we can route to our blog posts and admin pages.

Update the Header component for navigation 

The default Gadget template gives us a Header component that we will modify. The Header is already included in our app, and the component is found in frontend/App.jsx.

Right now, the Header displays a sign-in or sign-out button, as well as our app name. We want to include some clickable links for our blog and admin routes, so let's update the Header component to include these links. We also want to use Chakra UI to change the style of the Header. The whole code snippet will be posted first, with details following.

  1. Delete the frontend/App.css file from your project, and remove the App.css import statement from frontend/App.jsx and frontend/routes/index.jsx
  2. Replace the import statements at the top of frontend/App.jsx with the following to import additional React hooks and Chakra UI components:
1import { SignedIn, SignedInOrRedirect, SignedOut, useSignOut, useUser } from "@gadgetinc/react";
2import { Suspense, useEffect } from "react";
3import { Outlet, Route, RouterProvider, createBrowserRouter, createRoutesFromElements, Link } from "react-router-dom";
4import Icon from "./assets/google.svg";
5import { Avatar, Button, Box, Flex } from "@chakra-ui/react";
6import { api } from "./api";
  1. Replace the Header component in frontend/App.jsx:
1const Header = () => {
2 // the useUser() hook is used to get and display info about the current user
3 // in this case, your Google account profile picture
4 const user = useUser(api);
6 // signout and redirect when the "Sign out" button is clicked
7 const signOut = useSignOut();
9 // return the nav bar component
10 // the SignedIn component is used to display the "Admin" link and "Sign out" button if you are signed in
11 // the SignedOut component is used to display the "Sign in" button if you are not signed in
12 return (
13 <Box>
14 <Flex
15 minH="60px"
16 py={{ base: 2 }}
17 px={{ base: 4 }}
18 borderBottom={1}
19 borderStyle="solid"
20 borderColor="grey"
21 align="center"
22 justifyContent="space-between"
23 >
24 <Flex alignItems="center" gap="10px" fontSize="24px" fontWeight="600" lineHeight="30px">
25 {process.env["GADGET_PUBLIC_APP_SLUG"]}
26 </Flex>
27 <Flex gap="48px" alignItems="center">
28 <Link style={{ textDecoration: "none" }} to="/">
29 <Button variant="link">Blog</Button>
30 </Link>
31 <SignedIn>
32 <Link style={{ textDecoration: "none" }} to="/admin">
33 <Button variant="link">Admin</Button>
34 </Link>
35 <Flex align="center" gap="2">
36 <Button onClick={signOut} variant="link">
37 Sign out
38 </Button>
39 {user && <Avatar src={user.googleImageUrl} />}
40 </Flex>
41 </SignedIn>
42 <SignedOut>
43 <Button
44 variant="outline"
45 leftIcon={<img src={Icon} width={16} height={16} />}
46 as="a"
47 href="/auth/google/start?redirectTo=/admin"
48 >
49 Sign in with Google
50 </Button>
51 </SignedOut>
52 </Flex>
53 </Flex>
54 </Box>
55 );

There are a couple of important things to pay attention to in the Header component, most notably the hooks and components imported from @gadgetinc/react:

  • We use the useUser hook to get the current user's information. This hook will return an object with the current user record. We use this hook to display the user's profile picture in the navigation bar.
import { api } from "./api";
const user = useUser(api);
  • We use the SignedIn and SignedOut components to display different content depending on whether the user is signed in or not. In this case, we display the "Admin" link and "Sign out" button if the user is signed in, and the "Sign in" button if the user is not signed in.
2 <Link style={{ textDecoration: "none" }} to="/admin">
3 <Button variant="link">Admin</Button>
4 </Link>
5 <Flex align="center" gap="2">
6 <Button onClick={signOut} variant="link">
7 Sign out
8 </Button>
9 {user && <Avatar src={user.googleImageUrl} />}
10 </Flex>
13 <Button variant="outline" leftIcon={<img src={Icon} width={16} height={16} />} as="a" href="/auth/google/start?redirectTo=/admin">
14 Sign in with Google
15 </Button>

We can test out sign-in and sign-out now. When we sign in, we automatically redirect to the admin page and we can see an Admin link in the nav bar. Clicking on the Blog link in our header should change the placeholder text from "admin" to "blog", which means our router is working. Sign out, and you should be redirected back to the "blog" page.

Step 6: Build a page to display blog posts 

Now that our router is working we can focus on building pages for reading and writing blog posts. Let's start with a page for reading blog posts.

Start by building a page that will display posts to unauthenticated users. This page will be the root route / and will display a list of blog posts.

  1. Paste the following code into frontend/routes/index.jsx:
1import { useEffect, useState } from "react";
2import { useFindMany } from "@gadgetinc/react";
3import { Box, Button, Text, Collapse, Container, Alert, AlertIcon, Spinner, Flex } from "@chakra-ui/react";
4import { api } from "../api";
5import ChakraUIRenderer from "chakra-ui-markdown-renderer";
6import ReactMarkdown from "react-markdown";
8export default function () {
9 const [displayPosts, setDisplayPosts] = useState([]);
11 // the useFindMany hook is used to read blog posts from the backend API
12 const [{ data: posts, fetching, error }] = useFindMany(, {
13 sort: {
14 updatedAt: "Descending",
15 },
16 });
18 // set the React state when the posts are loaded from the useFindMany read request
19 // a "show" property is added to each post to track whether the post should be expanded or collapsed
20 useEffect(() => {
21 if (posts) {
22 setDisplayPosts( => ({, show: false })));
23 }
24 }, [posts]);
26 const toggleShow = (id) => {
27 setDisplayPosts( => ( === id ? {, show: ! } : post)));
28 };
30 if (!displayPosts || fetching) {
31 return <Spinner />;
32 }
34 return (
35 <Container maxW="5xl" py="15">
36 <Box mt="5" textAlign="left">
37 {error && (
38 <Alert status="error">
39 <AlertIcon />
40 Error loading posts
41 </Alert>
42 )}
43 { => (
44 <Box key={} mt="5" p="5" border="1px" borderColor="gray.200" borderRadius="md">
45 <Flex align="center" justify="space-between">
46 <Text fontSize="2xl" mb="4">
47 {post.title}
48 </Text>
49 <Text fontSize="l" mb="4">
50 {post.updatedAt.toDateString()}
51 </Text>
52 </Flex>
53 <Collapse in={}>
54 <ReactMarkdown components={ChakraUIRenderer()} children={post.content.markdown} skipHtml />
55 </Collapse>
56 <Button size="sm" onClick={() => toggleShow(}>
57 { ? "Hide post" : "Read post"}
58 </Button>
59 </Box>
60 ))}
61 {!displayPosts.length && (
62 <Text fontSize="2xl" mb="4">
63 No blog posts yet!
64 </Text>
65 )}
66 </Box>
67 </Container>
68 );

Most of this file is ordinary React state and component code! There are some Gadget-specific things to pay attention to:

  • We use the useFindMany hook to fetch all the posts from the database. This hook will return an object with a data property that contains the posts. We use this hook to display the posts in the blog, and a useEffect hook is used to update the state when the posts change. The posts are sorted by updatedAt in descending order, so the most recently updated posts will be displayed first. We also use the fetching and error properties to display a loading indicator and an error message if the posts are still being fetched or if there was an error fetching the posts.
const [{ data: posts, fetching, error }] = useFindMany(, {
sort: {
updatedAt: "Descending",

One more step until we can see our blog posts - let's hook up this page to our router in frontend/App.jsx:

  1. Import our route component into frontend/App.jsx:
import Index from "./routes/index";
  1. Replace the placeholder "blog" div components with the imported Index:
1const router = createBrowserRouter(
2 createRoutesFromElements(
3 <Route path="/" element={<Layout />}>
4 {/**Replace the element definition below!*/}
5 <Route index element={<Index />} />
6 <Route
7 path="admin"
8 element={
9 <SignedInOrRedirect>
10 <div>admin</div>
11 </SignedInOrRedirect>
12 }
13 />
14 </Route>
15 )

Now if we check our development app, we should see... a message that tells us we haven't created any posts yet.

A screenshot of the current state of the blog. There is a header with a sign-in button and the Blog link displayed, with the No posts yet... message

That's a bit anti-climatic. Let's fix that next.

Step 7: Build an admin page to write blog posts 

This is the last major step in building our blog. We'll build a page that authenticated users can use to write blog posts. This page will be at the /admin route.

  1. Create a new file frontend/routes/admin.jsx
  2. Paste the following code into frontend/index/admin.jsx:
1import { useState, useEffect } from "react";
2import {
3 Box,
4 FormControl,
5 FormLabel,
6 Input,
7 Button,
8 Switch,
9 Spinner,
10 Alert,
11 AlertIcon,
12 Container,
13 Flex,
14 Tabs,
15 TabList,
16 TabPanels,
17 Tab,
18 TabPanel,
19} from "@chakra-ui/react";
20import { api } from "../api";
21import { useAction, useFindMany, useUser } from "@gadgetinc/react";
22import RichTextEditor from "react-rte";
24export default function () {
25 const [title, setTitle] = useState("");
26 const [content, setContent] = useState(RichTextEditor.createEmptyValue());
27 const [posts, setPosts] = useState([]);
29 // the useUser hook grabs the user record from the Gadget API
30 // this is used to display the user's avatar in the UI
31 const user = useUser(api);
33 // the useFindMany hook reads all posts
34 const [{ data: postList }] = useFindMany(;
36 // the useAction hook helps run create and update actions for our posts
37 // we call the 'addPost' function in a callback when the user clicks the save button
38 const [{ data: createdPost, fetching: addPostFetching, error: addPostError }, addPost] = useAction(;
39 // the `changePublishState' function updates the isPublished field of a post when the frontend toggle for that post is clicked
40 const [{ error: publishError }, changePublishState] = useAction(;
42 useEffect(() => {
43 if (postList) {
44 setPosts(postList);
45 }
46 }, [postList]);
48 // handleSave calls 'addPost' to create a new blog post record
49 // by default, the post is not published
50 const handleSave = async () => {
51 await addPost({
52 post: {
53 title: title,
54 content: {
55 markdown: content.toString("markdown"),
56 },
57 user: {
58 _link:,
59 },
60 },
61 });
63 setContent(RichTextEditor.createEmptyValue());
64 setTitle("");
65 };
67 // toggle the isPublished field of a post by updating the record
68 const changePublished = async (id, currentState) => {
69 void changePublishState({
70 id,
71 post: {
72 isPublished: !currentState,
73 },
74 });
75 };
77 return (
78 <Container maxW="5xl" py="15">
79 <Tabs>
80 <TabList>
81 <Tab>Write</Tab>
82 <Tab>Publish</Tab>
83 </TabList>
85 <TabPanels>
86 <TabPanel>
87 <Box mt="5" textAlign="left">
88 {addPostError && (
89 <Alert status="error">
90 <AlertIcon />
91 Error occurred while saving the post!
92 </Alert>
93 )}
94 {createdPost && (
95 <Alert status="success">
96 <AlertIcon />
97 Blog post saved!
98 </Alert>
99 )}
100 {addPostFetching && <Spinner />}
101 <FormControl>
102 <FormLabel htmlFor="title">Title</FormLabel>
103 <Input type="text" id="title" value={title} onChange={(e) => setTitle(} />
104 <FormLabel htmlFor="content">Content</FormLabel>
105 <RichTextEditor value={content} onChange={(value) => setContent(value)} />
106 </FormControl>
107 <Button colorScheme="blue" mt={4} onClick={handleSave}>
108 Save
109 </Button>
110 </Box>
111 </TabPanel>
113 <TabPanel>
114 <Box mt={8} maxWidth="300px" p="4px">
115 {publishError && (
116 <Alert status="error">
117 <AlertIcon />
118 Publishing error!
119 </Alert>
120 )}
121 <FormControl>
122 {, index) => (
123 <Box key={index}>
124 <FormControl>
125 <Flex gap="2">
126 <Switch isChecked={post.isPublished} onChange={() => changePublished(, post.isPublished)} />
127 <FormLabel htmlFor="post">{post.title}</FormLabel>
128 </Flex>
129 </FormControl>
130 </Box>
131 ))}
132 </FormControl>
133 </Box>
134 </TabPanel>
135 </TabPanels>
136 </Tabs>
137 </Container>
138 );

This file is a bit longer than the other files we've written so far, but it's still mostly ordinary React code. There are a few Gadget-specific things to pay attention to:

  • We use the useUser hook again to get the currently logged-in user. In this case, the user record is used to link new blog posts with the author
  • The useFindMany hook is also used again to fetch all the posts from the database. We use this hook to display the posts in the "Publish" tab, and a useEffect hook is used to update the state when the posts change
  • The useAction hook is new to this app, and we use it to create new blog posts and update existing ones. The addPost action is used to create new posts using the action, and the changePublishState action is used to update the isPublished field of existing posts using our action

Once again, let's hook up this page to our router in frontend/App.jsx:

  1. Import the admin route component into frontend/App.jsx
import Admin from "./routes/admin";
  1. Replace the placeholder "admin" div component with the imported Admin:
1const router = createBrowserRouter(
2 createRoutesFromElements(
3 <Route path="/" element={<Layout />}>
4 <Route index element={<Blog />} />
5 <Route
6 path="admin"
7 element={
8 <SignedInOrRedirect>
9 {/**Replace the element definition below!*/}
10 <Admin />
11 </SignedInOrRedirect>
12 }
13 />
14 </Route>
15 )

And we're done building our app! Let's check it out in the browser and write a blog post! Once you have a post written, you can publish it by toggling the switch in the "Publish" tab.

Try logging in and out, and different publishing states to test out the experience for both signed-in and unauthenticated users. See the video at the start of the tutorial for a demo.

Step 8 (Optional): Deploying to Production 

If you want to publish your blog app to Production, you'll need to supply your own Google OAuth credentials. Read our documentation on how to set up credentials.

Finally, deploy your app to Production:

  • Click the Deploy button in the bottom right corner of the Gadget editor
  • Click the Deploy to Production button in the modal that appears

Your app will be bundled and minimized for production, and deployed. Preview your production app by:

  • Clicking on your app name in the top left corner of the Gadget editor
  • Hovering over Go to app and clicking Production

Next steps 

Interested in building apps using OpenAI's API? Check out our OpenAI tutorial to learn how to use Gadget's some of Gadget AI tooling, including the OpenAI connection, vector embeddings, and response streaming.

AI Screenwriter

Learn how to build an AI chatbot that writes fake movie scenes using Gadget, OpenAI, and Vercel's AI SDK.


If you have any questions, feel free to reach out to us on Discord to ask Gadget employees or the Gadget developer community!