Our post on DRF views ended on an unresolved thread: every view we built - CountryListView, PurchaseViewSet, PurchasedItemDetailView - still needed something pointing an actual URL at it. We never built that part. This is where it gets resolved.
Writing the URLs by hand, for real this time
Let’s start with our full CRUD table. Purchase needs six operations wired up - list, create, retrieve, update, delete, and the custom refund action from last time. Written the way Django’s always done it - one path() per operation - that’s:
#purchases/urls.py
from django.urls import path
from .views import PurchaseListView, PurchaseDetailView, PurchaseRefundView
urlpatterns = [
path('purchases/', PurchaseListView.as_view()),
path('purchases/<int:pk>/', PurchaseDetailView.as_view()),
path('purchases/<int:pk>/refunds/', PurchaseRefundView.as_view()),
]
Three path() calls, but notice what’s hiding behind them: PurchaseListView needs get() and post() written out, PurchaseDetailView needs get(), put(), and delete(), and PurchaseRefundView needs its own post() - the exact eight-method breakdown from the DRF views article, still here, just organized under three URLs instead of laid bare. The URLs don’t remove the repetition, they just group it.
There’s a second problem worth naming, though - one that only shows up once you try to wire up the real PurchaseViewSet instead of these hand-written stand-ins. ViewSets don’t work like a plain APIView. You can’t just write PurchaseViewSet.as_view() - without a router, .as_view() needs an explicit dictionary mapping HTTP methods to the action they should call:
#purchases/urls.py, PurchaseViewSet wired up by hand
urlpatterns = [
path('purchases/', PurchaseViewSet.as_view({
'get': 'list',
'post': 'create',
})),
path('purchases/<int:pk>/', PurchaseViewSet.as_view({
'get': 'retrieve',
'put': 'update',
'patch': 'partial_update',
'delete': 'destroy',
})),
path('purchases/<int:pk>/refunds/', PurchaseViewSet.as_view({
'post': 'refunds',
})),
]
This is the actual cost of a ViewSet, wired up without a router - not just repetition, but a second kind of bookkeeping on top: for every URL, you’re the one deciding which HTTP method calls which method on the class. Get one of those mappings wrong - say 'put': 'destroy' - and nothing warns you; it just quietly deletes on what looks like an update.
What router.register() replaces
Here’s the same thing, using what’s actually in purchases/urls.py:
#purchases/urls.py
from rest_framework.routers import DefaultRouter
from django.urls import path, include
from .views import PurchaseViewSet, PurchasedItemDetailView
router = DefaultRouter()
router.register(r'purchases', PurchaseViewSet, basename='purchase')
urlpatterns = [
path('', include(router.urls)),
path('purchased-items/<int:pk>/', PurchasedItemDetailView.as_view(), name='purchased-item-detail'),
]
Two lines - router = DefaultRouter() and router.register(...) - replace both the three hand-written path() calls and the three method-mapping dictionaries above. The router already knows that a ModelViewSet has list/create/retrieve/update/partial_update/destroy, and it already knows how to map each one to the right HTTP method on the right URL. It also finds refunds automatically, because it’s decorated with @action - nothing about that URL was written by hand anywhere.
Notice what’s conspicuously absent from this file: PurchasedItemDetailView is still there, still wired with a plain path(), no router involved. That’s not an oversight - it’s the actual point.
The router only helps where there’s a ViewSet
Across our four tables, only one of them uses a router at all:
Purchase- full CRUD plusrefunds, aModelViewSet, registered withDefaultRouterCountry- list only, a plaingenerics.ListAPIView, wired withpath()PurchasedItem- retrieve and update only, a plaingenerics.RetrieveUpdateAPIView, wired withpath()ShippingStatus- one lookup by tracking number, a bareAPIView, wired withpath()
The router isn’t a general-purpose replacement for path() - it’s specifically for ViewSets, because a ViewSet is the one kind of view that bundles multiple actions together in a way a router can inspect and auto-wire. Country, PurchasedItem, and ShippingStatus were never candidates for a router in the first place; they’re single-purpose views, and path() was already the right, simplest tool for them - same as it’s always been.
Proof: the browsable API
DefaultRouter does one more thing worth mentioning before the screenshots - it builds a root view at /api/, listing every endpoint it registered. Visit it and all you’ll see is {"purchases": "http://127.0.0.1:8000/api/purchases/"} - one entry. Not countries, not purchased-items, not shipping-status. None of them are missing by accident; none of them were ever registered with the router, so the router has no way to know they exist. That single-entry list is built entirely from router.register() calls, nothing else.
/api/purchases/ - the list and create actions, both reachable from one URL, distinguished only by HTTP method:

/api/purchases/45/ - retrieve, update, and delete, same story:

And /api/purchases/45/refunds/ - the custom action, reachable at a URL nobody wrote by hand:

Worth being honest about what this actually proves: the refunds action itself is still a stub - return Response({"status": "refunded", "purchase": purchase.id}), nothing persisted, no real refund logic. What this screenshot proves is narrower and still real: the mechanism works. A method decorated with @action on a router-registered ViewSet gets a working URL, with the right HTTP method enforced, without a single line of URL configuration written by hand.
For contrast - /api/purchased-items/45/, never touched by a router:

Allow: GET, PUT, PATCH, HEAD, OPTIONS - no POST, no DELETE. That’s not the router being selective; there’s no router here at all, just RetrieveUpdateAPIView doing exactly what it was built to do, same as it would with or without a router existing anywhere in the project.
/api/countries/ makes the same point from a different angle - a table that never had a ViewSet in the first place, so a router was never even a candidate:

One last one, for the record rather than for the router argument specifically - /api/shipping-status/<tracking_number>/, the one table with no model, no queryset, and (like Country and PurchasedItem) no router:

Where this leaves us
Four tables, one router. Not because the other three were forgotten, but because a router only pays for itself when there’s a ViewSet bundling multiple actions together - and even then, what it’s actually buying you isn’t magic, it’s exactly the same path() calls and method-mapping dictionaries we wrote by hand earlier in this article, generated instead of typed.