Fetch data from Rails API with Next.js

Intro

I'm going to run you through setting up a Next.js app so that it can fetch data from a Rails API using Tanstack Query. I'll step you through creating a new Next.js app, a new Rails App in API mode and show you how to connect the two. If you hang around to the end of the video I'll show you how you can easily add types to the Rails API.

Why?

React is really great for developing frontends (that's what it was built for) and Next.js is one of the leading frameworks for getting started. Javascript can definitely feel a bit clunky when building out your backend logic and this is where Rails comes in. It is very mature, has a bunch of helpers and gems that make building out API's super simple. ActiveRecord is an ORM that really has no match in the Javascript world and is one of the main reasons I always reach for Rails when building complex apps.

Setup Directory

We will create a directory to house both the frontend and backend. To do this run the command:

> mkdir nextjs-rails

Jump in to the directory so that we can setup the Next.js app:

> cd nextjs-rails

Setup Next.js App

This is how we create a new app with Next.js. We are going to use the app router as it is the recommended method in 2024.

> npx create-next-app@latest What is your project named? … frontend Would you like to use TypeScript? › No / Yes Would you like to use ESLint? › No / Yes Would you like to use Tailwind CSS? › No / Yes Would you like to use `src/` directory? › No / Yes Would you like to use App Router? (recommended) › No / Yes Would you like to customize the default import alias (@/*)? › No / Yes

Let that do its thing and then let's check if it runs:

npm run dev

Open up your browser and go to: http://localhost:3000/

Install Tanstack Query

If you don't know already, Tanstack Query (or formerly React Query) won the query library because it handles all the complex pieces of data fetching seamlessly. Let's install it so you can see what I mean.

npm install @tanstack/react-query

Setup the QueryClient component in src/components/ReactQueryClientProvider/index.tsx

'use client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useState } from 'react' export const ReactQueryClientProvider = ({ children }: { children: React.ReactNode }) => { const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { // With SSR, we usually want to set some default staleTime // above 0 to avoid refetching immediately on the client staleTime: 60 * 1000, }, }, }) ) return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> ) }

Now we need to wrap the entire App with the Provider. We can do this in the src/app/layout.tsx file:

import type { Metadata } from "next" import { Inter } from "next/font/google" import "./globals.css" import { ReactQueryClientProvider } from '@/components/ReactQueryClientProvider' const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { title: "Create Next App", description: "Generated by create next app", }; type RootLayoutProps = Readonly<{ children: React.ReactNode; }> export default function RootLayout(props: RootLayoutProps) { const { children, } = props return ( <ReactQueryClientProvider> <html lang="en"> <body className={inter.className}>{children}</body> </html> </ReactQueryClientProvider> ); }

Setup HTTP Client

We will need to setup our HTTP Client so that we can make requests to the server. We will use axios to do this:

> npm i axios

Add the baseUrl file in api/baseUrl.ts:

const baseURLs = { development: 'http://localhost:3002/v1/', staging: '', production: '', test: '', } const baseURL = baseURLs[process.env.NODE_ENV || 'development'] export default baseURL

Create the api file in api/index.ts:

import axios from "axios" import baseURL from "./baseUrl" const instance = axios.create({ baseURL, timeout: 1000, }) type ApiOptions = { data?: object, method?: 'get' | 'put' | 'post' | 'delete', params?: object, } export const api = async (url: string, options: ApiOptions = {}) => { const { data, method = 'get', params } = options const accessToken = 'ACCESS_TOKEN' try { const response = await instance.request({ data, headers: { 'Authorization': `Bearer ${accessToken}`, }, method, params, responseType: 'json', url, }) return response.data } catch (error) { throw new Error(error.response?.data?.errors) } } export default api

Next we will setup a router file in api/router.ts. This is just a way to keep all our queries organised and in one location so that we don't have them littered around our app:

import userEndpoints from './user' const endpoints = { users: userEndpoints, } export default endpoints

Now we can add our user endpoints in api/user.ts

import api from "./index" const endpoints = { getUsers: async () => { return await api('users') }, } export default endpoints

We can now use our API in our Next.js app, but before we do that we will setup our Rails API so that we can actually return some data.

Setup Rails API

Open a new tab in terminal and jump back to our root directory (nextjs-rails):

> cd ../

Let's create a new Rails app in API mode - we are going to name the app backend:

> rails new backend --api

Let's change the default port in backend/config/puma.rb so that our API runs on 3002:

port ENV.fetch("PORT") { 3002 }

We will use the jbuilder gem to build our json views:

In our Gemfile un-comment the jbuilder gem and run:

> bundle install

Note: We are using sqlite as our database as noted in the Gemfile. This is ok for now but you may want to use something like Postgresql in production.

Now we can start the rails server in the terminal and see if it is working:

> rails s

Open up http://localhost:3002 and you should be presented with the default page

Add User Model

If that is working we can move on to adding our users controller. To do this run the scaffold command to setup everything quickly:

> rails g scaffold User

This will setup our database migration, tests, routes, controller as well as create the jbuilder views required - with one simple command.

Open up the new migration file (it ends with create_users.rb) and let's add some fields. We are going to keep it really simple for now:

class CreateUsers < ActiveRecord::Migration[7.1] def change create_table :users do |t| t.string :name t.string :email t.timestamps end end end

Once you have done that save the file and run:

> rails db:migrate

Let's add a user to the database using the Rails console (one of my favourite tools):

> rails c > User.create(name: "Ken Greeff", email: "ken@email.com")

Restart the rails server and go to http://localhost:3002/users.json to see the user.

We haven't added in auth because it is outside the scope of this video but you can see how to implement it in a video I did on JWT Authentication: https://youtu.be/W8O-jlyH3k8

Ok, with that working we can update the view to also show the name and email in the view file backend/app/views/users/_user.json.jbuilder:

json.extract!(user, :id, :created_at, :email, :name, :updated_at, ) json.url user_url(user, format: :json)

Save the file then refresh the page in the browser to see your changes.

Setup API Versioning

Before we continue let's namespace our API endpoints so that we can easily modify versions later without breaking old clients.

Create a new folder called v1 in the backend/app/controllers directory. Inside that folder create a file called api_controller.rb:

module V1 class APIController < ApplicationController end end

By doing this it allows us to implement different authentication mechanisms and actions between versions easily, without breaking anything in previous versions.

Drag the backend/app/controllers/users_controller.rb file in to the v1 folder. Update it so that it looks like that:

module V1 class UsersController < APIController before_action :set_user, only: %i[ show update destroy ] # GET /users # GET /users.json def index @users = User.all end # GET /users/1 # GET /users/1.json def show end # POST /users # POST /users.json def create @user = User.new(user_params) if @user.save render :show, status: :created, location: @user else render json: @user.errors, status: :unprocessable_entity end end # PATCH/PUT /users/1 # PATCH/PUT /users/1.json def update if @user.update(user_params) render :show, status: :ok, location: @user else render json: @user.errors, status: :unprocessable_entity end end # DELETE /users/1 # DELETE /users/1.json def destroy @user.destroy! end private # Use callbacks to share common setup or constraints between actions. def set_user @user = User.find(params[:id]) end # Only allow a list of trusted parameters through. def user_params params.fetch(:user, {}) end end end

We need to update our routes backend/config/routes.rb to support the new changes:

Rails.application.routes.draw do # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check namespace "v1", defaults: { format: 'json' } do resources :users end end

Create a new folder called v1 in our views folder and move the users directory in to it. Update the index and show views to reflect this as well.

We also need to update our inflections backend/config/initializers/inflections.rb so that Rails can find our API namespace - (convention is to upcase the first character only):

# Be sure to restart your server when you modify this file. # Add new inflection rules using the following format. Inflections # are locale specific, and you may define rules for as many different # locales as you wish. All of these examples are active by default: # ActiveSupport::Inflector.inflections(:en) do |inflect| # inflect.plural /^(ox)$/i, "\\1en" # inflect.singular /^(ox)en/i, "\\1" # inflect.irregular "person", "people" # inflect.uncountable %w( fish sheep ) # end # These inflection rules are supported but not enabled by default: ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym "API" end

We will need to restart our server for the changes to take affect.

Displaying the data in our Frontend

With the API now setup we can jump back in to the frontend and render the results.

Open up the frontend/src/app/page.tsx file and clear out everything in between the main tags so that it looks like this:

'use client' export default function Home() { return ( <main className="flex min-h-screen flex-col items-center justify-between p-24"> Hello </main> ); }

Import useQuery and our apiRouter

'use client' import { useQuery } from "@tanstack/react-query"; import apiRouter from '@/api/router' export default function Home() { return ( <main className="flex min-h-screen flex-col items-center justify-between p-24"> Hello </main> ); }

Now we can add our query to get the users from our backend/API. You will notice that the apiRouter autocompletes which makes it easier to find the right query

'use client' import { useQuery } from "@tanstack/react-query"; import apiRouter from '@/api/router' export default function Home() { const { data } = useQuery({ queryKey: ['getUsers'], queryFn: apiRouter.users.getUsers }) return ( <main className="flex min-h-screen flex-col items-center justify-between p-24"> <ul> {data?.map((user) => { return ( <li key={user.id}> {user.name} - {user.email} </li> ) })} </ul> </main> ); }

You might get an error in the console:

Access to XMLHttpRequest at 'http://localhost:3002/v1/users' from origin 'http://localhost:3000' has been blocked by CORS policy

Let's fix it!

Cross-Origin Resource Sharing (CORS)

Let's update our Cross-Origin Resource Sharing (CORS) rules for the API so that our frontend can make requests if on a different domain.

In our Gemfile un-comment the rack-cors gem and run:

> bundle install

Open up the backend/config/initializers/cors.rb file and update it:

# Be sure to restart your server when you modify this file. # Avoid CORS issues when API is called from the frontend app. # Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests. # Read more: https://github.com/cyu/rack-cors Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins "*" resource "*", headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head] end end

Empty the Cache and perform a hard refresh and we should be back in business!

Bonus: Adding Types

There are a few ways of generating types for a Rails API but today I am just going to use a simple one as an example.

Install the gem - ts_schema

> bundle add ts_schema > rails generate ts_schema:install

Let's update the config file backend/config/initializers/ts_schema.rb quickly:

config.output = Rails.root.join("api-schema.d.ts") config.namespace = :APISchema config.schema_type = :type

Now we can run the command to generate our schema:

> rails ts_schema:generate

That will generate the schema file at backend/api-schema.d.ts

To use this in our frontend we simply edit the tsconfig.json file:

"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../backend/api-schema.d.ts"],

We can now use types in our API definitions:

import api from "./index" type Endpoints = { getUsers: () => Promise<APISchema.User[]> } const endpoints: Endpoints = { getUsers: async () => { return await api('users') }, } export default endpoints

Conclusion

And there you have it! A frontend running with Next.js that is connected to a Rails API. I would recommend watching my video on JWT Authentication next so that you can lock down your API.

Link to the repo: https://github.com/kengreeff/nextjs-rails

Catch you on the next one!

View More Posts

Portfolio built using the open source project by

Initium Studio

Create your own Next.js Portfolio with Contentful CMS by forking this repo onGitHub

Blog

|

Projects

|

Health

|

Recipes

|

About

|

Contact

2024 Greeff Consulting Pty Ltd